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

View file

@ -1,7 +1,17 @@
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
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8888"] ENTRYPOINT ["./entrypoint.sh"]

184
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 import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse 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") API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") 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): 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: try:
return open(f"{API_DIR}/{name}").read().strip() return open(f"{API_DIR}/{name}").read().strip()
except FileNotFoundError: except FileNotFoundError:
return None return None
def _parse_kv(name): def _parse_kv(name):
"""Parse key-value files like proxmox, authentik."""
raw = _read(name) raw = _read(name)
if not raw: if not raw:
return {} return {}
@ -30,13 +128,27 @@ def _parse_kv(name):
return d return d
def _parse_url_key(name): def _parse_url_key(name):
"""Parse files with URL on line 1, key on line 2 (radarr1080p, sonarr1080p)."""
raw = _read(name) raw = _read(name)
if not raw: if not raw:
return None, None return None, None
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 = {
@ -46,29 +158,33 @@ SERVICES = {
"radarr": {"url": "http://10.2.1.100:7878", "auth": "apikey", "key_file": "radarr"}, "radarr": {"url": "http://10.2.1.100:7878", "auth": "apikey", "key_file": "radarr"},
"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",
"n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n"}, "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"}, "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"},
"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"},
} }
# --- Session cache for Dockhand --- # --- Dockhand session cache ---
_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": _read("dockhand") or ""}, json={"username": "admin", "password": pw or ""},
) )
if r.status_code == 200: if r.status_code == 200:
_dockhand_cookie = dict(r.cookies) _dockhand_cookie = dict(r.cookies)
return _dockhand_cookie return _dockhand_cookie
# --- Auth helper --- # --- Auth ---
def _verify(request: Request): def _verify(request: Request):
if not BUTLER_TOKEN: if not BUTLER_TOKEN:
@ -77,15 +193,31 @@ def _verify(request: Request):
if auth != f"Bearer {BUTLER_TOKEN}": if auth != f"Bearer {BUTLER_TOKEN}":
raise HTTPException(401, "Invalid 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 --- # --- Routes ---
@app.get("/") @app.get("/")
async def root(): 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") @app.get("/health")
async def 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"]) @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)):
@ -98,14 +230,11 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
headers = dict(request.headers) headers = dict(request.headers)
cookies = {} cookies = {}
# Remove hop-by-hop headers and butler auth (replaced by service auth below)
for h in ["host", "content-length", "transfer-encoding", "authorization"]: for h in ["host", "content-length", "transfer-encoding", "authorization"]:
headers.pop(h, None) headers.pop(h, None)
# Build auth
if auth_type == "apikey": if auth_type == "apikey":
key = _read(cfg["key_file"]) headers["X-Api-Key"] = _get_key(cfg) or ""
headers["X-Api-Key"] = key 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"])
@ -113,17 +242,15 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
headers["X-Api-Key"] = key or "" headers["X-Api-Key"] = key or ""
elif auth_type == "bearer": elif auth_type == "bearer":
key = _read(cfg["key_file"]) headers["Authorization"] = f"Bearer {_get_key(cfg)}"
headers["Authorization"] = f"Bearer {key}"
elif auth_type == "n8n":
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 == "n8n":
key = _read(cfg["key_file"])
headers["X-N8N-API-KEY"] = key or ""
elif auth_type == "session": elif auth_type == "session":
global _dockhand_cookie global _dockhand_cookie
if not _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: async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
resp = await client.request( resp = await client.request(
method=request.method, method=request.method, url=target,
url=target, headers=headers, cookies=cookies, content=body,
headers=headers,
cookies=cookies,
content=body,
) )
# Dockhand session expired? Re-login once.
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)

View file

@ -7,6 +7,12 @@ services:
- "8888:8888" - "8888:8888"
volumes: volumes:
- /app-config/kiro/api:/data/api:ro - /app-config/kiro/api:/data/api:ro
- vault-cache:/data/api/.vault-cache
environment: environment:
- API_KEY_DIR=/data/api - API_KEY_DIR=/data/api
- BUTLER_TOKEN=${BUTLER_TOKEN} - BUTLER_TOKEN=${BUTLER_TOKEN}
- BW_PASSWORD=${BW_PASSWORD}
- VAULT_REFRESH_MINUTES=30
volumes:
vault-cache:

12
entrypoint.sh Normal file
View file

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