homelab-butler/app.py
2026-04-18 10:05:46 +02:00

161 lines
5.5 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."""
import os, json, httpx
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse
app = FastAPI(title="Homelab Butler", version="1.0.0")
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
# --- Credential loading ---
def _read(name):
try:
return open(f"{API_DIR}/{name}").read().strip()
except FileNotFoundError:
return None
def _parse_kv(name):
"""Parse key-value files like proxmox, authentik."""
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):
"""Parse files with URL on line 1, key on line 2 (radarr1080p, sonarr1080p)."""
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"},
"n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n"},
"proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"},
"homeassistant": {"url": "http://10.10.1.20:8123","auth": "bearer", "key_file": "homeassistent"},
"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"},
}
# --- Session cache for Dockhand ---
_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 helper ---
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")
# --- Routes ---
@app.get("/")
async def root():
return {"service": "homelab-butler", "version": "1.0.0", "services": list(SERVICES.keys())}
@app.get("/health")
async def health():
return {"status": "ok"}
@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 = {}
# Remove hop-by-hop headers and butler auth (replaced by service auth below)
for h in ["host", "content-length", "transfer-encoding", "authorization"]:
headers.pop(h, None)
# Build auth
if auth_type == "apikey":
key = _read(cfg["key_file"])
headers["X-Api-Key"] = key 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":
key = _read(cfg["key_file"])
headers["Authorization"] = f"Bearer {key}"
elif auth_type == "proxmox":
pv = _parse_kv("proxmox")
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":
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,
)
# Dockhand session expired? Re-login once.
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)