diff --git a/.gitignore b/.gitignore index bee8a64..112f331 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__ +.env diff --git a/Dockerfile b/Dockerfile index feaa01f..60d2170 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \ RUN curl -fsSL https://opencode.ai/install | bash # Copy OpenCode configuration -COPY ../config_opencode /root/.config/opencode +COPY config_opencode /root/.config/opencode # Copy session manager COPY . /app @@ -19,8 +19,8 @@ COPY . /app # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Create working directory -RUN mkdir -p /app/somedir +# Create working directory and auth directory +RUN mkdir -p /app/somedir /root/.local/share/opencode # Expose port EXPOSE 8080 @@ -28,5 +28,5 @@ EXPOSE 8080 # Set environment variables ENV PYTHONPATH=/app -# Start OpenCode server +# Start OpenCode server (OPENCODE_API_KEY passed via environment) CMD ["/bin/bash", "-c", "source /root/.bashrc && opencode serve --hostname 0.0.0.0 --port 8080 --mdns"] \ No newline at end of file diff --git a/config_opencode/opencode.jsonc b/config_opencode/opencode.jsonc index f7052be..0a43e34 100644 --- a/config_opencode/opencode.jsonc +++ b/config_opencode/opencode.jsonc @@ -2,6 +2,7 @@ "$schema": "https://opencode.ai/config.json", "theme": "opencode", "autoupdate": false, + "model": "opencode/kimi-k2.5-free", "plugin": [], "mcp": { "sequential-thinking": { diff --git a/docker-compose.yml b/docker-compose.yml index 7c0580f..37dff52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - OPENAI_API_KEY=${OPENAI_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} + - ZEN_API_KEY=${ZEN_API_KEY:-} # Certificate paths (configurable via environment) - DOCKER_CA_CERT=${DOCKER_CA_CERT:-/etc/docker/certs/ca.pem} - DOCKER_CLIENT_CERT=${DOCKER_CLIENT_CERT:-/etc/docker/certs/client-cert.pem} diff --git a/session-manager/routes/__init__.py b/session-manager/routes/__init__.py new file mode 100644 index 0000000..71bcdf4 --- /dev/null +++ b/session-manager/routes/__init__.py @@ -0,0 +1,6 @@ +from .sessions import router as sessions_router +from .auth import router as auth_router +from .health import router as health_router +from .proxy import router as proxy_router + +__all__ = ["sessions_router", "auth_router", "health_router", "proxy_router"] diff --git a/session-manager/routes/auth.py b/session-manager/routes/auth.py new file mode 100644 index 0000000..ed457df --- /dev/null +++ b/session-manager/routes/auth.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, HTTPException + +from session_manager import session_manager +from session_auth import ( + get_session_auth_info as get_auth_info, + list_active_auth_sessions, + _session_token_manager, +) + +router = APIRouter(tags=["auth"]) + + +@router.get("/sessions/{session_id}/auth") +async def get_session_auth_info(session_id: str): + session = await session_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + auth_info = get_auth_info(session_id) + if not auth_info: + raise HTTPException(status_code=404, detail="Authentication info not found") + + return { + "session_id": session_id, + "auth_info": auth_info, + "has_token": session.auth_token is not None, + } + + +@router.post("/sessions/{session_id}/auth/rotate") +async def rotate_session_token(session_id: str): + session = await session_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + new_token = _session_token_manager.rotate_session_token(session_id) + if not new_token: + raise HTTPException(status_code=404, detail="Failed to rotate token") + + session.auth_token = new_token + session_manager._save_sessions() + + return { + "session_id": session_id, + "new_token": new_token, + "message": "Token rotated successfully", + } + + +@router.get("/auth/sessions") +async def list_authenticated_sessions(): + sessions = list_active_auth_sessions() + return { + "active_auth_sessions": len(sessions), + "sessions": sessions, + } diff --git a/session-manager/routes/health.py b/session-manager/routes/health.py new file mode 100644 index 0000000..1a6a473 --- /dev/null +++ b/session-manager/routes/health.py @@ -0,0 +1,149 @@ +from datetime import datetime + +from fastapi import APIRouter, HTTPException + +from config import ( + CONTAINER_MEMORY_LIMIT, + CONTAINER_CPU_QUOTA, + CONTAINER_CPU_PERIOD, + MAX_CONCURRENT_SESSIONS, + USE_ASYNC_DOCKER, + USE_DATABASE_STORAGE, +) +from session_manager import session_manager +from host_ip_detector import async_get_host_ip +from resource_manager import check_system_resources +from http_pool import get_connection_pool_stats +from database import get_database_stats +from container_health import get_container_health_stats, get_container_health_history +from logging_config import get_logger + +logger = get_logger(__name__) + +router = APIRouter(tags=["health"]) + + +@router.get("/health/container") +async def get_container_health(): + stats = get_container_health_stats() + return stats + + +@router.get("/health/container/{session_id}") +async def get_session_container_health(session_id: str): + session = await session_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + stats = get_container_health_stats(session_id) + history = get_container_health_history(session_id, limit=20) + + return { + "session_id": session_id, + "container_id": session.container_id, + "stats": stats.get(f"session_{session_id}", {}), + "recent_history": history, + } + + +@router.get("/health") +async def health_check(): + docker_ok = False + host_ip_ok = False + detected_host_ip = None + resource_status = {} + http_pool_stats = {} + + try: + docker_ok = await session_manager.docker_service.ping() + except Exception as e: + logger.warning(f"Docker health check failed: {e}") + docker_ok = False + + try: + detected_host_ip = await async_get_host_ip() + host_ip_ok = True + except Exception as e: + logger.warning(f"Host IP detection failed: {e}") + host_ip_ok = False + + try: + resource_status = check_system_resources() + except Exception as e: + logger.warning("Resource monitoring failed", extra={"error": str(e)}) + resource_status = {"error": str(e)} + + try: + http_pool_stats = await get_connection_pool_stats() + except Exception as e: + logger.warning("HTTP pool stats failed", extra={"error": str(e)}) + http_pool_stats = {"error": str(e)} + + database_status = {} + if USE_DATABASE_STORAGE: + try: + database_status = await get_database_stats() + except Exception as e: + logger.warning("Database stats failed", extra={"error": str(e)}) + database_status = {"status": "error", "error": str(e)} + + container_health_stats = {} + try: + container_health_stats = get_container_health_stats() + except Exception as e: + logger.warning("Container health stats failed", extra={"error": str(e)}) + container_health_stats = {"error": str(e)} + + resource_alerts = ( + resource_status.get("alerts", []) if isinstance(resource_status, dict) else [] + ) + critical_alerts = [ + a + for a in resource_alerts + if isinstance(a, dict) and a.get("level") == "critical" + ] + + http_healthy = ( + http_pool_stats.get("status") == "healthy" + if isinstance(http_pool_stats, dict) + else False + ) + + if critical_alerts or not (docker_ok and host_ip_ok and http_healthy): + status = "unhealthy" + elif resource_alerts: + status = "degraded" + else: + status = "healthy" + + health_data = { + "status": status, + "docker": docker_ok, + "docker_mode": "async" if USE_ASYNC_DOCKER else "sync", + "host_ip_detection": host_ip_ok, + "detected_host_ip": detected_host_ip, + "http_connection_pool": http_pool_stats, + "storage_backend": "database" if USE_DATABASE_STORAGE else "json_file", + "active_sessions": len( + [s for s in session_manager.sessions.values() if s.status == "running"] + ), + "resource_limits": { + "memory_limit": CONTAINER_MEMORY_LIMIT, + "cpu_quota": CONTAINER_CPU_QUOTA, + "cpu_period": CONTAINER_CPU_PERIOD, + "max_concurrent_sessions": MAX_CONCURRENT_SESSIONS, + }, + "system_resources": resource_status.get("system_resources", {}) + if isinstance(resource_status, dict) + else {}, + "resource_alerts": resource_alerts, + "timestamp": datetime.now().isoformat(), + } + + if USE_DATABASE_STORAGE and database_status: + health_data["database"] = database_status + + if container_health_stats: + health_data["container_health"] = container_health_stats + + return health_data diff --git a/session-manager/routes/proxy.py b/session-manager/routes/proxy.py new file mode 100644 index 0000000..1e0e275 --- /dev/null +++ b/session-manager/routes/proxy.py @@ -0,0 +1,241 @@ +import os +import re +import time +from urllib.parse import urlparse + +from fastapi import APIRouter, HTTPException, Request, Response +import httpx + +from session_manager import session_manager +from http_pool import make_http_request +from logging_config import ( + RequestContext, + log_request, + log_session_operation, + log_security_event, +) + +router = APIRouter(tags=["proxy"]) + +ALL_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] + + +def get_session_from_cookie(request: Request) -> str: + 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 session_id + + +@router.api_route("/global/{path:path}", methods=ALL_METHODS) +async def proxy_global_to_session(request: Request, path: str): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, f"global/{path}") + + +@router.api_route("/assets/{path:path}", methods=ALL_METHODS) +async def proxy_assets_to_session(request: Request, path: str): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, f"assets/{path}") + + +@router.api_route("/provider/{path:path}", methods=ALL_METHODS) +async def proxy_provider_path_to_session(request: Request, path: str): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, f"provider/{path}") + + +@router.api_route("/provider", methods=ALL_METHODS) +async def proxy_provider_to_session(request: Request): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, "provider") + + +@router.api_route("/project", methods=ALL_METHODS) +async def proxy_project_to_session(request: Request): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, "project") + + +@router.api_route("/path", methods=ALL_METHODS) +async def proxy_path_to_session(request: Request): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, "path") + + +@router.api_route("/find/{path:path}", methods=ALL_METHODS) +async def proxy_find_to_session(request: Request, path: str): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, f"find/{path}") + + +@router.api_route("/file", methods=ALL_METHODS) +async def proxy_file_to_session(request: Request): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, "file") + + +@router.api_route("/file/{path:path}", methods=ALL_METHODS) +async def proxy_file_path_to_session(request: Request, path: str): + session_id = get_session_from_cookie(request) + return await proxy_to_session(request, session_id, f"file/{path}") + + +@router.api_route("/session/{session_id}/{path:path}", methods=ALL_METHODS) +async def proxy_to_session(request: Request, session_id: str, path: str): + start_time = time.time() + + with RequestContext(): + log_request( + request.method, + f"/session/{session_id}/{path}", + 200, + 0, + operation="proxy_start", + session_id=session_id, + ) + + session = await session_manager.get_session(session_id) + if not session or session.status != "running": + duration_ms = (time.time() - start_time) * 1000 + log_request( + request.method, + f"/session/{session_id}/{path}", + 404, + duration_ms, + session_id=session_id, + error="Session not found or not running", + ) + raise HTTPException( + status_code=404, detail="Session not found or not running" + ) + + docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375") + parsed = urlparse(docker_host) + container_host = parsed.hostname or "docker-daemon" + + container_url = f"http://{container_host}:{session.port}" + + url = f"{container_url}/{path}" + if request.url.query: + url += f"?{request.url.query}" + + body = await request.body() + + headers = dict(request.headers) + headers.pop("host", None) + + if session.auth_token: + headers["Authorization"] = f"Bearer {session.auth_token}" + headers["X-Session-Token"] = session.auth_token + headers["X-Session-ID"] = session.session_id + + try: + log_session_operation( + session_id, "proxy_request", method=request.method, path=path + ) + + response = await make_http_request( + method=request.method, + url=url, + headers=headers, + content=body, + ) + + duration_ms = (time.time() - start_time) * 1000 + log_request( + request.method, + f"/session/{session_id}/{path}", + response.status_code, + duration_ms, + session_id=session_id, + operation="proxy_complete", + ) + + log_security_event( + "proxy_access", + "info", + session_id=session_id, + method=request.method, + path=path, + status_code=response.status_code, + ) + + content = response.content + response_headers = dict(response.headers) + content_type = response.headers.get("content-type", "") + + if "text/html" in content_type: + try: + html = content.decode("utf-8") + session_prefix = f"/session/{session_id}" + + html = re.sub(r'src="/', f'src="{session_prefix}/', html) + html = re.sub(r'href="/', f'href="{session_prefix}/', html) + 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 + + resp = Response( + content=content, + status_code=response.status_code, + headers=response_headers, + ) + resp.set_cookie( + key="lovdata_session", + value=session_id, + httponly=True, + samesite="lax", + max_age=86400, + ) + return resp + + except httpx.TimeoutException as e: + duration_ms = (time.time() - start_time) * 1000 + log_request( + request.method, + f"/session/{session_id}/{path}", + 504, + duration_ms, + session_id=session_id, + error="timeout", + ) + log_security_event( + "proxy_timeout", + "warning", + session_id=session_id, + method=request.method, + path=path, + error=str(e), + ) + raise HTTPException( + status_code=504, detail="Request to session container timed out" + ) + except httpx.RequestError as e: + duration_ms = (time.time() - start_time) * 1000 + log_request( + request.method, + f"/session/{session_id}/{path}", + 502, + duration_ms, + session_id=session_id, + error=str(e), + ) + log_security_event( + "proxy_connection_error", + "error", + session_id=session_id, + method=request.method, + path=path, + error=str(e), + ) + raise HTTPException( + status_code=502, + detail=f"Failed to connect to session container: {str(e)}", + ) diff --git a/session-manager/routes/sessions.py b/session-manager/routes/sessions.py new file mode 100644 index 0000000..5245fcb --- /dev/null +++ b/session-manager/routes/sessions.py @@ -0,0 +1,121 @@ +import time +from typing import List + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Request + +from models import SessionData +from session_manager import session_manager +from session_auth import revoke_session_auth_token +from logging_config import ( + RequestContext, + log_performance, + log_request, + log_session_operation, +) + +router = APIRouter(tags=["sessions"]) + + +@router.post("/sessions", response_model=SessionData) +async def create_session(request: Request): + start_time = time.time() + + with RequestContext(): + try: + log_request("POST", "/sessions", 200, 0, operation="create_session_start") + + session = await session_manager.create_session() + + duration_ms = (time.time() - start_time) * 1000 + log_session_operation( + session.session_id, "created", duration_ms=duration_ms + ) + log_performance( + "create_session", duration_ms, session_id=session.session_id + ) + + return session + except HTTPException as e: + duration_ms = (time.time() - start_time) * 1000 + log_request( + "POST", "/sessions", e.status_code, duration_ms, error=str(e.detail) + ) + raise + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + log_request("POST", "/sessions", 500, duration_ms, error=str(e)) + raise HTTPException( + status_code=500, detail=f"Failed to create session: {str(e)}" + ) + + +@router.get("/sessions/{session_id}", response_model=SessionData) +async def get_session(session_id: str, request: Request): + start_time = time.time() + + with RequestContext(): + try: + log_request( + "GET", f"/sessions/{session_id}", 200, 0, operation="get_session_start" + ) + + session = await session_manager.get_session(session_id) + if not session: + duration_ms = (time.time() - start_time) * 1000 + log_request( + "GET", + f"/sessions/{session_id}", + 404, + duration_ms, + session_id=session_id, + ) + raise HTTPException(status_code=404, detail="Session not found") + + duration_ms = (time.time() - start_time) * 1000 + log_request( + "GET", + f"/sessions/{session_id}", + 200, + duration_ms, + session_id=session_id, + ) + log_session_operation(session_id, "accessed", duration_ms=duration_ms) + + return session + except HTTPException: + raise + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + log_request( + "GET", f"/sessions/{session_id}", 500, duration_ms, error=str(e) + ) + raise HTTPException( + status_code=500, detail=f"Failed to get session: {str(e)}" + ) + + +@router.get("/sessions", response_model=List[SessionData]) +async def list_sessions(): + return await session_manager.list_sessions() + + +@router.delete("/sessions/{session_id}") +async def delete_session(session_id: str, background_tasks: BackgroundTasks): + session = await session_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + revoke_session_auth_token(session_id) + + background_tasks.add_task(session_manager.cleanup_expired_sessions) + + del session_manager.sessions[session_id] + session_manager._save_sessions() + + return {"message": f"Session {session_id} scheduled for deletion"} + + +@router.post("/cleanup") +async def trigger_cleanup(): + await session_manager.cleanup_expired_sessions() + return {"message": "Cleanup completed"} diff --git a/session-manager/session_manager.py b/session-manager/session_manager.py index ed6d1c1..825be70 100644 --- a/session-manager/session_manager.py +++ b/session-manager/session_manager.py @@ -211,6 +211,7 @@ 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", ""), + "OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""), "SESSION_AUTH_TOKEN": session.auth_token or "", "SESSION_ID": session.session_id, }, @@ -280,6 +281,7 @@ 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", ""), + "OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""), "SESSION_AUTH_TOKEN": session.auth_token or "", "SESSION_ID": session.session_id, },