Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,363 @@
|
||||
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'}
|
||||
Reference in new issue
Block a user