From d4484e675001a99935b4718ea5fcca52ce0b58b3 Mon Sep 17 00:00:00 2001 From: "Junv (via Hermes)" Date: Sun, 14 Jun 2026 10:57:36 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Demo=20Service=20=E2=80=94=20Cadd?= =?UTF-8?q?y=20+=20FastAPI=20for=20hosting=20demo=20sites=20at=20demo.junv?= =?UTF-8?q?.cc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Caddy as static file server + reverse proxy - FastAPI management API with Pocket ID SSO - Web UI at demo.junv.cc/admin - Agent API with Bearer token auth - Demo sites accessible at demo.junv.cc/ - NFS PVC for persistent storage - Enable/disable per-site --- home-apps/demo-service/Caddyfile | 41 ++ home-apps/demo-service/Dockerfile | 16 + home-apps/demo-service/k8s-manifest.yaml | 174 +++++++ home-apps/demo-service/service.py | 621 +++++++++++++++++++++++ 4 files changed, 852 insertions(+) create mode 100644 home-apps/demo-service/Caddyfile create mode 100644 home-apps/demo-service/Dockerfile create mode 100644 home-apps/demo-service/k8s-manifest.yaml create mode 100644 home-apps/demo-service/service.py diff --git a/home-apps/demo-service/Caddyfile b/home-apps/demo-service/Caddyfile new file mode 100644 index 0000000..43e0e9e --- /dev/null +++ b/home-apps/demo-service/Caddyfile @@ -0,0 +1,41 @@ +# Caddy for Demo Service — internal server, TLS handled by Nginx Ingress +# Auth is handled by the Python API (calls OAuth2 Proxy for SSO validation) + +:80 { + log { + output stdout + format json + } + + # Management API → Python service + handle /api/* { + reverse_proxy localhost:3000 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + } + } + + # Admin Web UI → Python service + handle /admin/* { + reverse_proxy localhost:3000 + } + + # Root → redirect to admin + handle / { + reverse_proxy localhost:3000 + } + + # Static demo sites — public, no auth + handle_path /* { + root * /data/demos + file_server { + index index.html + } + } + + header { + X-Content-Type-Options nosniff + -Server + } +} diff --git a/home-apps/demo-service/Dockerfile b/home-apps/demo-service/Dockerfile new file mode 100644 index 0000000..78f5440 --- /dev/null +++ b/home-apps/demo-service/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies (added httpx for OAuth2 proxy cookie validation) +RUN pip install --no-cache-dir fastapi uvicorn python-multipart httpx + +# Copy the service +COPY service.py /app/ + +# Create data directory +RUN mkdir -p /data/demos + +EXPOSE 3000 + +CMD ["uvicorn", "service:app", "--host", "0.0.0.0", "--port", "3000", "--log-level", "info"] diff --git a/home-apps/demo-service/k8s-manifest.yaml b/home-apps/demo-service/k8s-manifest.yaml new file mode 100644 index 0000000..9304347 --- /dev/null +++ b/home-apps/demo-service/k8s-manifest.yaml @@ -0,0 +1,174 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: demo-sites-pvc + namespace: home-apps +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: nfs-client +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: caddy-config + namespace: home-apps +data: + Caddyfile: | + # Caddy for Demo Service — internal server, TLS handled by Nginx Ingress + # Auth is handled by the Python API (calls OAuth2 Proxy for SSO validation) + + :80 { + log { + output stdout + format json + } + + # Management API → Python service + handle /api/* { + reverse_proxy localhost:3000 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + } + } + + # Admin Web UI → Python service + handle /admin/* { + reverse_proxy localhost:3000 + } + + # Root → redirect to admin + handle / { + reverse_proxy localhost:3000 + } + + # Static demo sites — public, no auth + handle_path /* { + root * /data/demos + file_server { + index index.html + } + } + + header { + X-Content-Type-Options nosniff + -Server + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demo-service + namespace: home-apps + labels: + app: demo-service +spec: + replicas: 1 + selector: + matchLabels: + app: demo-service + template: + metadata: + labels: + app: demo-service + spec: + containers: + # ── Caddy — static file server + reverse proxy ── + - name: caddy + image: mirror.gcr.io/caddy:2 + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: caddy-config + mountPath: /etc/caddy/Caddyfile + subPath: Caddyfile + - name: demo-data + mountPath: /data/demos + resources: + requests: + memory: 32Mi + cpu: 50m + limits: + memory: 128Mi + cpu: 200m + + # ── Demo Manager API ── + - name: api + image: docker.io/library/demo-manager:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + name: api + env: + - name: DEMO_ROOT + value: /data/demos + - name: DEMO_API_KEY + value: hermes-demo-secret-key-change-me + - name: OAUTH_PROXY_URL + value: https://pass.junv.cc + volumeMounts: + - name: demo-data + mountPath: /data/demos + resources: + requests: + memory: 64Mi + cpu: 100m + limits: + memory: 256Mi + cpu: 500m + + volumes: + - name: caddy-config + configMap: + name: caddy-config + - name: demo-data + persistentVolumeClaim: + claimName: demo-sites-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: demo-service + namespace: home-apps +spec: + selector: + app: demo-service + ports: + - port: 80 + targetPort: 80 + name: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: demo-service-ingress + namespace: home-apps + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + kubernetes.io/tls-acme: "true" + # No global auth — public demo sites are served without auth + # API and Admin auth is handled by the Python service + # (validates Pocket ID session via OAuth2 Proxy internally) +spec: + ingressClassName: nginx + tls: + - hosts: + - demo.junv.cc + secretName: demo-service-tls + rules: + - host: demo.junv.cc + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: demo-service + port: + number: 80 diff --git a/home-apps/demo-service/service.py b/home-apps/demo-service/service.py new file mode 100644 index 0000000..2bead74 --- /dev/null +++ b/home-apps/demo-service/service.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python3 +""" +Demo Manager — FastAPI service for managing demo sites. +Pocket ID SSO auth via X-Auth-Request-Email header (set by OAuth2 proxy). +""" +import json +import os +import shutil +import uuid +import httpx +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form, Response +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +DEMO_ROOT = Path(os.environ.get("DEMO_ROOT", "/data/demos")) +META_FILE = DEMO_ROOT / ".meta" / "sites.json" +API_KEY = os.environ.get("DEMO_API_KEY", "hermes-demo-secret-key-change-me") +OAUTH_PROXY = os.environ.get("OAUTH_PROXY_URL", "https://pass.junv.cc") + +app = FastAPI(title="Demo Manager", version="1.0.0") + +# ─── Models ───────────────────────────────────────── +class SiteInfo(BaseModel): + name: str + enabled: bool = True + description: str = "" + created_at: str = "" + updated_at: str = "" + +class SiteCreate(BaseModel): + name: str + description: str = "" + +# ─── Helpers ──────────────────────────────────────── +def load_meta() -> dict: + META_FILE.parent.mkdir(parents=True, exist_ok=True) + if META_FILE.exists(): + return json.loads(META_FILE.read_text()) + return {"sites": {}} + +def save_meta(meta: dict): + META_FILE.parent.mkdir(parents=True, exist_ok=True) + META_FILE.write_text(json.dumps(meta, indent=2)) + +def check_auth(request: Request) -> Optional[str]: + """Return user email if authenticated, None otherwise.""" + # API key auth (for agent access) + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if token == API_KEY: + return "api-agent" + # OAuth2 proxy header (for browser SSO — set when using Nginx auth annotations) + email = request.headers.get("X-Auth-Request-Email") or request.headers.get("X-Forwarded-Email") + if email: + return email + # Server-side cookie validation via OAuth2 Proxy + # The _oauth2_proxy cookie is scoped to .junv.cc, so it's sent to demo.junv.cc + oauth_cookie = request.cookies.get("_oauth2_proxy") + if oauth_cookie: + try: + # Validate session with OAuth2 Proxy + resp = httpx.get( + f"{OAUTH_PROXY}/oauth2/auth", + cookies={"_oauth2_proxy": oauth_cookie}, + headers={"X-Auth-Request-Redirect": str(request.url)}, + timeout=5, + ) + if resp.status_code == 202: + # Valid session — user is authenticated + user_email = resp.headers.get("X-Auth-Request-Email", "") + if user_email: + return user_email + except Exception: + pass # Fail open → fall through to None + return None + +async def check_auth_async(request: Request) -> Optional[str]: + """Async wrapper for check_auth.""" + return check_auth(request) + +def require_auth(request: Request) -> str: + user = check_auth(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + return user + +def site_exists(name: str) -> bool: + """Check if site exists: meta entry must exist + directory should exist. + If meta entry doesn't exist, site is considered deleted even if directory lingers (NFS stale handles).""" + meta = load_meta() + if name not in meta.get("sites", {}): + return False + # Meta says it exists — verify directory too + site_dir = DEMO_ROOT / name + try: + return site_dir.is_dir() + except OSError: + return True # NFS stale handle, trust meta + +def site_index_path(name: str) -> bool: + """Check if the site has an index.html.""" + return (DEMO_ROOT / name / "index.html").exists() + +def ensure_disabled_index(name: str): + """Copy index.html to .index.html.bak and remove index.html.""" + site_dir = DEMO_ROOT / name + site_dir.mkdir(parents=True, exist_ok=True) + real_index = site_dir / "index.html" + backup_index = site_dir / ".index.html.bak" + if real_index.exists(): + # Copy content then remove original (avoids NFS rename issues) + backup_index.write_bytes(real_index.read_bytes()) + real_index.unlink() + +def ensure_enabled_index(name: str): + """Restore index.html from .index.html.bak.""" + site_dir = DEMO_ROOT / name + backup_index = site_dir / ".index.html.bak" + real_index = site_dir / "index.html" + if backup_index.exists(): + real_index.write_bytes(backup_index.read_bytes()) + backup_index.unlink() + +# ─── API Routes ───────────────────────────────────── +@app.get("/api/health") +def health(): + return {"status": "ok", "root": str(DEMO_ROOT)} + +@app.get("/api/sites") +def list_sites(request: Request): + user = require_auth(request) + meta = load_meta() + sites = [] + for name in sorted(meta.get("sites", {}).keys()): + info = meta["sites"][name] + site_dir = DEMO_ROOT / name + file_count = 0 + if site_dir.is_dir(): + file_count = sum(1 for _ in site_dir.rglob("*") if _.is_file()) + sites.append({**info, "name": name, "exists": site_dir.is_dir(), "file_count": file_count}) + return {"sites": sites, "user": user} + +@app.post("/api/sites") +def create_site(body: SiteCreate, request: Request): + user = require_auth(request) + name = body.name.strip().lower().replace(" ", "-") + if not name: + raise HTTPException(status_code=400, detail="Name is required") + if site_exists(name): + raise HTTPException(status_code=409, detail=f"Site '{name}' already exists") + + now = datetime.now(timezone.utc).isoformat() + meta = load_meta() + if "sites" not in meta: + meta["sites"] = {} + + site_dir = DEMO_ROOT / name + site_dir.mkdir(parents=True, exist_ok=True) + # Create a default index.html + (site_dir / "index.html").write_text(f""" + +{name} + + +

🚀 {name}

+""") + + site_info = { + "name": name, + "enabled": True, + "description": body.description, + "created_at": now, + "updated_at": now, + } + meta["sites"][name] = site_info + save_meta(meta) + return site_info + +@app.delete("/api/sites/{name}") +def delete_site(name: str, request: Request): + user = require_auth(request) + if not site_exists(name): + raise HTTPException(status_code=404, detail=f"Site '{name}' not found") + # Always remove from meta first (handles NFS stale file issues) + meta = load_meta() + meta["sites"].pop(name, None) + save_meta(meta) + # Try to remove directory; handle NFS issues gracefully + site_dir = DEMO_ROOT / name + try: + shutil.rmtree(site_dir) + except OSError: + pass # NFS stale file handle — directory will be orphaned but meta is clean + return {"status": "deleted", "name": name} + +@app.post("/api/sites/{name}/enable") +def enable_site(name: str, request: Request): + user = require_auth(request) + if not site_exists(name): + raise HTTPException(status_code=404, detail=f"Site '{name}' not found") + ensure_enabled_index(name) + meta = load_meta() + if name in meta.get("sites", {}): + meta["sites"][name]["enabled"] = True + meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat() + save_meta(meta) + return {"status": "enabled", "name": name} + +@app.post("/api/sites/{name}/disable") +def disable_site(name: str, request: Request): + user = require_auth(request) + if not site_exists(name): + raise HTTPException(status_code=404, detail=f"Site '{name}' not found") + ensure_disabled_index(name) + meta = load_meta() + if name in meta.get("sites", {}): + meta["sites"][name]["enabled"] = False + meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat() + save_meta(meta) + return {"status": "disabled", "name": name} + +@app.get("/api/sites/{name}") +def get_site(name: str, request: Request): + require_auth(request) + if not site_exists(name): + raise HTTPException(status_code=404, detail=f"Site '{name}' not found") + meta = load_meta() + info = meta.get("sites", {}).get(name, {"name": name, "enabled": True}) + site_dir = DEMO_ROOT / name + files = [] + if site_dir.is_dir(): + for f in sorted(site_dir.rglob("*")): + if f.is_file() and not f.name.startswith("."): + rel = str(f.relative_to(site_dir)) + files.append({"path": rel, "size": f.stat().st_size}) + return {"site": info, "files": files, "url": f"https://demo.junv.cc/{name}"} + +@app.post("/api/sites/{name}/files") +async def upload_file(name: str, request: Request, file: UploadFile = File(...), path: str = Form("")): + """Upload a file to a demo site. Set path to put file in a subdirectory.""" + user = require_auth(request) + if not site_exists(name): + raise HTTPException(status_code=404, detail=f"Site '{name}' not found. Create it first.") + + site_dir = DEMO_ROOT / name + # Prevent path traversal + safe_path = path.strip("/").replace("..", "") + dest_dir = site_dir / safe_path + dest_dir.mkdir(parents=True, exist_ok=True) + + file_path = dest_dir / file.filename + content = await file.read() + file_path.write_bytes(content) + + # Update timestamp + meta = load_meta() + if name in meta.get("sites", {}): + meta["sites"][name]["updated_at"] = datetime.now(timezone.utc).isoformat() + save_meta(meta) + + return { + "status": "uploaded", + "name": name, + "file": str(file_path.relative_to(site_dir)), + "size": len(content), + "url": f"https://demo.junv.cc/{name}/{str(file_path.relative_to(site_dir))}" + } + +@app.delete("/api/sites/{name}/files") +def delete_file(name: str, request: Request, path: str = Form("")): + """Delete a file from a demo site.""" + user = require_auth(request) + if not site_exists(name): + raise HTTPException(status_code=404, detail=f"Site '{name}' not found") + + safe_path = path.strip("/").replace("..", "") + if not safe_path: + raise HTTPException(status_code=400, detail="path is required") + + file_path = DEMO_ROOT / name / safe_path + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"File not found: {safe_path}") + if file_path.is_dir(): + shutil.rmtree(file_path) + else: + file_path.unlink() + + return {"status": "deleted", "name": name, "file": safe_path} + + +# ─── Web UI ───────────────────────────────────────── +UI_HTML = """ + + + + +Demo Manager — junv.cc + + + +
+

🧪 Demo Manager

+
Loading...
+
+
+
+

➕ Create Demo Site

+
+
+ + +
+
+ + +
+ +
+
+ +
+

📂 My Demo Sites

+
Loading...
+
+
+ + +
+ + + +""" + +@app.get("/admin", response_class=HTMLResponse) +@app.get("/admin/", response_class=HTMLResponse) +@app.get("/admin/{path:path}", response_class=HTMLResponse) +def admin_ui(request: Request, path: str = ""): + """Serve the admin Web UI.""" + require_auth(request) + return HTMLResponse(UI_HTML) + +@app.get("/") +def index(request: Request): + """Root redirects to admin.""" + return HTMLResponse(""" + +

Redirecting to Admin...

+""")