homelab-butler/app.py
2026-04-18 10:26:30 +02:00

285 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Homelab Butler Unified API proxy for Pfannkuchen homelab.
Reads credentials from Vaultwarden (Bitwarden CLI) on startup, caches locally."""
import os, json, asyncio, subprocess, 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")
# --- 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(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
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: 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())
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):
raw = _read(name)
if not raw:
return {}
d = {}
for line in raw.splitlines():
if ":" in line:
k, v = line.split(":", 1)
d[k.strip().lower()] = v.strip()
return d
def _parse_url_key(name):
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",
"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"},
}
# --- 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": pw or ""},
)
if r.status_code == 200:
_dockhand_cookie = dict(r.cookies)
return _dockhand_cookie
# --- Auth ---
def _verify(request: Request):
if not BUTLER_TOKEN:
return
auth = request.headers.get("authorization", "")
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": "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.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
cfg = SERVICES.get(service)
if not cfg:
raise HTTPException(404, f"Unknown service: {service}. Available: {list(SERVICES.keys())}")
base_url = cfg["url"]
auth_type = cfg["auth"]
headers = dict(request.headers)
cookies = {}
for h in ["host", "content-length", "transfer-encoding", "authorization"]:
headers.pop(h, None)
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:
async with httpx.AsyncClient(verify=False) as c:
await _dockhand_login(c)
cookies = _dockhand_cookie or {}
target = f"{base_url}/{path}"
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,
)
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,
)
try:
data = resp.json()
except Exception:
data = resp.text
return JSONResponse(content=data, status_code=resp.status_code)