"""Homelab Butler – Unified API proxy for Pfannkuchen homelab. Reads credentials from Vaultwarden (Bitwarden CLI) on startup, caches locally.""" import os, json, asyncio, subprocess, logging import httpx from fastapi import FastAPI, Request, HTTPException, Depends from fastapi.responses import JSONResponse from contextlib import asynccontextmanager log = logging.getLogger("butler") VAULT_REFRESH_MINUTES = int(os.environ.get("VAULT_REFRESH_MINUTES", "30")) API_DIR = os.environ.get("API_KEY_DIR", "/data/api") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") BW_PASSWORD = os.environ.get("BW_PASSWORD", "") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") # --- Vault sync --- _vault_cache: dict[str, str] = {} # Mapping: vault item name -> local file name VAULT_TO_FILE = { "HA_TOKEN": "homeassistent", "N8N_API_KEY": "n8n", "OUTLINE_API_KEY": "outline", "HETZNER_DNS_TOKEN": "hetzner-dns", "TG_HARALD_TOKEN": "tg-harald", "TG_SASCHA_CHAT": "tg-sascha-chat", "SEERR_URL": None, # skip URL-only items } def _sync_vault(): """Pull all items from Vaultwarden via bw CLI and cache them.""" global _vault_cache if not BW_PASSWORD: log.info("No BW_PASSWORD set, skipping vault sync") return False try: env = {**os.environ, "BW_PASSWORD": BW_PASSWORD} session = subprocess.run( ["bw", "unlock", "--passwordenv", "BW_PASSWORD", "--raw"], capture_output=True, text=True, env=env, timeout=30 ).stdout.strip() if not session: log.warning("Vault unlock failed") return False subprocess.run(["bw", "sync", "--session", session], capture_output=True, env=env, timeout=30) result = subprocess.run( ["bw", "list", "items", "--session", session], capture_output=True, text=True, env=env, timeout=30 ) items = json.loads(result.stdout) new_cache = {} for item in items: name = item.get("name", "") notes = item.get("notes") or "" if name and notes: new_cache[name] = notes.strip() _vault_cache = new_cache # Write to disk as cache (fallback if vault unreachable later) os.makedirs(VAULT_CACHE_DIR, exist_ok=True) for name, value in new_cache.items(): safe = name.lower().replace(" ", "-") with open(f"{VAULT_CACHE_DIR}/{safe}", "w") as f: f.write(value) log.info(f"Vault sync: {len(new_cache)} items cached") return True except Exception as e: log.warning(f"Vault sync failed: {e}") return False def _load_disk_cache(): """Load cached vault items from disk (fallback).""" global _vault_cache cache_dir = VAULT_CACHE_DIR if not os.path.isdir(cache_dir): return for f in os.listdir(cache_dir): path = os.path.join(cache_dir, f) if os.path.isfile(path): _vault_cache[f] = open(path).read().strip() log.info(f"Loaded {len(_vault_cache)} items from disk cache") async def _periodic_vault_sync(): while True: await asyncio.sleep(VAULT_REFRESH_MINUTES * 60) log.info("Periodic vault refresh...") _sync_vault() @asynccontextmanager async def lifespan(app: FastAPI): # Startup: try vault sync in background, use file fallback immediately _load_disk_cache() loop = asyncio.get_event_loop() loop.run_in_executor(None, _sync_vault) # non-blocking task = asyncio.create_task(_periodic_vault_sync()) yield task.cancel() app = FastAPI(title="Homelab Butler", version="2.0.0", lifespan=lifespan) # --- Credential reading (vault-first, file-fallback) --- def _read(name): """Read a credential: vault cache first, then flat file.""" # Check vault cache by exact name if name in _vault_cache: return _vault_cache[name] # Check vault cache by uppercase convention upper = name.upper().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) # --- Vault-aware credential getters --- def _get_seerr_key(): return _read("seer") or _vault_cache.get("SEERR_API_KEY", "") def _get_sonarr_key(variant=""): if variant == "1080p": return _read("sonarr1080p") or _vault_cache.get("SONARR_FHD_KEY", "") return _read("sonarr") or _vault_cache.get("SONARR_UHD_KEY", "") def _get_radarr_key(variant=""): if variant == "1080p": return _read("radarr1080p") or _vault_cache.get("RADARR_FHD_KEY", "") return _read("radarr") or _vault_cache.get("RADARR_UHD_KEY", "") # --- 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 cache --- _dockhand_cookie = None async def _dockhand_login(client): global _dockhand_cookie pw = _read("dockhand") r = await client.post( f"{SERVICES['dockhand']['url']}/api/auth/login", json={"username": "admin", "password": pw 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): """Get API key from vault (preferred) or file (fallback).""" 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/refresh") async def vault_refresh(_=Depends(_verify)): ok = _sync_vault() return {"refreshed": ok, "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) 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)