"""Homelab Butler – Unified API proxy for Pfannkuchen homelab.""" import os, json, httpx from fastapi import FastAPI, Request, HTTPException, Depends from fastapi.responses import JSONResponse app = FastAPI(title="Homelab Butler", version="1.0.0") API_DIR = os.environ.get("API_KEY_DIR", "/data/api") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") # --- Credential loading --- def _read(name): try: return open(f"{API_DIR}/{name}").read().strip() except FileNotFoundError: return None def _parse_kv(name): """Parse key-value files like proxmox, authentik.""" raw = _read(name) if not raw: return {} d = {} for line in raw.splitlines(): if ":" in line: k, v = line.split(":", 1) d[k.strip().lower()] = v.strip() return d def _parse_url_key(name): """Parse files with URL on line 1, key on line 2 (radarr1080p, sonarr1080p).""" raw = _read(name) if not raw: return None, None lines = [l.strip() for l in raw.splitlines() if l.strip()] return (lines[0] if lines else None, lines[1] if len(lines) > 1 else None) # --- Service configs --- SERVICES = { "dockhand": {"url": "http://10.4.1.116:3000", "auth": "session"}, "sonarr": {"url": "http://10.2.1.100:8989", "auth": "apikey", "key_file": "sonarr"}, "sonarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "sonarr1080p"}, "radarr": {"url": "http://10.2.1.100:7878", "auth": "apikey", "key_file": "radarr"}, "radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"}, "seerr": {"url": "http://10.2.1.100:5055", "auth": "apikey", "key_file": "seer"}, "outline": {"url": "http://10.1.1.100:3000", "auth": "bearer", "key_file": "outline"}, "n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n"}, "proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"}, "homeassistant": {"url": "http://10.10.1.20:8123","auth": "bearer", "key_file": "homeassistent"}, "grafana": {"url": "http://10.1.1.111:3000", "auth": "bearer", "key_file": "grafana"}, "uptime": {"url": "http://159.69.245.190:3001", "auth": "bearer", "key_file": "uptime"}, } # --- Session cache for Dockhand --- _dockhand_cookie = None async def _dockhand_login(client): global _dockhand_cookie r = await client.post( f"{SERVICES['dockhand']['url']}/api/auth/login", json={"username": "admin", "password": _read("dockhand") or ""}, ) if r.status_code == 200: _dockhand_cookie = dict(r.cookies) return _dockhand_cookie # --- Auth helper --- def _verify(request: Request): if not BUTLER_TOKEN: return auth = request.headers.get("authorization", "") if auth != f"Bearer {BUTLER_TOKEN}": raise HTTPException(401, "Invalid token") # --- Routes --- @app.get("/") async def root(): return {"service": "homelab-butler", "version": "1.0.0", "services": list(SERVICES.keys())} @app.get("/health") async def health(): return {"status": "ok"} @app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): cfg = SERVICES.get(service) if not cfg: raise HTTPException(404, f"Unknown service: {service}. Available: {list(SERVICES.keys())}") base_url = cfg["url"] auth_type = cfg["auth"] headers = dict(request.headers) cookies = {} # Remove hop-by-hop headers and butler auth (replaced by service auth below) for h in ["host", "content-length", "transfer-encoding", "authorization"]: headers.pop(h, None) # Build auth if auth_type == "apikey": key = _read(cfg["key_file"]) headers["X-Api-Key"] = key or "" elif auth_type == "apikey_urlfile": url, key = _parse_url_key(cfg["key_file"]) base_url = url.rstrip("/") if url else "" headers["X-Api-Key"] = key or "" elif auth_type == "bearer": key = _read(cfg["key_file"]) headers["Authorization"] = f"Bearer {key}" elif auth_type == "proxmox": pv = _parse_kv("proxmox") headers["Authorization"] = f"PVEAPIToken={pv.get('tokenid', '')}={pv.get('secret', '')}" elif auth_type == "n8n": key = _read(cfg["key_file"]) headers["X-N8N-API-KEY"] = key or "" elif auth_type == "session": global _dockhand_cookie if not _dockhand_cookie: async with httpx.AsyncClient(verify=False) as c: await _dockhand_login(c) cookies = _dockhand_cookie or {} target = f"{base_url}/{path}" body = await request.body() async with httpx.AsyncClient(verify=False, timeout=30.0) as client: resp = await client.request( method=request.method, url=target, headers=headers, cookies=cookies, content=body, ) # Dockhand session expired? Re-login once. if auth_type == "session" and resp.status_code == 401: _dockhand_cookie = None await _dockhand_login(client) cookies = _dockhand_cookie or {} resp = await client.request( method=request.method, url=target, headers=headers, cookies=cookies, content=body, ) try: data = resp.json() except Exception: data = resp.text return JSONResponse(content=data, status_code=resp.status_code)