feat: add restricted media handoff bridge #50

Merged
sascha merged 2 commits from feat/media-handoff-orchestrator-20260905 into main 2026-09-05 07:59:55 +02:00
Showing only changes of commit 73d1b0ed38 - Show all commits

68
app.py
View file

@ -12,13 +12,14 @@ from contextlib import asynccontextmanager
from contextvars import ContextVar from contextvars import ContextVar
log = logging.getLogger("butler") log = logging.getLogger("butler")
VERSION = "2.3.5" VERSION = "2.3.6"
API_DIR = os.environ.get("API_KEY_DIR", "/data/api") API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml") CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml")
UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html")) UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html"))
MEDIA_HANDOFF_ALLOWED_NETWORKS = os.environ.get("MEDIA_HANDOFF_ALLOWED_NETWORKS", "10.2.1.119/32")
# --- Config loading --- # --- Config loading ---
@ -3395,6 +3396,71 @@ async def tts_health(_=Depends(_verify)):
return results return results
class MediaHandoffPayload(BaseModel):
action: Literal["start", "moved", "status", "fail"]
category: str | None = Field(None, max_length=32)
directory: str | None = Field(None, max_length=1000)
release: str | None = Field(None, max_length=500)
cleanName: str | None = Field(None, max_length=500)
jobId: str | None = Field(None, max_length=80)
reason: str | None = Field(None, max_length=300)
def _media_handoff_caller_allowed(request: Request) -> bool:
auth = request.headers.get("authorization", "")
if BUTLER_TOKEN and secrets.compare_digest(auth, f"Bearer {BUTLER_TOKEN}"):
return True
try:
caller = ipaddress.ip_address(request.client.host if request.client else "")
networks = [
ipaddress.ip_network(value.strip(), strict=False)
for value in MEDIA_HANDOFF_ALLOWED_NETWORKS.split(",")
if value.strip()
]
except ValueError:
return False
return any(caller in network for network in networks)
@app.post("/media/handoff")
async def media_handoff(payload: MediaHandoffPayload, request: Request):
"""Narrow SABnzbd-to-n8n bridge; no generic unauthenticated proxy access."""
if not _media_handoff_caller_allowed(request):
raise HTTPException(403, "Media handoff caller is not allowed")
allowed_categories = {"serien4k", "serien", "video4k", "video"}
if payload.action == "start":
if payload.category not in allowed_categories:
raise HTTPException(422, "Unsupported media category")
expected_prefix = f"/usenet/complete/{payload.category}/"
if not payload.directory or not payload.directory.startswith(expected_prefix):
raise HTTPException(422, "Invalid media handoff directory")
if not payload.release:
raise HTTPException(422, "Release name is required")
else:
if not payload.jobId or not re.fullmatch(r"[a-z0-9-]{8,80}", payload.jobId):
raise HTTPException(422, "Valid jobId is required")
cfg = SERVICES.get("n8n")
if not cfg or not cfg.get("url"):
raise HTTPException(503, "n8n service is not configured")
target = f"{cfg['url'].rstrip('/')}/webhook/media-handoff"
body = payload.model_dump(exclude_none=True) if hasattr(payload, "model_dump") else payload.dict(exclude_none=True)
try:
async with httpx.AsyncClient(verify=False, timeout=15) as client:
response = await client.post(target, json=body, headers={"Content-Type": "application/json"})
except httpx.HTTPError as exc:
_audit("/media/handoff", "POST", 502, f"action={payload.action} error={type(exc).__name__}")
raise HTTPException(502, "n8n media handoff is unavailable") from exc
try:
result = response.json()
except Exception:
result = {"ok": False, "error": "invalid_n8n_response"}
_audit("/media/handoff", "POST", response.status_code, f"action={payload.action}")
return JSONResponse(content=result, status_code=response.status_code)
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
SKIP_SERVICES = {"vm", "inventory", "ansible", "debug", "tts", "status", "audit", "config"} SKIP_SERVICES = {"vm", "inventory", "ansible", "debug", "tts", "status", "audit", "config"}