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

424 lines
18 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.exceptions import RequestValidationError
from fastapi.exception_handlers import request_validation_exception_handler
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_settings import router as ai_settings_router
from .api.routes_exposure_settings import router as exposure_settings_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_user_auth import router as user_auth_router
from .api.routes_widefrog import router as widefrog_router
from .core.config import settings
from .core.security import require_service_request, voice_auth
from .services.user_access import actor_identity, authorize_request, current_user
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 .runner_statistics_mcp import runner_statistics_mcp_http_app, run_runner_statistics_mcp
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__)
class LoginAccessLogFilter(logging.Filter):
"""Never include authorization codes or login state in uvicorn access logs."""
def filter(self, record: logging.LogRecord) -> bool:
if isinstance(record.args, tuple) and len(record.args) >= 3:
path = str(record.args[2])
if path.startswith("/api/auth/callback/"):
record.args = (*record.args[:2], path.split("?", 1)[0], *record.args[3:])
return True
logging.getLogger("uvicorn.access").addFilter(LoginAccessLogFilter())
@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)
await stack.enter_async_context(run_runner_statistics_mcp())
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)
_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 == "/mcp-runner-statistics" or path.startswith("/mcp-runner-statistics/")
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_runner_statistics_mcp = path == "/mcp-runner-statistics" or path.startswith("/mcp-runner-statistics/")
is_n8n = path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/")
is_public_auth = request.method in _SAFE_METHODS and (
path in {"/api/auth/session", "/api/auth/providers", "/api/health"}
or path.startswith(("/api/auth/login/", "/api/auth/callback/"))
)
if is_runner_statistics_mcp:
try:
require_service_request(request, "mcp_runner_statistics")
except HTTPException as exc:
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
elif 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_runner_statistics_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"}))
# Machine voice calls keep their scoped bearer identity. Browser voice calls
# always use the same user permissions as every other human API request.
machine_voice = is_voice and bool(request.headers.get("Authorization"))
if machine_voice:
try:
require_service_request(request, "voice")
except HTTPException as exc:
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
needs_operator = is_api and not is_n8n and not machine_voice and not is_public_auth
user = None
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:
user = current_user(request) if path == "/api/auth/logout" else authorize_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
)
if path.startswith("/api/auth/login/"):
client_host = request.client.host if request.client else "unknown"
if not _consume_mutation_budget(f"login:{client_host}"):
return finalize_response(JSONResponse(status_code=429, content={"detail": "Login rate limit exceeded"}, headers={"Retry-After": "60"}))
if should_rate_limit:
client_host = request.client.host if request.client else "unknown"
identity = f"n8n:{client_host}" if is_n8n else f"user:{user.id if user else 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"},
))
actor_token = actor_identity.set(f"{user.provider}:{user.subject}" if user else None)
try:
response = await call_next(request)
finally:
actor_identity.reset(actor_token)
if is_api or is_runner_statistics_mcp:
response.headers.setdefault("Cache-Control", "no-store")
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(user_auth_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_settings_router)
app.include_router(exposure_settings_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.exception_handler(RequestValidationError)
async def validation_error_response(request, exc):
# Root template input accepts no credentials. Never echo an accidentally
# submitted secret through Pydantic's validation-error input field.
if request.url.path == "/api/enrollments/trust/oidc-profile":
return JSONResponse(status_code=422, content={"detail": "invalid_oidc_profile"})
return await request_validation_exception_handler(request, exc)
app.include_router(integrations_router)
app.include_router(n8n_router)
app.include_router(knowledge_router)
app.mount("/mcp", mcp_http_app)
app.mount("/mcp-runner-statistics", runner_statistics_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').resolve()
if not index_file.is_relative_to(static_root.resolve()):
raise HTTPException(status_code=404, detail='frontend_file_not_found')
requested = (static_root / full_path).resolve()
if not requested.is_relative_to(static_root.resolve()):
raise HTTPException(status_code=404, detail='frontend_file_not_found')
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'}