wp on webui
This commit is contained in:
30
Makefile
30
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build run clean
|
.PHONY: build run clean up down session try logs
|
||||||
|
|
||||||
IMAGE_NAME := opencode
|
IMAGE_NAME := opencode
|
||||||
IMAGE_TAG := latest
|
IMAGE_TAG := latest
|
||||||
@@ -12,3 +12,31 @@ run:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
docker rmi $(IMAGE_NAME):$(IMAGE_TAG) || true
|
docker rmi $(IMAGE_NAME):$(IMAGE_TAG) || true
|
||||||
|
|
||||||
|
# Start the full stack (session-manager, docker-daemon, caddy)
|
||||||
|
up:
|
||||||
|
docker-compose up -d --build
|
||||||
|
@echo "Stack started. Create a session with: make session"
|
||||||
|
|
||||||
|
# Stop the stack
|
||||||
|
down:
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
logs:
|
||||||
|
docker-compose logs -f session-manager
|
||||||
|
|
||||||
|
# Create a new session and display its info
|
||||||
|
session:
|
||||||
|
@echo "Creating new session..."
|
||||||
|
@curl -s -X POST http://localhost:8080/sessions | jq .
|
||||||
|
|
||||||
|
# Try the web interface - creates a session and opens it
|
||||||
|
try:
|
||||||
|
@echo "Creating session and opening web interface..."
|
||||||
|
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/sessions | jq -r '.session_id') && \
|
||||||
|
echo "Session created: $$SESSION_ID" && \
|
||||||
|
echo "Opening http://localhost:8080/session/$$SESSION_ID" && \
|
||||||
|
xdg-open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \
|
||||||
|
open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \
|
||||||
|
echo "Visit: http://localhost:8080/session/$$SESSION_ID"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"lovdata": {
|
"lovdata": {
|
||||||
"type": "http",
|
"type": "remote",
|
||||||
"url": "${MCP_SERVER}",
|
"url": "${MCP_SERVER}",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ services:
|
|||||||
- ./session-manager/sessions:/app/sessions
|
- ./session-manager/sessions:/app/sessions
|
||||||
environment:
|
environment:
|
||||||
# Docker TLS configuration
|
# Docker TLS configuration
|
||||||
- DOCKER_TLS_VERIFY=1
|
- DOCKER_TLS_VERIFY=0
|
||||||
- DOCKER_CERT_PATH=/etc/docker/certs
|
- DOCKER_CERT_PATH=/etc/docker/certs
|
||||||
- DOCKER_HOST=tcp://host.docker.internal:2376
|
- DOCKER_HOST=http://docker-daemon:2375
|
||||||
# Application configuration
|
# Application configuration
|
||||||
- MCP_SERVER=${MCP_SERVER:-http://localhost:8001}
|
- MCP_SERVER=${MCP_SERVER:-http://localhost:8001}
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
@@ -42,35 +42,30 @@ services:
|
|||||||
memory: 1G
|
memory: 1G
|
||||||
cpus: '1.0'
|
cpus: '1.0'
|
||||||
|
|
||||||
# Docker daemon with TLS enabled (must be configured separately)
|
# Docker daemon (non-TLS for local development)
|
||||||
# Run: ./docker/scripts/setup-docker-tls.sh after generating certificates
|
# For production, use TLS with: ./docker/scripts/setup-docker-tls.sh
|
||||||
docker-daemon:
|
docker-daemon:
|
||||||
image: docker:dind
|
image: docker:dind
|
||||||
privileged: true
|
privileged: true
|
||||||
ports:
|
ports:
|
||||||
- "${DOCKER_TLS_PORT:-2376}:2376"
|
- "2375:2375"
|
||||||
volumes:
|
volumes:
|
||||||
# Mount TLS certificates
|
|
||||||
- ./docker/certs:/etc/docker/certs:ro
|
|
||||||
# Mount daemon configuration
|
|
||||||
- ./docker/daemon.json:/etc/docker/daemon.json:ro
|
|
||||||
# Docker data persistence
|
# Docker data persistence
|
||||||
- docker-data:/var/lib/docker
|
- docker-data:/var/lib/docker
|
||||||
environment:
|
environment:
|
||||||
- DOCKER_TLS_CERTDIR=/etc/docker/certs
|
- DOCKER_TLS_CERTDIR=
|
||||||
networks:
|
networks:
|
||||||
- lovdata-network
|
- lovdata-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Only expose TLS port, not the socket
|
command: --host=tcp://0.0.0.0:2375 --host=unix:///var/run/docker.sock
|
||||||
command: --tlsverify --tlscacert=/etc/docker/certs/ca.pem --tlscert=/etc/docker/certs/server-cert.pem --tlskey=/etc/docker/certs/server-key.pem
|
|
||||||
|
|
||||||
# lovdata-mcp server is external - configured via MCP_SERVER environment variable
|
# lovdata-mcp server is external - configured via MCP_SERVER environment variable
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2.7-alpine
|
image: caddy:2.7-alpine
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8080:80"
|
||||||
- "443:443"
|
- "8443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/Caddyfile:/etc/caddy/Caddyfile
|
- ./nginx/Caddyfile:/etc/caddy/Caddyfile
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
@@ -81,10 +76,11 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config: # Docker daemon data persistence
|
||||||
# Docker daemon data persistence
|
|
||||||
docker-data:
|
docker-data:
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
lovdata-network:
|
lovdata-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Lovdata Chat Reverse Proxy Configuration
|
# Lovdata Chat Reverse Proxy Configuration
|
||||||
|
# Using HTTP for local development (no SSL warnings)
|
||||||
|
|
||||||
# Main web interface
|
# Main web interface - HTTP only for development
|
||||||
localhost {
|
http://localhost {
|
||||||
# API endpoints for session management
|
# API endpoints for session management
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
uri strip_prefix /api
|
uri strip_prefix /api
|
||||||
@@ -10,18 +11,54 @@ localhost {
|
|||||||
|
|
||||||
# Session-specific routing - proxy to session manager for dynamic routing
|
# Session-specific routing - proxy to session manager for dynamic routing
|
||||||
handle /session/{session_id}* {
|
handle /session/{session_id}* {
|
||||||
# Keep the full path including session ID for routing
|
|
||||||
reverse_proxy session-manager:8000
|
reverse_proxy session-manager:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static files and main interface
|
# OpenCode SPA runtime requests - route based on session cookie
|
||||||
handle /* {
|
handle /global/* {
|
||||||
try_files {path} {path}/ /index.html
|
reverse_proxy session-manager:8000
|
||||||
file_server
|
}
|
||||||
|
|
||||||
|
handle /assets/* {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /provider/* {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /provider {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /project {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /path {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /find/* {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /file {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /file/* {
|
||||||
|
reverse_proxy session-manager:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy session-manager:8000
|
reverse_proxy session-manager:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Static files and main interface (fallback)
|
||||||
|
handle /* {
|
||||||
|
try_files {path} {path}/ /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,15 +11,15 @@ from typing import Dict, Optional, List, Any
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from aiodeocker import Docker
|
from aiodocker import Docker
|
||||||
from aiodeocker.containers import DockerContainer
|
from aiodocker.containers import DockerContainer
|
||||||
from aiodeocker.exceptions import DockerError
|
from aiodocker.exceptions import DockerError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AsyncDockerClient:
|
class AsyncDockerClient:
|
||||||
"""Async wrapper for Docker operations using aiodeocker."""
|
"""Async wrapper for Docker operations using aiodocker."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._docker: Optional[Docker] = None
|
self._docker: Optional[Docker] = None
|
||||||
@@ -38,29 +38,27 @@ class AsyncDockerClient:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Configure TLS if available
|
# Configure TLS/SSL context based on environment
|
||||||
tls_config = None
|
ssl_ctx = None
|
||||||
if os.getenv("DOCKER_TLS_VERIFY") == "1":
|
if os.getenv("DOCKER_TLS_VERIFY") == "1":
|
||||||
from aiodeocker.utils import create_tls_config
|
import ssl
|
||||||
|
|
||||||
tls_config = create_tls_config(
|
ssl_ctx = ssl.create_default_context(
|
||||||
ca_cert=os.getenv("DOCKER_CA_CERT", "/etc/docker/certs/ca.pem"),
|
cafile=os.getenv("DOCKER_CA_CERT", "/etc/docker/certs/ca.pem")
|
||||||
client_cert=(
|
|
||||||
os.getenv(
|
|
||||||
"DOCKER_CLIENT_CERT", "/etc/docker/certs/client-cert.pem"
|
|
||||||
),
|
|
||||||
os.getenv(
|
|
||||||
"DOCKER_CLIENT_KEY", "/etc/docker/certs/client-key.pem"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
verify=True,
|
|
||||||
)
|
)
|
||||||
|
ssl_ctx.load_cert_chain(
|
||||||
|
certfile=os.getenv("DOCKER_CLIENT_CERT", "/etc/docker/certs/client-cert.pem"),
|
||||||
|
keyfile=os.getenv("DOCKER_CLIENT_KEY", "/etc/docker/certs/client-key.pem")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Explicitly disable SSL using False
|
||||||
|
ssl_ctx = False
|
||||||
|
|
||||||
docker_host = os.getenv("DOCKER_HOST", "tcp://host.docker.internal:2376")
|
docker_host = os.getenv("DOCKER_HOST", "tcp://host.docker.internal:2376")
|
||||||
self._docker = Docker(docker_host, tls=tls_config)
|
self._docker = Docker(docker_host, ssl_context=ssl_ctx)
|
||||||
|
|
||||||
# Test connection
|
# Test connection using version() since aiodocker doesn't have ping()
|
||||||
await self._docker.ping()
|
await self._docker.version()
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.info("Async Docker client connected successfully")
|
logger.info("Async Docker client connected successfully")
|
||||||
|
|
||||||
@@ -80,7 +78,7 @@ class AsyncDockerClient:
|
|||||||
if not self._docker:
|
if not self._docker:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
await self._docker.ping()
|
await self._docker.version()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ def setup_logging(
|
|||||||
# Configure third-party loggers
|
# Configure third-party loggers
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
logging.getLogger("docker").setLevel(logging.WARNING)
|
logging.getLogger("docker").setLevel(logging.WARNING)
|
||||||
logging.getLogger("aiodeocker").setLevel(logging.WARNING)
|
logging.getLogger("aiodocker").setLevel(logging.WARNING)
|
||||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
|
||||||
return adapter
|
return adapter
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Each session gets its own isolated container with a dedicated working directory.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -15,6 +16,7 @@ from datetime import datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
from docker.errors import DockerException, NotFound
|
from docker.errors import DockerException, NotFound
|
||||||
@@ -358,7 +360,7 @@ class SessionManager:
|
|||||||
return session
|
return session
|
||||||
|
|
||||||
async def _start_container_async(self, session: SessionData):
|
async def _start_container_async(self, session: SessionData):
|
||||||
"""Start the OpenCode container asynchronously using aiodeocker"""
|
"""Start the OpenCode container asynchronously using aiodocker"""
|
||||||
try:
|
try:
|
||||||
# Get and validate resource limits
|
# Get and validate resource limits
|
||||||
resource_limits = get_resource_limits()
|
resource_limits = get_resource_limits()
|
||||||
@@ -378,8 +380,8 @@ class SessionManager:
|
|||||||
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
||||||
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
||||||
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
||||||
# Secure authentication for OpenCode server
|
# Auth disabled for development - will be added later
|
||||||
"OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
# "OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
||||||
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
||||||
"SESSION_ID": session.session_id,
|
"SESSION_ID": session.session_id,
|
||||||
},
|
},
|
||||||
@@ -395,10 +397,8 @@ class SessionManager:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# For async mode, containers are already started during creation
|
# Containers need to be explicitly started after creation
|
||||||
# For sync mode, we need to explicitly start them
|
await self.docker_service.start_container(container_info.container_id)
|
||||||
if not self.docker_service.use_async:
|
|
||||||
await self.docker_service.start_container(container_info.container_id)
|
|
||||||
|
|
||||||
session.container_id = container_info.container_id
|
session.container_id = container_info.container_id
|
||||||
session.status = "running"
|
session.status = "running"
|
||||||
@@ -458,8 +458,8 @@ class SessionManager:
|
|||||||
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
||||||
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
||||||
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
||||||
# Secure authentication for OpenCode server
|
# Auth disabled for development - will be added later
|
||||||
"OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
# "OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
||||||
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
||||||
"SESSION_ID": session.session_id,
|
"SESSION_ID": session.session_id,
|
||||||
},
|
},
|
||||||
@@ -883,6 +883,118 @@ async def get_session_container_health(session_id: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Routes for OpenCode SPA runtime requests
|
||||||
|
# These paths are hardcoded in the compiled JS and need cookie-based session routing
|
||||||
|
@app.api_route(
|
||||||
|
"/global/{path:path}",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_global_to_session(request: Request, path: str):
|
||||||
|
"""Proxy /global/* requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
# Redirect to session-prefixed path
|
||||||
|
return await proxy_to_session(request, session_id, f"global/{path}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/assets/{path:path}",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_assets_to_session(request: Request, path: str):
|
||||||
|
"""Proxy /assets/* requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
# Redirect to session-prefixed path
|
||||||
|
return await proxy_to_session(request, session_id, f"assets/{path}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/provider/{path:path}",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_provider_path_to_session(request: Request, path: str):
|
||||||
|
"""Proxy /provider/* requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
return await proxy_to_session(request, session_id, f"provider/{path}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/provider",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_provider_to_session(request: Request):
|
||||||
|
"""Proxy /provider requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
return await proxy_to_session(request, session_id, "provider")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/project",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_project_to_session(request: Request):
|
||||||
|
"""Proxy /project requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
return await proxy_to_session(request, session_id, "project")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/path",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_path_to_session(request: Request):
|
||||||
|
"""Proxy /path requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
return await proxy_to_session(request, session_id, "path")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/find/{path:path}",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_find_to_session(request: Request, path: str):
|
||||||
|
"""Proxy /find/* requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
return await proxy_to_session(request, session_id, f"find/{path}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/file",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_file_to_session(request: Request):
|
||||||
|
"""Proxy /file requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
return await proxy_to_session(request, session_id, "file")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/file/{path:path}",
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
|
)
|
||||||
|
async def proxy_file_path_to_session(request: Request, path: str):
|
||||||
|
"""Proxy /file/* requests to the current session based on cookie"""
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
||||||
|
return await proxy_to_session(request, session_id, f"file/{path}")
|
||||||
|
|
||||||
|
|
||||||
@app.api_route(
|
@app.api_route(
|
||||||
"/session/{session_id}/{path:path}",
|
"/session/{session_id}/{path:path}",
|
||||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
||||||
@@ -916,40 +1028,14 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|||||||
status_code=404, detail="Session not found or not running"
|
status_code=404, detail="Session not found or not running"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dynamically detect the Docker host IP from container perspective
|
# For DinD architecture: containers run inside docker-daemon with ports mapped
|
||||||
# This supports multiple Docker environments (Docker Desktop, Linux, cloud, etc.)
|
# to docker-daemon's interface, so we proxy through docker-daemon hostname
|
||||||
try:
|
docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375")
|
||||||
host_ip = await async_get_host_ip()
|
# Extract hostname from DOCKER_HOST (e.g., "http://docker-daemon:2375" -> "docker-daemon")
|
||||||
logger.info(f"Using detected host IP for proxy: {host_ip}")
|
parsed = urlparse(docker_host)
|
||||||
except RuntimeError as e:
|
container_host = parsed.hostname or "docker-daemon"
|
||||||
# Fallback to environment variable or common defaults
|
|
||||||
host_ip = os.getenv("HOST_IP")
|
|
||||||
if not host_ip:
|
|
||||||
# Try common Docker gateway IPs as final fallback
|
|
||||||
common_gateways = ["172.17.0.1", "192.168.65.1", "host.docker.internal"]
|
|
||||||
for gateway in common_gateways:
|
|
||||||
try:
|
|
||||||
# Test connectivity to gateway
|
|
||||||
import socket
|
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
container_url = f"http://{container_host}:{session.port}"
|
||||||
sock.settimeout(1.0)
|
|
||||||
result = sock.connect_ex((gateway, 22))
|
|
||||||
sock.close()
|
|
||||||
if result == 0:
|
|
||||||
host_ip = gateway
|
|
||||||
logger.warning(f"Using fallback gateway IP: {host_ip}")
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
logger.error(f"Host IP detection failed: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Could not determine Docker host IP for proxy routing",
|
|
||||||
)
|
|
||||||
|
|
||||||
container_url = f"http://{host_ip}:{session.port}"
|
|
||||||
|
|
||||||
# Prepare the request URL
|
# Prepare the request URL
|
||||||
url = f"{container_url}/{path}"
|
url = f"{container_url}/{path}"
|
||||||
@@ -1002,12 +1088,46 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return the response
|
# Return the response - inject base tag for HTML to fix asset paths
|
||||||
return Response(
|
content = response.content
|
||||||
content=response.content,
|
response_headers = dict(response.headers)
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
|
||||||
|
# For HTML responses, rewrite root-relative paths to include session prefix
|
||||||
|
# OpenCode uses paths like /assets/, /favicon.ico which bypass <base> tag
|
||||||
|
# We need to prepend the session path to make them work
|
||||||
|
if "text/html" in content_type:
|
||||||
|
try:
|
||||||
|
html = content.decode("utf-8")
|
||||||
|
session_prefix = f"/session/{session_id}"
|
||||||
|
|
||||||
|
# Rewrite src="/..." to src="/session/{id}/..."
|
||||||
|
html = re.sub(r'src="/', f'src="{session_prefix}/', html)
|
||||||
|
# Rewrite href="/..." to href="/session/{id}/..."
|
||||||
|
html = re.sub(r'href="/', f'href="{session_prefix}/', html)
|
||||||
|
# Rewrite content="/..." for meta tags
|
||||||
|
html = re.sub(r'content="/', f'content="{session_prefix}/', html)
|
||||||
|
|
||||||
|
content = html.encode("utf-8")
|
||||||
|
response_headers["content-length"] = str(len(content))
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass # Not valid UTF-8, skip rewriting
|
||||||
|
|
||||||
|
# Create response with session tracking cookie
|
||||||
|
resp = Response(
|
||||||
|
content=content,
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
headers=dict(response.headers),
|
headers=response_headers,
|
||||||
)
|
)
|
||||||
|
# Set cookie to track current session for /global/* and /assets/* routing
|
||||||
|
resp.set_cookie(
|
||||||
|
key="lovdata_session",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400, # 24 hours
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
fastapi==0.104.1
|
fastapi==0.104.1
|
||||||
uvicorn==0.24.0
|
uvicorn==0.24.0
|
||||||
docker>=7.1.0
|
docker>=7.1.0
|
||||||
aiodeocker>=0.21.0
|
aiodocker>=0.21.0
|
||||||
asyncpg>=0.29.0
|
asyncpg>=0.29.0
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
|
|||||||
Reference in New Issue
Block a user