commit b1aa6671e27183bbd4b037b7270988c28c89739b Author: sascha Date: Sat Apr 18 10:03:05 2026 +0200 Initial: Homelab Butler API proxy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6082160 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.13-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +EXPOSE 8888 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8888"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..44c1660 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Homelab Butler + +Unified API proxy for the Pfannkuchen homelab. One token, one URL, all services. + +## Quick Start + +```bash +# Set your butler token +echo "BUTLER_TOKEN=$(openssl rand -hex 24)" > .env + +# Deploy +docker compose up -d --build +``` + +## Usage + +```bash +TOKEN="your-butler-token" +BUTLER="http://10.4.1.116:8888" + +# List available services +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/ + +# Dockhand (session auth handled automatically) +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/dockhand/api/environments + +# Sonarr +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/sonarr/api/v3/series + +# Radarr +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/radarr/api/v3/movie + +# Seerr +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/seerr/api/v1/request + +# Proxmox +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/proxmox/api2/json/nodes + +# Home Assistant +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/homeassistant/api/states + +# Outline Wiki +curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{}' $BUTLER/outline/api/collections.list + +# n8n +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/n8n/api/v1/workflows + +# Grafana +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/grafana/api/dashboards/home + +# Uptime Kuma +curl -s -H "Authorization: Bearer $TOKEN" $BUTLER/uptime/api/status-page/pfannkuchen +``` + +## Services + +| Service | Backend | Auth handled | +|---------|---------|-------------| +| dockhand | 10.4.1.116:3000 | Session cookie (auto-login) | +| sonarr | 10.2.1.100:8989 | X-Api-Key | +| sonarr1080p | 10.2.1.100:8990 | X-Api-Key | +| radarr | 10.2.1.100:7878 | X-Api-Key | +| radarr1080p | 10.2.1.100:7879 | X-Api-Key | +| seerr | 10.2.1.100:5055 | X-Api-Key | +| outline | 10.1.1.100:3000 | Bearer token | +| n8n | 10.4.1.113:5678 | Bearer token | +| proxmox | 10.5.85.11:8006 | PVEAPIToken | +| homeassistant | 10.10.1.20:8123 | Bearer token | +| grafana | 10.1.1.111:3000 | Bearer token | +| uptime | 159.69.245.190:3001 | Bearer token | + +## Credentials + +API keys are read from `/app-config/kiro/api/` (mounted read-only). The butler token is set via `BUTLER_TOKEN` env var. + +For Dockhand: create `/app-config/kiro/api/dockhand` with the admin password. diff --git a/app.py b/app.py new file mode 100644 index 0000000..0bee6e2 --- /dev/null +++ b/app.py @@ -0,0 +1,157 @@ +"""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": "bearer", "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 + for h in ["host", "content-length", "transfer-encoding"]: + 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 == "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) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c51f55e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,12 @@ +services: + homelab-butler: + build: . + container_name: homelab-butler + restart: always + ports: + - "8888:8888" + volumes: + - /app-config/kiro/api:/data/api:ro + environment: + - API_KEY_DIR=/data/api + - BUTLER_TOKEN=${BUTLER_TOKEN} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3acd60d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +httpx +fastapi +uvicorn[standard]