v2: Vault integration - read credentials from Vaultwarden with disk cache fallback

This commit is contained in:
sascha 2026-04-18 10:24:53 +02:00
parent a85a3175cd
commit 09bbc47d6c
4 changed files with 191 additions and 41 deletions

202
app.py
View file

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