Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e

This commit is contained in:
Public Source Publisher committed 2026-10-01 19:39:31 +00:00
1 parent c61fbfd87f
commit 1a54836efc
101 files changed
+4973 -1298

No files matched your search

+56 -17
View File
@@ -18,6 +18,8 @@ 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
@@ -57,9 +59,11 @@ 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_operator_request, require_service_request, voice_auth
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
@@ -80,6 +84,20 @@ 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()
@@ -135,11 +153,6 @@ async def lifespan(_: FastAPI):
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
@@ -231,7 +244,10 @@ async def private_operator_boundary(request, call_next):
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
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_mcp or is_n8n:
try:
require_service_request(request, "mcp" if is_mcp else "n8n")
@@ -241,32 +257,48 @@ async def private_operator_boundary(request, call_next):
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)
)
# 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:
require_operator_request(request)
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 and settings.operator_auth_required)
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"operator:{client_host}"
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"},
))
response = await call_next(request)
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:
response.headers.setdefault("Cache-Control", "no-store")
return finalize_response(response)
app.add_middleware(
@@ -279,6 +311,7 @@ app.add_middleware(
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)
@@ -311,6 +344,8 @@ 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)
@@ -354,8 +389,12 @@ def _index_response(index_file: Path) -> FileResponse:
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
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():