wp on webui

This commit is contained in:
2026-02-02 23:37:11 +01:00
parent ce24e0caa0
commit 5e1cb64a81
8 changed files with 278 additions and 99 deletions

View File

@@ -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"

View File

@@ -14,7 +14,7 @@
"enabled": true "enabled": true
}, },
"lovdata": { "lovdata": {
"type": "http", "type": "remote",
"url": "${MCP_SERVER}", "url": "${MCP_SERVER}",
"enabled": true "enabled": true
} }

View File

@@ -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

View File

@@ -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
}
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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,9 +397,7 @@ 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
if not self.docker_service.use_async:
await self.docker_service.start_container(container_info.container_id) await self.docker_service.start_container(container_info.container_id)
session.container_id = container_info.container_id session.container_id = container_info.container_id
@@ -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

View File

@@ -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