208 lines
7.7 KiB
Python
208 lines
7.7 KiB
Python
"""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",
|
||
"vault_key": "dockhand_password"},
|
||
"sonarr": {"url": "http://10.2.1.100:8989", "auth": "apikey", "key_file": "sonarr",
|
||
"vault_key": "sonarr_uhd_key"},
|
||
"sonarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "sonarr1080p"},
|
||
"radarr": {"url": "http://10.2.1.100:7878", "auth": "apikey", "key_file": "radarr",
|
||
"vault_key": "radarr_uhd_key"},
|
||
"radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"},
|
||
"seerr": {"url": "http://10.2.1.100:5055", "auth": "apikey", "key_file": "seer",
|
||
"vault_key": "seerr_api_key"},
|
||
"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",
|
||
"vault_key": "grafana_api_key"},
|
||
"uptime": {"url": "http://159.69.245.190:3001", "auth": "bearer", "key_file": "uptime",
|
||
"vault_key": "uptime_api_key"},
|
||
"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)
|