From 5e1cb64a8126f131726d7daa319a57f74dc7c4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B8rn=20Lindahl?= Date: Mon, 2 Feb 2026 23:37:11 +0100 Subject: [PATCH] wp on webui --- Makefile | 30 +++- config_opencode/opencode.jsonc | 4 +- docker-compose.yml | 30 ++-- nginx/Caddyfile | 51 +++++- session-manager/async_docker_client.py | 44 +++-- session-manager/logging_config.py | 2 +- session-manager/main.py | 214 +++++++++++++++++++------ session-manager/requirements.txt | 2 +- 8 files changed, 278 insertions(+), 99 deletions(-) diff --git a/Makefile b/Makefile index a405a7e..2f07329 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run clean +.PHONY: build run clean up down session try logs IMAGE_NAME := opencode IMAGE_TAG := latest @@ -12,3 +12,31 @@ run: clean: 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" diff --git a/config_opencode/opencode.jsonc b/config_opencode/opencode.jsonc index 7b2095a..f7052be 100644 --- a/config_opencode/opencode.jsonc +++ b/config_opencode/opencode.jsonc @@ -14,9 +14,9 @@ "enabled": true }, "lovdata": { - "type": "http", + "type": "remote", "url": "${MCP_SERVER}", "enabled": true } } -} +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d658c43..7c0580f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,9 @@ services: - ./session-manager/sessions:/app/sessions environment: # Docker TLS configuration - - DOCKER_TLS_VERIFY=1 + - DOCKER_TLS_VERIFY=0 - DOCKER_CERT_PATH=/etc/docker/certs - - DOCKER_HOST=tcp://host.docker.internal:2376 + - DOCKER_HOST=http://docker-daemon:2375 # Application configuration - MCP_SERVER=${MCP_SERVER:-http://localhost:8001} - OPENAI_API_KEY=${OPENAI_API_KEY:-} @@ -42,35 +42,30 @@ services: memory: 1G cpus: '1.0' - # Docker daemon with TLS enabled (must be configured separately) - # Run: ./docker/scripts/setup-docker-tls.sh after generating certificates + # Docker daemon (non-TLS for local development) + # For production, use TLS with: ./docker/scripts/setup-docker-tls.sh docker-daemon: image: docker:dind privileged: true ports: - - "${DOCKER_TLS_PORT:-2376}:2376" + - "2375:2375" 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:/var/lib/docker environment: - - DOCKER_TLS_CERTDIR=/etc/docker/certs + - DOCKER_TLS_CERTDIR= networks: - lovdata-network restart: unless-stopped - # Only expose TLS port, not the socket - command: --tlsverify --tlscacert=/etc/docker/certs/ca.pem --tlscert=/etc/docker/certs/server-cert.pem --tlskey=/etc/docker/certs/server-key.pem + command: --host=tcp://0.0.0.0:2375 --host=unix:///var/run/docker.sock # lovdata-mcp server is external - configured via MCP_SERVER environment variable caddy: image: caddy:2.7-alpine ports: - - "80:80" - - "443:443" + - "8080:80" + - "8443:443" volumes: - ./nginx/Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data @@ -81,10 +76,11 @@ services: volumes: caddy_data: - caddy_config: - # Docker daemon data persistence + caddy_config: # Docker daemon data persistence + docker-data: + networks: lovdata-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/nginx/Caddyfile b/nginx/Caddyfile index e1cb088..cbb9f26 100644 --- a/nginx/Caddyfile +++ b/nginx/Caddyfile @@ -1,7 +1,8 @@ # Lovdata Chat Reverse Proxy Configuration +# Using HTTP for local development (no SSL warnings) -# Main web interface -localhost { +# Main web interface - HTTP only for development +http://localhost { # API endpoints for session management handle /api/* { uri strip_prefix /api @@ -10,18 +11,54 @@ localhost { # Session-specific routing - proxy to session manager for dynamic routing handle /session/{session_id}* { - # Keep the full path including session ID for routing reverse_proxy session-manager:8000 } - # Static files and main interface - handle /* { - try_files {path} {path}/ /index.html - file_server + # OpenCode SPA runtime requests - route based on session cookie + handle /global/* { + reverse_proxy session-manager:8000 + } + + 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 handle /health { reverse_proxy session-manager:8000 } + + # Static files and main interface (fallback) + handle /* { + try_files {path} {path}/ /index.html + file_server + } } \ No newline at end of file diff --git a/session-manager/async_docker_client.py b/session-manager/async_docker_client.py index fcc18c2..5c00b0d 100644 --- a/session-manager/async_docker_client.py +++ b/session-manager/async_docker_client.py @@ -11,15 +11,15 @@ from typing import Dict, Optional, List, Any from contextlib import asynccontextmanager import os -from aiodeocker import Docker -from aiodeocker.containers import DockerContainer -from aiodeocker.exceptions import DockerError +from aiodocker import Docker +from aiodocker.containers import DockerContainer +from aiodocker.exceptions import DockerError logger = logging.getLogger(__name__) class AsyncDockerClient: - """Async wrapper for Docker operations using aiodeocker.""" + """Async wrapper for Docker operations using aiodocker.""" def __init__(self): self._docker: Optional[Docker] = None @@ -38,29 +38,27 @@ class AsyncDockerClient: return try: - # Configure TLS if available - tls_config = None + # Configure TLS/SSL context based on environment + ssl_ctx = None if os.getenv("DOCKER_TLS_VERIFY") == "1": - from aiodeocker.utils import create_tls_config - - tls_config = create_tls_config( - ca_cert=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, + import ssl + + ssl_ctx = ssl.create_default_context( + cafile=os.getenv("DOCKER_CA_CERT", "/etc/docker/certs/ca.pem") ) + 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") - self._docker = Docker(docker_host, tls=tls_config) + self._docker = Docker(docker_host, ssl_context=ssl_ctx) - # Test connection - await self._docker.ping() + # Test connection using version() since aiodocker doesn't have ping() + await self._docker.version() self._connected = True logger.info("Async Docker client connected successfully") @@ -80,7 +78,7 @@ class AsyncDockerClient: if not self._docker: return False try: - await self._docker.ping() + await self._docker.version() return True except Exception: return False diff --git a/session-manager/logging_config.py b/session-manager/logging_config.py index ea9ee16..1edde19 100644 --- a/session-manager/logging_config.py +++ b/session-manager/logging_config.py @@ -208,7 +208,7 @@ def setup_logging( # Configure third-party loggers logging.getLogger("httpx").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) return adapter diff --git a/session-manager/main.py b/session-manager/main.py index c6e8426..f3e850e 100644 --- a/session-manager/main.py +++ b/session-manager/main.py @@ -6,6 +6,7 @@ Each session gets its own isolated container with a dedicated working directory. """ import os +import re import uuid import json import asyncio @@ -15,6 +16,7 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Dict, Optional, List from contextlib import asynccontextmanager +from urllib.parse import urlparse import docker from docker.errors import DockerException, NotFound @@ -358,7 +360,7 @@ class SessionManager: return session async def _start_container_async(self, session: SessionData): - """Start the OpenCode container asynchronously using aiodeocker""" + """Start the OpenCode container asynchronously using aiodocker""" try: # Get and validate resource limits resource_limits = get_resource_limits() @@ -378,8 +380,8 @@ class SessionManager: "OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""), "ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""), "GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""), - # Secure authentication for OpenCode server - "OPENCODE_SERVER_PASSWORD": session.auth_token or "", + # Auth disabled for development - will be added later + # "OPENCODE_SERVER_PASSWORD": session.auth_token or "", "SESSION_AUTH_TOKEN": session.auth_token or "", "SESSION_ID": session.session_id, }, @@ -395,10 +397,8 @@ class SessionManager: }, ) - # For async mode, containers are already started during creation - # For sync mode, we need to explicitly start them - if not self.docker_service.use_async: - await self.docker_service.start_container(container_info.container_id) + # Containers need to be explicitly started after creation + await self.docker_service.start_container(container_info.container_id) session.container_id = container_info.container_id session.status = "running" @@ -458,8 +458,8 @@ class SessionManager: "OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""), "ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""), "GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""), - # Secure authentication for OpenCode server - "OPENCODE_SERVER_PASSWORD": session.auth_token or "", + # Auth disabled for development - will be added later + # "OPENCODE_SERVER_PASSWORD": session.auth_token or "", "SESSION_AUTH_TOKEN": session.auth_token or "", "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( "/session/{session_id}/{path:path}", 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" ) - # Dynamically detect the Docker host IP from container perspective - # This supports multiple Docker environments (Docker Desktop, Linux, cloud, etc.) - try: - host_ip = await async_get_host_ip() - logger.info(f"Using detected host IP for proxy: {host_ip}") - except RuntimeError as e: - # 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) - 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}" + # For DinD architecture: containers run inside docker-daemon with ports mapped + # to docker-daemon's interface, so we proxy through docker-daemon hostname + docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375") + # Extract hostname from DOCKER_HOST (e.g., "http://docker-daemon:2375" -> "docker-daemon") + parsed = urlparse(docker_host) + container_host = parsed.hostname or "docker-daemon" + + container_url = f"http://{container_host}:{session.port}" # Prepare the request URL 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, ) - # Return the response - return Response( - content=response.content, + # Return the response - inject base tag for HTML to fix asset paths + 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 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, - 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: duration_ms = (time.time() - start_time) * 1000 diff --git a/session-manager/requirements.txt b/session-manager/requirements.txt index acac2ea..c5c68ee 100644 --- a/session-manager/requirements.txt +++ b/session-manager/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.104.1 uvicorn==0.24.0 docker>=7.1.0 -aiodeocker>=0.21.0 +aiodocker>=0.21.0 asyncpg>=0.29.0 pydantic==2.5.0 python-multipart==0.0.6