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