Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
+56
-17
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user