"""Homelab Butler – Unified API proxy for Pfannkuchen homelab. Reads credentials from Vaultwarden cache (synced by host cron) with flat-file fallback.""" import os, json, asyncio, logging import httpx from fastapi import FastAPI, Request, HTTPException, Depends from fastapi.responses import JSONResponse from contextlib import asynccontextmanager log = logging.getLogger("butler") API_DIR = os.environ.get("API_KEY_DIR", "/data/api") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") # --- Credential cache --- _vault_cache: dict[str, str] = {} def _load_vault_cache(): """Load vault items from disk cache (written by host-side vault-sync.sh).""" global _vault_cache if not os.path.isdir(VAULT_CACHE_DIR): log.info(f"No vault cache at {VAULT_CACHE_DIR}") return new = {} for f in os.listdir(VAULT_CACHE_DIR): path = os.path.join(VAULT_CACHE_DIR, f) if os.path.isfile(path): new[f] = open(path).read().strip() _vault_cache = new log.info(f"Loaded {len(new)} vault items from cache") async def _periodic_cache_reload(): """Reload vault cache every 5 minutes (host cron writes new files).""" while True: await asyncio.sleep(300) _load_vault_cache() @asynccontextmanager async def lifespan(app: FastAPI): _load_vault_cache() task = asyncio.create_task(_periodic_cache_reload()) yield task.cancel() app = FastAPI(title="Homelab Butler", version="2.0.0", lifespan=lifespan) # --- Credential reading (vault-first, file-fallback) --- def _read(name): """Read credential: vault cache first, then flat file.""" # Vault cache uses lowercase-hyphenated names vault_name = name.lower().replace("_", "-") if vault_name in _vault_cache: return _vault_cache[vault_name] # Try uppercase convention upper = name.upper().replace("-", "_").lower().replace("_", "-") if upper in _vault_cache: return _vault_cache[upper] # Fallback to flat file try: return open(f"{API_DIR}/{name}").read().strip() except FileNotFoundError: return None def _parse_kv(name): 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): 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", "vault_key": "outline_api_key"}, "n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n", "vault_key": "n8n_api_key"}, "proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"}, "homeassistant": {"url": "http://10.10.1.20:8123", "auth": "bearer", "key_file": "homeassistent", "vault_key": "ha_token"}, "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"}, } # --- Dockhand session --- _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 --- 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") def _get_key(cfg): vault_key = cfg.get("vault_key") if vault_key and vault_key in _vault_cache: return _vault_cache[vault_key] return _read(cfg.get("key_file", "")) # --- Routes --- @app.get("/") async def root(): return {"service": "homelab-butler", "version": "2.0.0", "services": list(SERVICES.keys()), "vault_items": len(_vault_cache)} @app.get("/health") async def health(): return {"status": "ok", "vault_items": len(_vault_cache)} @app.post("/vault/reload") async def vault_reload(_=Depends(_verify)): _load_vault_cache() return {"reloaded": True, "items": len(_vault_cache)} @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 = {} for h in ["host", "content-length", "transfer-encoding", "authorization"]: headers.pop(h, None) if auth_type == "apikey": headers["X-Api-Key"] = _get_key(cfg) 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": headers["Authorization"] = f"Bearer {_get_key(cfg)}" elif auth_type == "n8n": headers["X-N8N-API-KEY"] = _get_key(cfg) or "" elif auth_type == "proxmox": pv = _parse_kv("proxmox") headers["Authorization"] = f"PVEAPIToken={pv.get('tokenid', '')}={pv.get('secret', '')}" 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) if auth_type == "session" and resp.status_code == 401: _dockhand_cookie = None await _dockhand_login(client) resp = await client.request(method=request.method, url=target, headers=headers, cookies=_dockhand_cookie or {}, content=body) try: data = resp.json() except Exception: data = resp.text return JSONResponse(content=data, status_code=resp.status_code)