connected zen

This commit is contained in:
2026-02-03 00:36:22 +01:00
parent 9281c0e02a
commit 05aa70c4af
10 changed files with 582 additions and 4 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
__pycache__ __pycache__
.env

View File

@@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \
RUN curl -fsSL https://opencode.ai/install | bash RUN curl -fsSL https://opencode.ai/install | bash
# Copy OpenCode configuration # Copy OpenCode configuration
COPY ../config_opencode /root/.config/opencode COPY config_opencode /root/.config/opencode
# Copy session manager # Copy session manager
COPY . /app COPY . /app
@@ -19,8 +19,8 @@ COPY . /app
# Install Python dependencies # Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Create working directory # Create working directory and auth directory
RUN mkdir -p /app/somedir RUN mkdir -p /app/somedir /root/.local/share/opencode
# Expose port # Expose port
EXPOSE 8080 EXPOSE 8080
@@ -28,5 +28,5 @@ EXPOSE 8080
# Set environment variables # Set environment variables
ENV PYTHONPATH=/app 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"] CMD ["/bin/bash", "-c", "source /root/.bashrc && opencode serve --hostname 0.0.0.0 --port 8080 --mdns"]

View File

@@ -2,6 +2,7 @@
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"theme": "opencode", "theme": "opencode",
"autoupdate": false, "autoupdate": false,
"model": "opencode/kimi-k2.5-free",
"plugin": [], "plugin": [],
"mcp": { "mcp": {
"sequential-thinking": { "sequential-thinking": {

View File

@@ -22,6 +22,7 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- ZEN_API_KEY=${ZEN_API_KEY:-}
# Certificate paths (configurable via environment) # Certificate paths (configurable via environment)
- DOCKER_CA_CERT=${DOCKER_CA_CERT:-/etc/docker/certs/ca.pem} - DOCKER_CA_CERT=${DOCKER_CA_CERT:-/etc/docker/certs/ca.pem}
- DOCKER_CLIENT_CERT=${DOCKER_CLIENT_CERT:-/etc/docker/certs/client-cert.pem} - DOCKER_CLIENT_CERT=${DOCKER_CLIENT_CERT:-/etc/docker/certs/client-cert.pem}

View File

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

View File

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

View File

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

View File

@@ -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)}",
)

View File

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

View File

@@ -211,6 +211,7 @@ 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", ""),
"OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""),
"SESSION_AUTH_TOKEN": session.auth_token or "", "SESSION_AUTH_TOKEN": session.auth_token or "",
"SESSION_ID": session.session_id, "SESSION_ID": session.session_id,
}, },
@@ -280,6 +281,7 @@ 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", ""),
"OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""),
"SESSION_AUTH_TOKEN": session.auth_token or "", "SESSION_AUTH_TOKEN": session.auth_token or "",
"SESSION_ID": session.session_id, "SESSION_ID": session.session_id,
}, },