diff --git a/Dockerfile b/Dockerfile index 6082160..6b3b413 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,17 @@ FROM python:3.13-slim WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl unzip && \ + curl -sL 'https://vault.bitwarden.com/download/?app=cli&platform=linux' -o /tmp/bw.zip && \ + unzip /tmp/bw.zip -d /usr/local/bin/ && chmod +x /usr/local/bin/bw && \ + rm /tmp/bw.zip && apt-get purge -y curl unzip && apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . + +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh EXPOSE 8888 -CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8888"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/app.py b/app.py index 7d6b90e..73a8544 100644 --- a/app.py +++ b/app.py @@ -1,24 +1,122 @@ -"""Homelab Butler – Unified API proxy for Pfannkuchen homelab.""" +"""Homelab Butler – Unified API proxy for Pfannkuchen homelab. +Reads credentials from Vaultwarden (Bitwarden CLI) on startup, caches locally.""" -import os, json, httpx +import os, json, asyncio, subprocess, logging +import httpx from fastapi import FastAPI, Request, HTTPException, Depends from fastapi.responses import JSONResponse +from contextlib import asynccontextmanager -app = FastAPI(title="Homelab Butler", version="1.0.0") +log = logging.getLogger("butler") 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_REFRESH_MINUTES = int(os.environ.get("VAULT_REFRESH_MINUTES", "30")) -# --- Credential loading --- +# --- 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(f"{API_DIR}/.vault-cache", exist_ok=True) + for name, value in new_cache.items(): + safe = name.lower().replace(" ", "-") + with open(f"{API_DIR}/.vault-cache/{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 = f"{API_DIR}/.vault-cache" + 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: sync vault, fall back to disk cache + if not _sync_vault(): + _load_disk_cache() + 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): - """Parse key-value files like proxmox, authentik.""" raw = _read(name) if not raw: return {} @@ -30,45 +128,63 @@ def _parse_kv(name): 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) +# --- 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"}, - "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"}, + "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"}, } -# --- Session cache for Dockhand --- +# --- 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": _read("dockhand") or ""}, + json={"username": "admin", "password": pw or ""}, ) if r.status_code == 200: _dockhand_cookie = dict(r.cookies) return _dockhand_cookie -# --- Auth helper --- +# --- Auth --- def _verify(request: Request): if not BUTLER_TOKEN: @@ -77,15 +193,31 @@ def _verify(request: Request): 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": "1.0.0", "services": list(SERVICES.keys())} + 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"} + 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)): @@ -98,14 +230,11 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): 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 "" + headers["X-Api-Key"] = _get_key(cfg) or "" elif auth_type == "apikey_urlfile": url, key = _parse_url_key(cfg["key_file"]) @@ -113,17 +242,15 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): headers["X-Api-Key"] = key or "" elif auth_type == "bearer": - key = _read(cfg["key_file"]) - headers["Authorization"] = f"Bearer {key}" + 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 == "n8n": - key = _read(cfg["key_file"]) - headers["X-N8N-API-KEY"] = key or "" - elif auth_type == "session": global _dockhand_cookie if not _dockhand_cookie: @@ -136,14 +263,9 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): 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, + 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) diff --git a/compose.yaml b/compose.yaml index c51f55e..d35135c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,6 +7,12 @@ services: - "8888:8888" volumes: - /app-config/kiro/api:/data/api:ro + - vault-cache:/data/api/.vault-cache environment: - API_KEY_DIR=/data/api - BUTLER_TOKEN=${BUTLER_TOKEN} + - BW_PASSWORD=${BW_PASSWORD} + - VAULT_REFRESH_MINUTES=30 + +volumes: + vault-cache: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..3b40f3a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Configure bw CLI for Vaultwarden on first run +if [ ! -f /root/.config/Bitwarden\ CLI/data.json ] || ! bw status 2>/dev/null | grep -q '"status":"locked"'; then + bw config server https://vault.sascha-lutz.de 2>/dev/null || true + BW_CLIENTID="user.d0a1f14f-fcbb-436a-b18c-426987704df5" \ + BW_CLIENTSECRET="wwr4O0pf7mWTOSmTslNP3jqzl5OERp" \ + bw login --apikey 2>/dev/null || true +fi + +exec uvicorn app:app --host 0.0.0.0 --port 8888