413 lines
18 KiB
Python
413 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.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.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'}
|