Initial: Homelab Butler API proxy

This commit is contained in:
sascha 2026-04-18 10:03:05 +02:00
commit b1aa6671e2
6 changed files with 259 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc

7
Dockerfile Normal file
View file

@ -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"]

77
README.md Normal file
View file

@ -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.

157
app.py Normal file
View file

@ -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)

12
compose.yaml Normal file
View file

@ -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}

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
httpx
fastapi
uvicorn[standard]