homelab-butler/app.py
2026-04-18 12:09:20 +02:00

202 lines
7.4 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 cache (synced by host cron) with flat-file fallback."""
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")
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
# --- Credential cache ---
_vault_cache: dict[str, str] = {}
def _load_vault_cache():
"""Load vault items from disk cache (written by host-side vault-sync.sh)."""
global _vault_cache
if not os.path.isdir(VAULT_CACHE_DIR):
log.info(f"No vault cache at {VAULT_CACHE_DIR}")
return
new = {}
for f in os.listdir(VAULT_CACHE_DIR):
path = os.path.join(VAULT_CACHE_DIR, f)
if os.path.isfile(path):
new[f] = open(path).read().strip()
_vault_cache = new
log.info(f"Loaded {len(new)} vault items from cache")
async def _periodic_cache_reload():
"""Reload vault cache every 5 minutes (host cron writes new files)."""
while True:
await asyncio.sleep(300)
_load_vault_cache()
@asynccontextmanager
async def lifespan(app: FastAPI):
_load_vault_cache()
task = asyncio.create_task(_periodic_cache_reload())
yield
task.cancel()
app = FastAPI(title="Homelab Butler", version="2.0.0", lifespan=lifespan)
# --- Credential reading (vault-first, file-fallback) ---
def _read(name):
"""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
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)
# --- 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"},
"waha": {"url": "http://10.4.1.110:3500", "auth": "apikey",
"key_file": "waha_api_key", "vault_key": "waha_api_key"},
}
# --- Dockhand session ---
_dockhand_cookie = None
async def _dockhand_login(client):
global _dockhand_cookie
r = await client.post(
f"{SERVICES['dockhand']['url']}/api/auth/login",
json={"username": "admin", "password": _read("dockhand") 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):
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/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)):
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)
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)