v2: Vault via host-side sync + disk cache, no bw CLI in container

This commit is contained in:
sascha 2026-04-18 10:30:31 +02:00
parent 3b9d54231b
commit 8b97dea0e7
5 changed files with 74 additions and 152 deletions

169
app.py
View file

@ -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)