Files
Unraid-AppOps-Gateway-Public/backend/app/main.py
T

364 lines
15 KiB
Python

from __future__ import annotations
import asyncio
import logging
import re
from collections import defaultdict, deque
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from time import monotonic
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from .api.routes_actions import router as actions_router
from .api.routes_ai import router as ai_router
from .api.routes_ai_remediation import router as ai_remediation_router
from .api.routes_ai_security import router as ai_security_router
from .api.routes_approvals import router as approvals_router
from .api.routes_apps import router as apps_router
from .api.routes_audit import router as audit_router
from .api.routes_authentik import router as authentik_router
from .api.routes_connectors import router as connectors_router
from .api.routes_containers import router as containers_router
from .api.routes_deluge import router as deluge_router
from .api.routes_deployments import router as deployments_router
from .api.routes_enrollments import router as enrollments_router
from .api.routes_gitea_actions import router as gitea_actions_router
from .api.routes_health import router as health_router
from .api.routes_integrations import n8n_router
from .api.routes_integrations import router as integrations_router
from .api.routes_inventory import router as inventory_router
from .api.routes_jobs import router as jobs_router
from .api.routes_knowledge import router as knowledge_router
from .api.routes_media_pipeline import router as media_pipeline_router
from .api.routes_nginx_proxy_manager import alias_router as npm_alias_router
from .api.routes_nginx_proxy_manager import router as npm_router
from .api.routes_notifications import router as notifications_router
from .api.routes_observability import router as observability_router
from .api.routes_playbooks import router as playbooks_router
from .api.routes_plex import router as plex_router
from .api.routes_policies import router as policies_router
from .api.routes_prowlarr import router as prowlarr_router
from .api.routes_privatemuse_artifacts import router as privatemuse_artifacts_router
from .api.routes_radarr import router as radarr_router
from .api.routes_runtime import router as runtime_router
from .api.routes_safety_center import router as safety_center_router
from .api.routes_setup import router as setup_router
from .api.routes_sonarr import router as sonarr_router
from .api.routes_stacks import router as stacks_router
from .api.routes_system import router as system_router
from .api.routes_tautulli import router as tautulli_router
from .api.routes_unraid import router as unraid_router
from .api.routes_voice import router as voice_router
from .api.routes_voice_operator import router as voice_operator_router
from .api.routes_widefrog import router as widefrog_router
from .core.config import settings
from .core.security import require_operator_request, require_service_request, voice_auth
from .core.version import APP_VERSION
from .db import session as db_session
from .db.init_db import init_db
from .mcp_server import (
appops_mcp,
mcp_http_app,
restore_tool_metrics,
set_session_running,
)
from .services import knowledge_service
from .services.discovery_scheduler import run_periodic_discovery
from .services.maintenance_scheduler import (
persist_observability,
run_periodic_maintenance,
)
from .services.request_metrics import record_request, restore_request_metrics
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
try:
with Session(db_session.engine) as session:
restore_request_metrics(session)
restore_tool_metrics(session)
knowledge_service.ensure_index_current(session)
except Exception: # noqa: BLE001 - RAG must degrade without taking down the control plane.
logger.exception("Knowledge index startup refresh failed")
# A zero interval is an explicit opt-out. In particular, development and
# test instances must not run background reconciliation merely because the
# ASGI lifespan has started.
discovery_task = (
asyncio.create_task(run_periodic_discovery(settings.discovery_interval_seconds))
if settings.discovery_interval_seconds > 0
else None
)
maintenance_enabled = any(
(
settings.notification_interval_seconds,
settings.connector_health_interval_seconds,
settings.observability_persist_interval_seconds,
settings.restore_drill_interval_seconds,
settings.mcp_canary_interval_seconds if settings.mcp_enabled else 0,
)
)
maintenance_task = asyncio.create_task(run_periodic_maintenance()) if maintenance_enabled else None
async with AsyncExitStack() as stack:
if settings.mcp_enabled:
await stack.enter_async_context(appops_mcp.session_manager.run())
set_session_running(True)
try:
yield
finally:
set_session_running(False)
if discovery_task:
discovery_task.cancel()
if maintenance_task:
maintenance_task.cancel()
if discovery_task:
try:
await discovery_task
except asyncio.CancelledError:
pass
if maintenance_task:
try:
await maintenance_task
except asyncio.CancelledError:
pass
await asyncio.to_thread(persist_observability)
app = FastAPI(title=settings.app_title, version=APP_VERSION, lifespan=lifespan)
_OPERATOR_GET_PREFIXES = (
"/api/admin/",
"/api/operator/",
"/api/settings/secrets",
)
_MUTATION_WINDOWS: dict[str, deque[float]] = defaultdict(deque)
_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
_MAX_MACHINE_BODY_BYTES = 1_048_576
_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
def _consume_mutation_budget(identity: str) -> bool:
now = monotonic()
window = _MUTATION_WINDOWS[identity]
while window and now - window[0] >= 60:
window.popleft()
if len(window) >= settings.mutation_rate_limit_per_minute:
return False
window.append(now)
return True
class MachineBodyLimitMiddleware:
"""Apply a real streaming cap before machine-interface body parsing."""
def __init__(self, app: ASGIApp, max_body_bytes: int = _MAX_MACHINE_BODY_BYTES):
self.app = app
self.max_body_bytes = max_body_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
path = str(scope.get("path", ""))
method = str(scope.get("method", "GET")).upper()
is_machine_post = method not in _SAFE_METHODS and (
path == "/mcp" or path.startswith("/mcp/")
or path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/")
)
if scope["type"] != "http" or not is_machine_post:
await self.app(scope, receive, send)
return
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
chunk = message.get("body", b"")
body.extend(chunk)
if len(body) > self.max_body_bytes:
response = JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"})
await response(scope, receive, send)
return
if not message.get("more_body", False):
break
replayed = False
async def replay_receive() -> Message:
nonlocal replayed
if replayed:
return {"type": "http.disconnect"}
replayed = True
return {"type": "http.request", "body": bytes(body), "more_body": False}
await self.app(scope, replay_receive, send)
@app.middleware("http")
async def private_operator_boundary(request, call_next):
request_started = monotonic()
supplied_request_id = request.headers.get("X-Request-ID", "")
request_id = supplied_request_id if _REQUEST_ID_PATTERN.fullmatch(supplied_request_id) else uuid4().hex
path = request.url.path
def finalize_response(response):
route = request.scope.get("route")
route_path = getattr(route, "path", path)
duration_ms = (monotonic() - request_started) * 1000
record_request(method=request.method, path=str(route_path), status=response.status_code, duration_ms=duration_ms)
response.headers.setdefault("X-Request-ID", request_id)
response.headers.setdefault("Server-Timing", f"app;dur={duration_ms:.2f}")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; "
"img-src 'self' data:; font-src 'self'; style-src 'self' 'unsafe-inline'; "
"script-src 'self'; connect-src 'self' http: https: ws: wss:",
)
return response
is_api = path == "/api" or path.startswith("/api/")
is_voice = path.startswith("/api/voice/")
is_mcp = path == "/mcp" or path.startswith("/mcp/")
is_n8n = path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/")
is_operator_session_status = path == "/api/operator/session" and request.method in _SAFE_METHODS
if is_mcp or is_n8n:
try:
require_service_request(request, "mcp" if is_mcp else "n8n")
except HTTPException as exc:
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
if is_mcp or is_n8n:
content_length = request.headers.get("content-length")
if content_length and content_length.isdigit() and int(content_length) > 1_048_576:
return finalize_response(JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"}))
needs_operator = is_api and not is_voice and not is_n8n and not is_operator_session_status and (
request.method not in _SAFE_METHODS
or any(path.startswith(prefix) for prefix in _OPERATOR_GET_PREFIXES)
)
if needs_operator:
content_length = request.headers.get("content-length")
if content_length and content_length.isdigit() and int(content_length) > 1_048_576:
return finalize_response(JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"}))
try:
require_operator_request(request)
except HTTPException as exc:
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
is_read_only_n8n_query = is_n8n and path == "/api/integrations/n8n/knowledge/query"
should_rate_limit = request.method not in _SAFE_METHODS and not is_read_only_n8n_query and (
is_n8n or (needs_operator and settings.operator_auth_required)
)
if should_rate_limit:
client_host = request.client.host if request.client else "unknown"
identity = f"n8n:{client_host}" if is_n8n else f"operator:{client_host}"
if not _consume_mutation_budget(identity):
return finalize_response(JSONResponse(
status_code=429,
content={"detail": "Mutation rate limit exceeded"},
headers={"Retry-After": "60"},
))
response = await call_next(request)
return finalize_response(response)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin, "http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(MachineBodyLimitMiddleware)
app.include_router(health_router)
app.include_router(system_router)
app.include_router(apps_router)
app.include_router(containers_router)
app.include_router(stacks_router)
app.include_router(unraid_router)
app.include_router(connectors_router)
app.include_router(inventory_router)
app.include_router(gitea_actions_router)
app.include_router(playbooks_router)
app.include_router(policies_router)
app.include_router(sonarr_router)
app.include_router(radarr_router)
app.include_router(prowlarr_router)
app.include_router(privatemuse_artifacts_router)
app.include_router(deluge_router)
app.include_router(plex_router)
app.include_router(tautulli_router)
app.include_router(media_pipeline_router)
app.include_router(authentik_router)
app.include_router(npm_router)
app.include_router(npm_alias_router)
app.include_router(widefrog_router)
app.include_router(actions_router)
app.include_router(approvals_router)
app.include_router(audit_router)
app.include_router(jobs_router)
app.include_router(observability_router)
app.include_router(safety_center_router)
app.include_router(notifications_router)
app.include_router(voice_router, dependencies=[Depends(voice_auth)])
app.include_router(voice_operator_router)
app.include_router(ai_router)
app.include_router(ai_security_router)
app.include_router(ai_remediation_router)
app.include_router(setup_router)
app.include_router(runtime_router)
app.include_router(deployments_router)
app.include_router(enrollments_router)
app.include_router(integrations_router)
app.include_router(n8n_router)
app.include_router(knowledge_router)
app.mount("/mcp", mcp_http_app)
@app.get('/api')
def index():
return {'name': 'unraid-appops-gateway', 'status': 'ok'}
def _static_root() -> Path:
root = Path(settings.static_dir)
if not root.is_absolute():
root = Path.cwd() / root
return root
static_root = _static_root()
assets_root = static_root / 'assets'
if assets_root.exists():
app.mount('/assets', StaticFiles(directory=str(assets_root)), name='assets')
def _index_response(index_file: Path) -> FileResponse:
return FileResponse(
index_file,
headers={
'Cache-Control': 'no-store',
},
)
@app.get('/{full_path:path}', include_in_schema=False)
def serve_frontend(full_path: str):
if full_path.startswith('api/'):
raise HTTPException(status_code=404, detail='api_route_not_found')
index_file = static_root / 'index.html'
requested = static_root / full_path
if full_path and requested.exists() and requested.is_file():
return FileResponse(requested)
if index_file.exists():
return _index_response(index_file)
return {'name': 'unraid-appops-gateway', 'status': 'api_only', 'frontend': 'not_built'}