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,255 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
from ipaddress import ip_address, ip_network
|
||||
from time import monotonic
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
OPERATOR_SESSION_COOKIE = "appops_operator_session"
|
||||
OPERATOR_SESSION_CSRF_HEADER = "X-AppOps-Session-CSRF"
|
||||
_OPERATOR_SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
_operator_sessions: dict[str, tuple[float, str]] = {}
|
||||
_operator_sessions_lock = threading.Lock()
|
||||
|
||||
|
||||
def _operator_token() -> str | None:
|
||||
expected = settings.operator_token or os.getenv("APPOPS_OPERATOR_TOKEN")
|
||||
token_file = settings.operator_token_file or os.getenv("APPOPS_OPERATOR_TOKEN_FILE")
|
||||
if expected or not token_file:
|
||||
return expected
|
||||
try:
|
||||
with open(token_file, encoding="utf-8") as handle:
|
||||
return handle.read().strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def token_is_strong(value: str | None, *, minimum_length: int = 24) -> bool:
|
||||
if not value or len(value) < minimum_length:
|
||||
return False
|
||||
return value.strip().lower() not in {"change-me", "changeme", "dev", "test", "dev-voice-token"}
|
||||
|
||||
|
||||
def _extract_bearer_token(authorization: str | None) -> str:
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header missing")
|
||||
|
||||
prefix = "Bearer "
|
||||
if not authorization.startswith(prefix):
|
||||
raise HTTPException(status_code=401, detail="Authorization header missing")
|
||||
|
||||
return authorization[len(prefix):]
|
||||
|
||||
|
||||
def require_auth_token(provided_token: str | None, expected_token: str | None, scope: str):
|
||||
if expected_token:
|
||||
token = _extract_bearer_token(provided_token)
|
||||
if not hmac.compare_digest(token, expected_token):
|
||||
raise HTTPException(status_code=403, detail=f"Invalid {scope} token")
|
||||
|
||||
|
||||
def _operator_token_from_request(request: Request) -> str | None:
|
||||
explicit = request.headers.get("X-AppOps-Operator-Token")
|
||||
if explicit:
|
||||
return explicit.strip()
|
||||
authorization = request.headers.get("Authorization")
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
return authorization[len("Bearer "):].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _operator_token_fingerprint(value: str | None) -> str:
|
||||
return hashlib.sha256((value or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _remove_expired_operator_sessions(now: float) -> None:
|
||||
expired = [session_id for session_id, (expires_at, _) in _operator_sessions.items() if expires_at <= now]
|
||||
for session_id in expired:
|
||||
_operator_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def issue_operator_session(request: Request) -> str:
|
||||
"""Exchange the explicit operator token for an opaque, process-local browser session."""
|
||||
expected = _operator_token()
|
||||
provided = _operator_token_from_request(request)
|
||||
auth_required = settings.operator_auth_required
|
||||
if not auth_required:
|
||||
raise HTTPException(status_code=409, detail="Operator sessions are disabled")
|
||||
if not provided:
|
||||
raise HTTPException(status_code=401, detail="Operator token missing")
|
||||
if not expected:
|
||||
if auth_required:
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
|
||||
elif settings.environment == "production" and not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
|
||||
elif not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail="Invalid operator token")
|
||||
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
now = monotonic()
|
||||
with _operator_sessions_lock:
|
||||
_remove_expired_operator_sessions(now)
|
||||
_operator_sessions[session_id] = (
|
||||
now + _OPERATOR_SESSION_TTL_SECONDS,
|
||||
_operator_token_fingerprint(expected),
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def revoke_operator_session(request: Request) -> None:
|
||||
session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "")
|
||||
if not session_id:
|
||||
return
|
||||
with _operator_sessions_lock:
|
||||
_operator_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def _operator_session_identity(request: Request, expected: str | None) -> str | None:
|
||||
session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "")
|
||||
if not session_id:
|
||||
return None
|
||||
now = monotonic()
|
||||
with _operator_sessions_lock:
|
||||
_remove_expired_operator_sessions(now)
|
||||
record = _operator_sessions.get(session_id)
|
||||
if record and not hmac.compare_digest(record[1], _operator_token_fingerprint(expected)):
|
||||
_operator_sessions.pop(session_id, None)
|
||||
record = None
|
||||
if not record:
|
||||
return None
|
||||
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
csrf = request.headers.get(OPERATOR_SESSION_CSRF_HEADER, "")
|
||||
if not hmac.compare_digest(csrf, "1"):
|
||||
raise HTTPException(status_code=403, detail="Operator session CSRF validation failed")
|
||||
return "session"
|
||||
|
||||
|
||||
def _normalized_set(values: list[str] | None) -> set[str]:
|
||||
return {value.strip().casefold() for value in values or [] if value.strip()}
|
||||
|
||||
|
||||
def _request_from_trusted_operator_proxy(request: Request) -> bool:
|
||||
if not settings.operator_proxy_auth_enabled or not settings.operator_proxy_trusted_networks:
|
||||
return False
|
||||
client_host = request.client.host if request.client else ""
|
||||
try:
|
||||
client_ip = ip_address(client_host)
|
||||
except ValueError:
|
||||
return False
|
||||
for configured_network in settings.operator_proxy_trusted_networks:
|
||||
try:
|
||||
if client_ip in ip_network(configured_network, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _trusted_proxy_operator_identity(request: Request) -> str | None:
|
||||
"""Resolve an Authentik identity only across an explicitly trusted proxy boundary."""
|
||||
if not _request_from_trusted_operator_proxy(request):
|
||||
return None
|
||||
|
||||
allowed_users = _normalized_set(settings.operator_proxy_users)
|
||||
allowed_groups = _normalized_set(settings.operator_proxy_groups)
|
||||
if not allowed_users and not allowed_groups:
|
||||
return None
|
||||
|
||||
username = request.headers.get("X-authentik-username", "").strip()
|
||||
groups_header = request.headers.get("X-authentik-groups", "")
|
||||
groups = {
|
||||
item.strip().casefold()
|
||||
for item in re.split(r"[|,;]", groups_header)
|
||||
if item.strip()
|
||||
}
|
||||
if username and (username.casefold() in allowed_users or groups.intersection(allowed_groups)):
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def require_operator_request(request: Request) -> str:
|
||||
"""Validate the local operator boundary without leaking token material."""
|
||||
expected = _operator_token()
|
||||
auth_required = settings.operator_auth_required
|
||||
if not auth_required:
|
||||
return "private_unlocked"
|
||||
proxy_identity = _trusted_proxy_operator_identity(request)
|
||||
if proxy_identity:
|
||||
return f"authentik:{proxy_identity}"
|
||||
|
||||
provided = _operator_token_from_request(request)
|
||||
if provided:
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
|
||||
if settings.environment == "production" and not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail="Invalid operator token")
|
||||
return "operator"
|
||||
|
||||
session_identity = _operator_session_identity(request, expected)
|
||||
if session_identity:
|
||||
return session_identity
|
||||
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
|
||||
if settings.environment == "production" and not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
|
||||
raise HTTPException(status_code=401, detail="Operator authorization missing")
|
||||
|
||||
|
||||
def operator_session_status(request: Request) -> dict[str, str | bool | None]:
|
||||
"""Return the current request's operator state without exposing credentials."""
|
||||
try:
|
||||
identity = require_operator_request(request)
|
||||
except HTTPException:
|
||||
return {"authorized": False, "mode": "locked", "identity": None}
|
||||
if identity.startswith("authentik:"):
|
||||
return {"authorized": True, "mode": "account", "identity": identity.split(":", 1)[1]}
|
||||
if identity == "session":
|
||||
return {"authorized": True, "mode": "session", "identity": None}
|
||||
return {"authorized": True, "mode": "token" if identity == "operator" else "private", "identity": None}
|
||||
|
||||
|
||||
def require_service_request(request: Request, service: str) -> str:
|
||||
"""Authenticate a narrowly scoped machine integration without reusing operator credentials."""
|
||||
if service == "mcp":
|
||||
enabled, expected = settings.mcp_enabled, settings.mcp_token
|
||||
elif service == "n8n":
|
||||
enabled, expected = settings.n8n_enabled, settings.n8n_token
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Unknown service identity")
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=404, detail=f"{service} integration is disabled")
|
||||
if not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail=f"{service} authentication is not safely configured")
|
||||
provided = _operator_token_from_request(request)
|
||||
if not provided:
|
||||
raise HTTPException(status_code=401, detail=f"{service} authorization missing")
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail=f"Invalid {service} token")
|
||||
return service
|
||||
|
||||
|
||||
def n8n_auth(request: Request) -> str:
|
||||
return require_service_request(request, "n8n")
|
||||
|
||||
|
||||
def operator_auth(request: Request) -> str:
|
||||
return require_operator_request(request)
|
||||
|
||||
|
||||
def voice_auth(authorization: str | None = Header(default=None, alias='Authorization')):
|
||||
expected = settings.voice_token or os.getenv("APP_OPS_VOICE_TOKEN")
|
||||
if not expected:
|
||||
return True
|
||||
|
||||
require_auth_token(authorization, expected, "voice")
|
||||
return True
|
||||
Reference in new issue
Block a user