diff --git a/Dockerfile b/Dockerfile index 6b3b413..6082160 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,7 @@ 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 -ENTRYPOINT ["./entrypoint.sh"] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8888"] diff --git a/app.py b/app.py index 7ef6c04..4b2b52c 100644 --- a/app.py +++ b/app.py @@ -1,101 +1,46 @@ """Homelab Butler – Unified API proxy for Pfannkuchen homelab. -Reads credentials from Vaultwarden (Bitwarden CLI) on startup, caches locally.""" +Reads credentials from Vaultwarden cache (synced by host cron) with flat-file fallback.""" -import os, json, asyncio, subprocess, logging +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") -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") +BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") -# --- Vault sync --- +# --- Credential cache --- _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.""" +def _load_vault_cache(): + """Load vault items from disk cache (written by host-side vault-sync.sh).""" 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): + if not os.path.isdir(VAULT_CACHE_DIR): + log.info(f"No vault cache at {VAULT_CACHE_DIR}") return - for f in os.listdir(cache_dir): - path = os.path.join(cache_dir, f) + new = {} + for f in os.listdir(VAULT_CACHE_DIR): + path = os.path.join(VAULT_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") + new[f] = open(path).read().strip() + _vault_cache = new + log.info(f"Loaded {len(new)} vault items from cache") -async def _periodic_vault_sync(): +async def _periodic_cache_reload(): + """Reload vault cache every 5 minutes (host cron writes new files).""" while True: - await asyncio.sleep(VAULT_REFRESH_MINUTES * 60) - log.info("Periodic vault refresh...") - _sync_vault() + await asyncio.sleep(300) + _load_vault_cache() @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()) + _load_vault_cache() + task = asyncio.create_task(_periodic_cache_reload()) yield task.cancel() @@ -104,12 +49,13 @@ 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("-", "_") + """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 @@ -136,21 +82,6 @@ def _parse_url_key(name): 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 = { @@ -161,26 +92,25 @@ SERVICES = { "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"}, + "vault_key": "outline_api_key"}, "n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n", - "vault_key": "N8N_API_KEY"}, + "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"}, + "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 session --- _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 ""}, + json={"username": "admin", "password": _read("dockhand") or ""}, ) if r.status_code == 200: _dockhand_cookie = dict(r.cookies) @@ -196,7 +126,6 @@ def _verify(request: Request): 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] @@ -206,20 +135,17 @@ def _get_key(cfg): @app.get("/") async def root(): - return { - "service": "homelab-butler", "version": "2.0.0", - "services": list(SERVICES.keys()), - "vault_items": len(_vault_cache), - } + 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.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)): @@ -237,22 +163,17 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): 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: @@ -264,22 +185,16 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): 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, - ) + 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, - ) + 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) diff --git a/compose.yaml b/compose.yaml index 5c0c3af..6ed9728 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,8 +12,6 @@ services: - API_KEY_DIR=/data/api - VAULT_CACHE_DIR=/data/vault-cache - BUTLER_TOKEN=${BUTLER_TOKEN} - - BW_PASSWORD=${BW_PASSWORD} - - VAULT_REFRESH_MINUTES=30 volumes: vault-cache: diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 3b40f3a..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/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 diff --git a/vault-sync.sh b/vault-sync.sh new file mode 100644 index 0000000..8f03bc0 --- /dev/null +++ b/vault-sync.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# vault-sync.sh - Sync Vaultwarden items to Butler cache volume +# Run via cron: */30 * * * * /app-config/homelab-butler/vault-sync.sh +set -euo pipefail + +export BW_PASSWORD="8yRG5LADfoTLHdC1Oj" +CACHE_DIR=$(sudo docker inspect homelab-butler --format '{{range .Mounts}}{{if eq .Destination "/data/vault-cache"}}{{.Source}}{{end}}{{end}}' 2>/dev/null) + +[ -z "$CACHE_DIR" ] && echo "Butler container not found" && exit 1 + +SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null) +[ -z "$SESSION" ] && echo "Vault unlock failed" && exit 1 + +bw sync --session "$SESSION" >/dev/null 2>&1 + +bw list items --session "$SESSION" 2>/dev/null | sudo python3 -c " +import sys, json, os +items = json.load(sys.stdin) +cache_dir = '$CACHE_DIR' +os.makedirs(cache_dir, exist_ok=True) +count = 0 +for item in items: + name = item.get('name', '') + notes = item.get('notes') or '' + if name and notes: + safe = name.lower().replace(' ', '-') + with open(f'{cache_dir}/{safe}', 'w') as f: + f.write(notes.strip()) + count += 1 +print(f'vault-sync: {count} items written') +"