diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8c19db --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Lovdata Chat Environment Configuration + +# MCP Server Configuration +# URL of the external lovdata MCP server +MCP_SERVER=http://localhost:8001 + +# LLM API Keys (optional - at least one required) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= + +# Development Settings +# These are configured in docker-compose.yml +# DOCKER_HOST=tcp://docker-daemon:2376 +# DOCKER_TLS_VERIFY=1 +# DOCKER_CERT_PATH=/certs \ No newline at end of file diff --git a/README-setup.md b/README-setup.md index 25056fb..d177a7a 100644 --- a/README-setup.md +++ b/README-setup.md @@ -1,40 +1,53 @@ # Lovdata Chat Development Environment -This setup creates a container-per-visitor architecture for the Norwegian legal research chat interface. +This setup creates a container-per-visitor architecture for the Norwegian legal research chat interface with secure TLS-based Docker communication. ## Quick Start -1. **Set up environment variables:** +1. **Generate TLS certificates for secure Docker communication:** ```bash - cp .env.example .env - # Edit .env with your API keys + ./generate-certs.sh ``` -2. **Start the services:** +2. **Set up environment variables:** + ```bash + cp .env.example .env + # Edit .env with your API keys and MCP server URL + ``` + +3. **Start the services:** ```bash docker-compose up --build ``` -3. **Create a session:** +4. **Create a session:** ```bash curl http://localhost/api/sessions -X POST ``` -4. **Access the chat interface:** +5. **Access the chat interface:** Open the returned URL in your browser ## Architecture -- **session-manager**: FastAPI service managing container lifecycles -- **lovdata-mcp**: Placeholder for Norwegian legal research MCP server -- **caddy**: Reverse proxy for routing requests to session containers +- **session-manager**: FastAPI service managing container lifecycles with TLS-secured Docker communication +- **docker-daemon**: Docker-in-Docker daemon with TLS certificates for secure container management +- **lovdata-mcp**: External Norwegian legal research MCP server (configured via MCP_SERVER env var) +- **caddy**: Reverse proxy with dynamic session-based routing + +## Security Features + +- **TLS-secured Docker communication**: No Docker socket exposure +- **Container isolation**: Each visitor gets dedicated container with resource limits +- **Automatic cleanup**: Sessions expire after 60 minutes of inactivity +- **Resource quotas**: 4GB RAM, 1 CPU core per container, max 3 concurrent sessions ## Development Notes -- Sessions auto-cleanup after 60 minutes of inactivity -- Limited to 3 concurrent sessions for workstation development -- Each session gets 4GB RAM and 1 CPU core - Session data persists in ./sessions/ directory +- TLS certificates auto-generated for development +- External MCP server configured via environment variables +- Health checks ensure service reliability ## API Endpoints @@ -43,4 +56,17 @@ This setup creates a container-per-visitor architecture for the Norwegian legal - `GET /api/sessions/{id}` - Get session info - `DELETE /api/sessions/{id}` - Delete session - `POST /api/cleanup` - Manual cleanup -- `GET /api/health` - Health check \ No newline at end of file +- `GET /api/health` - Health check +- `/{path}` - Dynamic proxy routing (with X-Session-ID header) + +## Environment Variables + +```bash +# Required +MCP_SERVER=http://your-lovdata-mcp-server:8001 + +# Optional LLM API keys +OPENAI_API_KEY=your_key +ANTHROPIC_API_KEY=your_key +GOOGLE_API_KEY=your_key +``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f2d53ed..8af6925 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,29 +9,34 @@ services: - "8000:8000" volumes: - ./sessions:/app/sessions - - /var/run/docker.sock:/var/run/docker.sock environment: - - MCP_SERVER=http://lovdata-mcp:8001 + - MCP_SERVER=${MCP_SERVER:-http://localhost:8001} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} + - DOCKER_HOST=tcp://docker-daemon:2376 + - DOCKER_TLS_VERIFY=1 + - DOCKER_CERT_PATH=/certs depends_on: - - lovdata-mcp + - docker-daemon networks: - lovdata-network restart: unless-stopped - lovdata-mcp: - # Placeholder for lovdata MCP server - # This should be replaced with the actual lovdata MCP server image - image: python:3.11-slim - ports: - - "8001:8001" + docker-daemon: + image: docker:dind + privileged: true + environment: + - DOCKER_TLS_CERTDIR=/certs + volumes: + - ./docker-certs:/certs networks: - lovdata-network - command: ["python", "-c", "import time; time.sleep(999999)"] # Placeholder + command: ["--tlsverify", "--tlscacert=/certs/server/ca.pem", "--tlscert=/certs/server/cert.pem", "--tlskey=/certs/server/key.pem"] restart: unless-stopped + # lovdata-mcp server is external - configured via MCP_SERVER environment variable + caddy: image: caddy:2.7-alpine ports: diff --git a/generate-certs.sh b/generate-certs.sh new file mode 100755 index 0000000..51fef92 --- /dev/null +++ b/generate-certs.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Generate TLS certificates for secure Docker communication + +set -e + +CERT_DIR="./docker-certs" +CA_DIR="$CERT_DIR/ca" +SERVER_DIR="$CERT_DIR/server" +CLIENT_DIR="$CERT_DIR/client" + +# Create directories +mkdir -p "$CA_DIR" "$SERVER_DIR" "$CLIENT_DIR" + +# Generate CA private key +openssl genrsa -out "$CA_DIR/ca-key.pem" 4096 + +# Generate CA certificate +openssl req -new -x509 -days 365 -key "$CA_DIR/ca-key.pem" -sha256 -out "$CA_DIR/ca.pem" -subj "/C=US/ST=CA/L=San Francisco/O=Docker/CN=docker-ca" + +# Generate server private key +openssl genrsa -out "$SERVER_DIR/server-key.pem" 4096 + +# Generate server certificate signing request +openssl req -subj "/CN=docker-daemon" -new -key "$SERVER_DIR/server-key.pem" -out "$SERVER_DIR/server.csr" + +# Create server extensions file +cat > "$SERVER_DIR/server-extfile.cnf" << EOF +subjectAltName = DNS:docker-daemon,IP:127.0.0.1,IP:172.18.0.1 +extendedKeyUsage = serverAuth +EOF + +# Sign server certificate +openssl x509 -req -days 365 -in "$SERVER_DIR/server.csr" -CA "$CA_DIR/ca.pem" -CAkey "$CA_DIR/ca-key.pem" -CAcreateserial -out "$SERVER_DIR/cert.pem" -extfile "$SERVER_DIR/server-extfile.cnf" + +# Generate client private key +openssl genrsa -out "$CLIENT_DIR/key.pem" 4096 + +# Generate client certificate signing request +openssl req -subj "/CN=docker-client" -new -key "$CLIENT_DIR/key.pem" -out "$CLIENT_DIR/client.csr" + +# Create client extensions file +cat > "$CLIENT_DIR/client-extfile.cnf" << EOF +extendedKeyUsage = clientAuth +EOF + +# Sign client certificate +openssl x509 -req -days 365 -in "$CLIENT_DIR/client.csr" -CA "$CA_DIR/ca.pem" -CAkey "$CA_DIR/ca-key.pem" -CAcreateserial -out "$CLIENT_DIR/cert.pem" -extfile "$CLIENT_DIR/client-extfile.cnf" + +# Copy CA certificate to client and server directories +cp "$CA_DIR/ca.pem" "$CLIENT_DIR/ca.pem" +cp "$CA_DIR/ca.pem" "$SERVER_DIR/ca.pem" + +# Set appropriate permissions +chmod 600 "$CA_DIR/ca-key.pem" "$SERVER_DIR/server-key.pem" "$CLIENT_DIR/key.pem" +chmod 644 "$CA_DIR/ca.pem" "$SERVER_DIR/cert.pem" "$CLIENT_DIR/cert.pem" + +echo "TLS certificates generated successfully in $CERT_DIR" +echo "CA certificate: $CA_DIR/ca.pem" +echo "Server cert: $SERVER_DIR/cert.pem" +echo "Client cert: $CLIENT_DIR/cert.pem" \ No newline at end of file diff --git a/nginx/Caddyfile b/nginx/Caddyfile index de6aceb..287fd83 100644 --- a/nginx/Caddyfile +++ b/nginx/Caddyfile @@ -8,14 +8,13 @@ localhost { reverse_proxy session-manager:8000 } - # Session-specific routing - dynamically route to containers + # Session-specific routing - proxy to session manager for dynamic routing handle /session/{session_id}* { + # Strip the session prefix and pass to session manager uri strip_prefix /session/{session_id} - # This will be handled by dynamic routing in the session manager - reverse_proxy session-manager:8000 { - # The session manager will return the actual container port - # This is a simplified version for development - } + # Add session ID as header for routing + header X-Session-ID {session_id} + reverse_proxy session-manager:8000 } # Static files and main interface diff --git a/requirements.txt b/requirements.txt index d38d165..696850d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ fastapi==0.104.1 uvicorn==0.24.0 docker==7.0.0 pydantic==2.5.0 -python-multipart==0.0.6 \ No newline at end of file +python-multipart==0.0.6 +httpx==0.25.2 \ No newline at end of file diff --git a/session-manager/Dockerfile b/session-manager/Dockerfile index f0f5365..21f1769 100644 --- a/session-manager/Dockerfile +++ b/session-manager/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y \ curl \ + docker.io \ && rm -rf /var/lib/apt/lists/* # Copy requirements first for better caching diff --git a/session-manager/main.py b/session-manager/main.py index fd92e67..afebd07 100644 --- a/session-manager/main.py +++ b/session-manager/main.py @@ -16,10 +16,12 @@ from contextlib import asynccontextmanager import docker from docker.errors import DockerException, NotFound -from fastapi import FastAPI, HTTPException, BackgroundTasks -from fastapi.responses import JSONResponse +from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel import uvicorn +import httpx +import asyncio # Configuration @@ -45,7 +47,16 @@ class SessionData(BaseModel): class SessionManager: def __init__(self): - self.docker_client = docker.from_env() + # Use TLS certificates for secure Docker communication + tls_config = docker.tls.TLSConfig( + client_cert=("/certs/client/cert.pem", "/certs/client/key.pem"), + ca_cert="/certs/client/ca.pem", + verify=True, + ) + self.docker_client = docker.DockerClient( + base_url=os.getenv("DOCKER_HOST", "tcp://docker-daemon:2376"), + tls=tls_config, + ) self.sessions: Dict[str, SessionData] = {} self._load_sessions() @@ -316,6 +327,56 @@ async def trigger_cleanup(): return {"message": "Cleanup completed"} +@app.api_route( + "/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] +) +async def proxy_to_session(request: Request, path: str): + """Proxy requests to session containers based on X-Session-ID header""" + session_id = request.headers.get("X-Session-ID") + if not session_id: + raise HTTPException(status_code=400, detail="Missing X-Session-ID header") + + session = await session_manager.get_session(session_id) + if not session or session.status != "running": + raise HTTPException(status_code=404, detail="Session not found or not running") + + # Proxy the request to the container + container_url = f"http://localhost:{session.port}" + + # Prepare the request + url = f"{container_url}/{path}" + if request.url.query: + url += f"?{request.url.query}" + + # Get request body + body = await request.body() + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.request( + method=request.method, + url=url, + headers={ + k: v + for k, v in request.headers.items() + if k.lower() not in ["host", "x-session-id"] + }, + content=body, + follow_redirects=False, + ) + + # Return the proxied response + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + except httpx.RequestError as e: + raise HTTPException( + status_code=502, detail=f"Container proxy error: {str(e)}" + ) + + @app.get("/health") async def health_check(): """Health check endpoint"""