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

View file

@ -1,17 +1,7 @@
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app.py . COPY app.py .
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
EXPOSE 8888 EXPOSE 8888
ENTRYPOINT ["./entrypoint.sh"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8888"]

169
app.py
View file

@ -1,101 +1,46 @@
"""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.""" 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 import httpx
from fastapi import FastAPI, Request, HTTPException, Depends from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
log = logging.getLogger("butler") 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") 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_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] = {} _vault_cache: dict[str, str] = {}
# Mapping: vault item name -> local file name def _load_vault_cache():
VAULT_TO_FILE = { """Load vault items from disk cache (written by host-side vault-sync.sh)."""
"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 global _vault_cache
if not BW_PASSWORD: if not os.path.isdir(VAULT_CACHE_DIR):
log.info("No BW_PASSWORD set, skipping vault sync") log.info(f"No vault cache at {VAULT_CACHE_DIR}")
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 return
for f in os.listdir(cache_dir): new = {}
path = os.path.join(cache_dir, f) for f in os.listdir(VAULT_CACHE_DIR):
path = os.path.join(VAULT_CACHE_DIR, f)
if os.path.isfile(path): if os.path.isfile(path):
_vault_cache[f] = open(path).read().strip() new[f] = open(path).read().strip()
log.info(f"Loaded {len(_vault_cache)} items from disk cache") _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: while True:
await asyncio.sleep(VAULT_REFRESH_MINUTES * 60) await asyncio.sleep(300)
log.info("Periodic vault refresh...") _load_vault_cache()
_sync_vault()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup: try vault sync in background, use file fallback immediately _load_vault_cache()
_load_disk_cache() task = asyncio.create_task(_periodic_cache_reload())
loop = asyncio.get_event_loop()
loop.run_in_executor(None, _sync_vault) # non-blocking
task = asyncio.create_task(_periodic_vault_sync())
yield yield
task.cancel() task.cancel()
@ -104,12 +49,13 @@ app = FastAPI(title="Homelab Butler", version="2.0.0", lifespan=lifespan)
# --- Credential reading (vault-first, file-fallback) --- # --- Credential reading (vault-first, file-fallback) ---
def _read(name): def _read(name):
"""Read a credential: vault cache first, then flat file.""" """Read credential: vault cache first, then flat file."""
# Check vault cache by exact name # Vault cache uses lowercase-hyphenated names
if name in _vault_cache: vault_name = name.lower().replace("_", "-")
return _vault_cache[name] if vault_name in _vault_cache:
# Check vault cache by uppercase convention return _vault_cache[vault_name]
upper = name.upper().replace("-", "_") # Try uppercase convention
upper = name.upper().replace("-", "_").lower().replace("_", "-")
if upper in _vault_cache: if upper in _vault_cache:
return _vault_cache[upper] return _vault_cache[upper]
# Fallback to flat file # Fallback to flat file
@ -136,21 +82,6 @@ def _parse_url_key(name):
lines = [l.strip() for l in raw.splitlines() if l.strip()] 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) 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 --- # --- Service configs ---
SERVICES = { SERVICES = {
@ -161,26 +92,25 @@ SERVICES = {
"radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"}, "radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"},
"seerr": {"url": "http://10.2.1.100:5055", "auth": "apikey", "key_file": "seer"}, "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", "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", "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"}, "proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"},
"homeassistant": {"url": "http://10.10.1.20:8123", "auth": "bearer", "key_file": "homeassistent", "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"}, "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"}, "uptime": {"url": "http://159.69.245.190:3001", "auth": "bearer", "key_file": "uptime"},
} }
# --- Dockhand session cache --- # --- Dockhand session ---
_dockhand_cookie = None _dockhand_cookie = None
async def _dockhand_login(client): async def _dockhand_login(client):
global _dockhand_cookie global _dockhand_cookie
pw = _read("dockhand")
r = await client.post( r = await client.post(
f"{SERVICES['dockhand']['url']}/api/auth/login", 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: if r.status_code == 200:
_dockhand_cookie = dict(r.cookies) _dockhand_cookie = dict(r.cookies)
@ -196,7 +126,6 @@ def _verify(request: Request):
raise HTTPException(401, "Invalid token") raise HTTPException(401, "Invalid token")
def _get_key(cfg): def _get_key(cfg):
"""Get API key from vault (preferred) or file (fallback)."""
vault_key = cfg.get("vault_key") vault_key = cfg.get("vault_key")
if vault_key and vault_key in _vault_cache: if vault_key and vault_key in _vault_cache:
return _vault_cache[vault_key] return _vault_cache[vault_key]
@ -206,20 +135,17 @@ def _get_key(cfg):
@app.get("/") @app.get("/")
async def root(): async def root():
return { return {"service": "homelab-butler", "version": "2.0.0",
"service": "homelab-butler", "version": "2.0.0", "services": list(SERVICES.keys()), "vault_items": len(_vault_cache)}
"services": list(SERVICES.keys()),
"vault_items": len(_vault_cache),
}
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok", "vault_items": len(_vault_cache)} return {"status": "ok", "vault_items": len(_vault_cache)}
@app.post("/vault/refresh") @app.post("/vault/reload")
async def vault_refresh(_=Depends(_verify)): async def vault_reload(_=Depends(_verify)):
ok = _sync_vault() _load_vault_cache()
return {"refreshed": ok, "items": len(_vault_cache)} return {"reloaded": True, "items": len(_vault_cache)}
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): 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": if auth_type == "apikey":
headers["X-Api-Key"] = _get_key(cfg) or "" headers["X-Api-Key"] = _get_key(cfg) or ""
elif auth_type == "apikey_urlfile": elif auth_type == "apikey_urlfile":
url, key = _parse_url_key(cfg["key_file"]) url, key = _parse_url_key(cfg["key_file"])
base_url = url.rstrip("/") if url else "" base_url = url.rstrip("/") if url else ""
headers["X-Api-Key"] = key or "" headers["X-Api-Key"] = key or ""
elif auth_type == "bearer": elif auth_type == "bearer":
headers["Authorization"] = f"Bearer {_get_key(cfg)}" headers["Authorization"] = f"Bearer {_get_key(cfg)}"
elif auth_type == "n8n": elif auth_type == "n8n":
headers["X-N8N-API-KEY"] = _get_key(cfg) or "" headers["X-N8N-API-KEY"] = _get_key(cfg) or ""
elif auth_type == "proxmox": elif auth_type == "proxmox":
pv = _parse_kv("proxmox") pv = _parse_kv("proxmox")
headers["Authorization"] = f"PVEAPIToken={pv.get('tokenid', '')}={pv.get('secret', '')}" headers["Authorization"] = f"PVEAPIToken={pv.get('tokenid', '')}={pv.get('secret', '')}"
elif auth_type == "session": elif auth_type == "session":
global _dockhand_cookie global _dockhand_cookie
if not _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() body = await request.body()
async with httpx.AsyncClient(verify=False, timeout=30.0) as client: async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
resp = await client.request( resp = await client.request(method=request.method, url=target,
method=request.method, url=target, headers=headers, cookies=cookies, content=body)
headers=headers, cookies=cookies, content=body,
)
if auth_type == "session" and resp.status_code == 401: if auth_type == "session" and resp.status_code == 401:
_dockhand_cookie = None _dockhand_cookie = None
await _dockhand_login(client) await _dockhand_login(client)
cookies = _dockhand_cookie or {} resp = await client.request(method=request.method, url=target,
resp = await client.request( headers=headers, cookies=_dockhand_cookie or {}, content=body)
method=request.method, url=target,
headers=headers, cookies=cookies, content=body,
)
try: try:
data = resp.json() data = resp.json()
except Exception: except Exception:
data = resp.text data = resp.text
return JSONResponse(content=data, status_code=resp.status_code) return JSONResponse(content=data, status_code=resp.status_code)

View file

@ -12,8 +12,6 @@ services:
- API_KEY_DIR=/data/api - API_KEY_DIR=/data/api
- VAULT_CACHE_DIR=/data/vault-cache - VAULT_CACHE_DIR=/data/vault-cache
- BUTLER_TOKEN=${BUTLER_TOKEN} - BUTLER_TOKEN=${BUTLER_TOKEN}
- BW_PASSWORD=${BW_PASSWORD}
- VAULT_REFRESH_MINUTES=30
volumes: volumes:
vault-cache: vault-cache:

View file

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

31
vault-sync.sh Normal file
View file

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