dc4593f8b5
Product decision: drop 3389 remote desktop page, API, guacd sidecar, and related tests after unresolved target-side black screen issues.
744 lines
28 KiB
Python
744 lines
28 KiB
Python
"""Nexus — FastAPI Application Entry Point
|
|
Clean Architecture: Presentation Layer (API routes + middleware + lifespan)
|
|
|
|
Lifespan startup (when .env exists — normal mode):
|
|
1. Verify SECRET_KEY is set (fatal if missing)
|
|
2. init_db() — create tables
|
|
3. init_redis() — ADR-009 mandatory validation (exit if unavailable)
|
|
4. load_settings_from_db() — override mutable settings from MySQL
|
|
5. Start Redis Pub/Sub subscriber (ADR-010)
|
|
6. Launch background tasks (heartbeat flush, self-monitor)
|
|
|
|
Lifespan startup (no .env — install mode):
|
|
- Only the /api/install/ routes are functional
|
|
- All other API routes return 503 "System not configured"
|
|
- User completes the install wizard at /app/install.html
|
|
|
|
D2: install wizard (install.html + FastAPI API)
|
|
D7: DB session leak fix — middleware auto-manages session lifecycle per request.
|
|
"""
|
|
|
|
import asyncio
|
|
import fcntl
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from server.config import settings
|
|
from server.infrastructure.database.session import init_db, AsyncSessionLocal
|
|
|
|
# Detect install mode: no .env = first-time setup
|
|
# Evaluated at call time so install wizard writing .env is detected immediately
|
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def is_install_mode() -> bool:
|
|
"""Check install mode dynamically (not at import time)."""
|
|
return not (ROOT_DIR / ".env").exists()
|
|
|
|
# Install wizard (no JWT required — runs before system is configured)
|
|
from server.api.install import router as install_router
|
|
from server.api.auth_jwt import JwtAuthMiddleware
|
|
from server.api.auth import REFRESH_COOKIE_NAME
|
|
|
|
# API routes (require JWT + full app init)
|
|
from server.api.servers import router as servers_router
|
|
from server.api.auth import router as auth_router
|
|
from server.api.agent import router as agent_router
|
|
from server.api.scripts import router as scripts_router
|
|
from server.api.execution_records import router as execution_records_router
|
|
from server.api.settings import (
|
|
router as settings_router,
|
|
schedule_router,
|
|
preset_router,
|
|
ssh_key_preset_router,
|
|
audit_router,
|
|
retry_router,
|
|
alert_history_router,
|
|
)
|
|
from server.api.websocket import router as websocket_router
|
|
from server.api.health import router as health_router
|
|
from server.api.assets import router as assets_router
|
|
from server.api.webssh import router as webssh_router
|
|
from server.api.sync_v2 import router as sync_v2_router
|
|
from server.api.files import router as files_router
|
|
from server.api.search import router as search_router
|
|
from server.api.terminal import router as terminal_router
|
|
|
|
# Background tasks
|
|
from server.background.heartbeat_flush import heartbeat_flush_loop
|
|
from server.background.script_execution_flush import script_execution_flush_loop
|
|
from server.background.self_monitor import self_monitor_loop
|
|
from server.background.server_offline_monitor import server_offline_monitor_loop
|
|
from server.background.schedule_runner import schedule_runner_loop
|
|
from server.background.retry_runner import retry_runner_loop
|
|
from server.background.ip_allowlist_refresh import ip_allowlist_refresh_loop
|
|
from server.background.upload_staging_cleanup import upload_staging_cleanup_loop
|
|
from server.background.sync_log_purge import sync_log_purge_loop
|
|
|
|
logger = logging.getLogger("nexus")
|
|
|
|
# Track background tasks for clean shutdown
|
|
_background_tasks: list[asyncio.Task] = []
|
|
|
|
# Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn)
|
|
_PRIMARY_LOCK_KEY = "nexus:primary_worker"
|
|
_PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically
|
|
_PRIMARY_FILE_LOCK_FD: int | None = None
|
|
|
|
|
|
def _try_acquire_file_primary_lock() -> bool:
|
|
"""Fallback when Redis is unavailable: one process per host via flock."""
|
|
global _PRIMARY_FILE_LOCK_FD
|
|
lock_path = os.environ.get("NEXUS_PRIMARY_LOCK_FILE", "/tmp/nexus-primary.lock")
|
|
try:
|
|
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
_PRIMARY_FILE_LOCK_FD = fd
|
|
return True
|
|
except (OSError, BlockingIOError):
|
|
return False
|
|
|
|
|
|
async def _acquire_primary_lock() -> bool:
|
|
"""Try to become the primary worker via Redis SETNX.
|
|
|
|
Only one uvicorn worker will hold this lock at a time.
|
|
The lock auto-expires after PRIMARY_LOCK_TTL seconds (safety net for crashes).
|
|
Primary worker renews the lock periodically via _renew_primary_lock().
|
|
"""
|
|
try:
|
|
from server.infrastructure.redis.client import get_redis
|
|
import os
|
|
redis = get_redis()
|
|
worker_id = str(os.getpid())
|
|
# SET with NX (only if not exists) + EX (TTL)
|
|
acquired = await redis.set(_PRIMARY_LOCK_KEY, worker_id, nx=True, ex=_PRIMARY_LOCK_TTL)
|
|
if acquired:
|
|
# Start lock renewal background task
|
|
asyncio.create_task(_renew_primary_lock(), name="primary_lock_renewal")
|
|
logger.info(f"Primary worker lock acquired (pid={worker_id})")
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.warning("Primary lock check failed, trying file lock fallback: %s", e)
|
|
acquired = _try_acquire_file_primary_lock()
|
|
if acquired:
|
|
logger.info("Primary worker lock acquired via file fallback")
|
|
return acquired
|
|
|
|
|
|
async def _renew_primary_lock():
|
|
"""Periodically renew the primary worker lock in Redis."""
|
|
import os
|
|
worker_id = str(os.getpid())
|
|
while True:
|
|
await asyncio.sleep(_PRIMARY_LOCK_TTL // 2) # Renew at half the TTL
|
|
try:
|
|
from server.infrastructure.redis.client import get_redis
|
|
redis = get_redis()
|
|
current = await redis.get(_PRIMARY_LOCK_KEY)
|
|
if current == worker_id:
|
|
await redis.expire(_PRIMARY_LOCK_KEY, _PRIMARY_LOCK_TTL)
|
|
else:
|
|
logger.warning("Primary worker lock lost — another worker took over; cancelling background tasks")
|
|
for task in _background_tasks:
|
|
task.cancel()
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Primary lock renewal failed: {e}")
|
|
|
|
|
|
# ── D7: DB Session Middleware (fixes session leak in 4 service factories) ──
|
|
|
|
class DbSessionMiddleware:
|
|
"""Pure ASGI middleware: auto-manage DB session lifecycle per HTTP request.
|
|
|
|
Problem: 4 service factories (get_server_service, get_script_service,
|
|
get_auth_service, get_sync_service) create AsyncSessionLocal() but never
|
|
close them — causing connection pool exhaustion (P0 bug).
|
|
|
|
Solution: Middleware opens session at request start, stores in scope["state"],
|
|
and closes it after response. Service factories read from request.state.db
|
|
instead of creating new sessions.
|
|
|
|
Pattern: Similar to Django's ATOMIC_REQUESTS — session lifetime = request lifetime.
|
|
|
|
NOTE: Uses pure ASGI (not BaseHTTPMiddleware) to avoid MissingGreenlet errors
|
|
with SQLAlchemy async. BaseHTTPMiddleware's call_next() runs the endpoint in
|
|
a separate task, which breaks SQLAlchemy's async greenlet context, causing
|
|
session.commit() and session.refresh() to fail.
|
|
"""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
path = scope.get("path", "")
|
|
|
|
# Skip non-API routes (WebSocket, health probe, static files).
|
|
# /health/detail needs DB for JWT admin lookup — same session lifecycle as API.
|
|
if not path.startswith("/api/") and path != "/health/detail":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
# Skip install API (it manages its own DB connections)
|
|
if path.startswith("/api/install/"):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
# Skip in install mode (no global engine available)
|
|
if is_install_mode():
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
# Ensure scope has a state dict (for request.state.db sharing)
|
|
scope.setdefault("state", {})
|
|
|
|
# Open session for this request
|
|
async with AsyncSessionLocal() as session:
|
|
scope["state"]["db"] = session
|
|
try:
|
|
await self.app(scope, receive, send)
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Application lifecycle: startup → background tasks → shutdown
|
|
|
|
Install mode (no .env): only install API + static files available.
|
|
Normal mode (.env exists): full initialization with DB, Redis, background tasks.
|
|
"""
|
|
|
|
# ── Install mode: skip everything ──
|
|
if is_install_mode():
|
|
logger.info("⚠ INSTALL MODE — .env not found. Visit /app/install.html to configure.")
|
|
yield
|
|
return
|
|
|
|
# ── Normal mode: full startup ──
|
|
if not settings.SECRET_KEY:
|
|
logger.error("SECRET_KEY is empty! Set it in .env or MySQL settings table.")
|
|
raise SystemExit("SECRET_KEY is required for Nexus to start.")
|
|
|
|
if not settings.API_KEY:
|
|
logger.error("API_KEY is empty! Set it in .env (generated by install wizard).")
|
|
raise SystemExit("API_KEY is required for Agent authentication and encryption fallback.")
|
|
|
|
if not settings.ENCRYPTION_KEY:
|
|
logger.error("ENCRYPTION_KEY is empty! Set it in .env (generated by install wizard).")
|
|
raise SystemExit("ENCRYPTION_KEY is required for credential encryption.")
|
|
|
|
# 1. Initialize database tables
|
|
await init_db()
|
|
logger.info("Database tables initialized")
|
|
|
|
# 1a. Seed built-in quick commands
|
|
from server.api.terminal import seed_builtin_commands
|
|
async with AsyncSessionLocal() as session:
|
|
await seed_builtin_commands(session)
|
|
logger.info("Built-in quick commands seeded")
|
|
|
|
# 1b. D8: Run data migrations (category → Node, backfill platform_id)
|
|
from server.infrastructure.database.migrations import run_migrations
|
|
await run_migrations()
|
|
|
|
# 2. Initialize Redis (ADR-009: mandatory — exits if unavailable)
|
|
from server.infrastructure.redis.client import init_redis, close_redis
|
|
await init_redis(app)
|
|
|
|
# 2a. Restore refresh tokens from MySQL → Redis (sessions survive Redis restart)
|
|
from server.application.services.refresh_token_hydration import hydrate_refresh_tokens_from_mysql
|
|
await hydrate_refresh_tokens_from_mysql()
|
|
|
|
# 3. Load mutable settings from MySQL (override .env values)
|
|
async with AsyncSessionLocal() as session:
|
|
overridden = await settings.load_settings_from_db(session)
|
|
logger.info(f"Settings from DB: {overridden} overrides applied")
|
|
# Migrate api_base_url from .env to MySQL if missing (for existing installations)
|
|
await settings.ensure_api_base_url_in_db(session)
|
|
|
|
# 4. Start Redis Pub/Sub subscriber (ADR-010)
|
|
from server.api.websocket import start_redis_subscriber, stop_redis_subscriber
|
|
await start_redis_subscriber()
|
|
|
|
# 4a. Settings cross-worker sync (notify toggles, thresholds, etc.)
|
|
from server.infrastructure.settings_broadcast import (
|
|
start_settings_subscriber,
|
|
stop_settings_subscriber,
|
|
)
|
|
await start_settings_subscriber()
|
|
|
|
# 5. Launch background tasks (only on primary worker to avoid duplicate execution)
|
|
# When running with --workers N, each worker gets its own lifespan.
|
|
# Use Redis-based leader election to ensure only one worker runs background tasks.
|
|
is_primary = await _acquire_primary_lock()
|
|
if is_primary:
|
|
from server.application.services.server_batch_service import recover_orphaned_batch_jobs
|
|
from server.background.server_batch_reconcile import server_batch_reconcile_loop
|
|
|
|
recovered = await recover_orphaned_batch_jobs()
|
|
if recovered:
|
|
logger.info("Startup: recovered %s orphaned server batch job(s)", recovered)
|
|
|
|
task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush")
|
|
task_script_flush = asyncio.create_task(
|
|
script_execution_flush_loop(), name="script_execution_flush"
|
|
)
|
|
task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor")
|
|
task_offline_monitor = asyncio.create_task(
|
|
server_offline_monitor_loop(), name="server_offline_monitor"
|
|
)
|
|
task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner")
|
|
task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner")
|
|
task_ip_refresh = asyncio.create_task(ip_allowlist_refresh_loop(), name="ip_allowlist_refresh")
|
|
task_upload_cleanup = asyncio.create_task(
|
|
upload_staging_cleanup_loop(), name="upload_staging_cleanup"
|
|
)
|
|
task_batch_reconcile = asyncio.create_task(
|
|
server_batch_reconcile_loop(), name="server_batch_reconcile"
|
|
)
|
|
task_sync_log_purge = asyncio.create_task(
|
|
sync_log_purge_loop(), name="sync_log_purge"
|
|
)
|
|
_background_tasks.extend([
|
|
task_flush,
|
|
task_script_flush,
|
|
task_monitor,
|
|
task_offline_monitor,
|
|
task_schedule,
|
|
task_retry,
|
|
task_ip_refresh,
|
|
task_upload_cleanup,
|
|
task_batch_reconcile,
|
|
task_sync_log_purge,
|
|
])
|
|
logger.info("Primary worker — background tasks launched")
|
|
else:
|
|
logger.info("Secondary worker — background tasks skipped (primary worker handles them)")
|
|
|
|
# 6. Start asyncssh connection pool (W1)
|
|
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
|
await ssh_pool.start()
|
|
|
|
logger.info(
|
|
f"{settings.SYSTEM_NAME} v6.0.0 started — "
|
|
f"background tasks: heartbeat_flush(10min), script_execution_flush(60s), "
|
|
f"server_batch_reconcile(60s), sync_log_purge(24h), self_monitor(30s), "
|
|
f"server_offline_monitor(30s), "
|
|
f"schedule_runner(60s), retry_runner(5min), upload_staging_cleanup(1h)"
|
|
)
|
|
|
|
yield
|
|
|
|
# ── Shutdown ──
|
|
# Cancel WebSocket heartbeat task (H11)
|
|
from server.api.websocket import manager as ws_manager, sync_manager as ws_sync_manager
|
|
ws_manager.cancel_heartbeat()
|
|
ws_sync_manager.cancel_heartbeat()
|
|
|
|
for task in _background_tasks:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
_background_tasks.clear()
|
|
|
|
# Stop Redis Pub/Sub subscriber
|
|
await stop_redis_subscriber()
|
|
await stop_settings_subscriber()
|
|
|
|
# Close Redis connection
|
|
await close_redis()
|
|
|
|
# Stop asyncssh connection pool
|
|
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
|
await ssh_pool.stop()
|
|
|
|
# Close shared Telegram httpx client
|
|
from server.infrastructure.telegram import close_client as close_telegram_client
|
|
await close_telegram_client()
|
|
|
|
logger.info(f"{settings.SYSTEM_NAME} shutting down — background tasks cancelled")
|
|
|
|
|
|
def _expose_api_docs() -> bool:
|
|
flag = (getattr(settings, "EXPOSE_API_DOCS", "false") or "false").lower()
|
|
return flag in ("true", "1", "yes", "on")
|
|
|
|
|
|
_docs_url = "/docs" if _expose_api_docs() else None
|
|
_redoc_url = "/redoc" if _expose_api_docs() else None
|
|
_openapi_url = "/openapi.json" if _expose_api_docs() else None
|
|
|
|
app = FastAPI(
|
|
title=settings.SYSTEM_NAME if not is_install_mode() else "Nexus Installer",
|
|
description=f"{settings.SYSTEM_TITLE}" if not is_install_mode() else "Nexus Installation Wizard",
|
|
version="6.0.0",
|
|
lifespan=lifespan,
|
|
docs_url=_docs_url,
|
|
redoc_url=_redoc_url,
|
|
openapi_url=_openapi_url,
|
|
)
|
|
|
|
|
|
# ── Install mode middleware: block non-install routes when not configured ──
|
|
|
|
class InstallModeMiddleware:
|
|
"""Pure ASGI middleware: in install mode, only /api/install/, /app/, /health are accessible.
|
|
All other API routes return 503 Service Unavailable.
|
|
|
|
NOTE: Uses pure ASGI (not BaseHTTPMiddleware) to avoid MissingGreenlet errors
|
|
with SQLAlchemy async.
|
|
"""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
if is_install_mode():
|
|
path = scope.get("path", "")
|
|
# Redirect root to install wizard
|
|
if path == "/":
|
|
from fastapi.responses import RedirectResponse
|
|
response = RedirectResponse(url="/app/install.html")
|
|
await response(scope, receive, send)
|
|
return
|
|
# Allow: install API, static files, health check
|
|
if path.startswith("/api/install/") or path.startswith("/app/") or path == "/health":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
if path.startswith("/ws/"):
|
|
from fastapi.responses import JSONResponse
|
|
response = JSONResponse(
|
|
status_code=503,
|
|
content={"detail": "安装模式下 WebSocket 不可用,请先完成安装"},
|
|
)
|
|
await response(scope, receive, send)
|
|
return
|
|
# Block everything else
|
|
from fastapi.responses import JSONResponse
|
|
response = JSONResponse(
|
|
status_code=503,
|
|
content={"detail": "系统尚未配置,请先访问 /app/install.html 完成安装"},
|
|
)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
# Install mode middleware (must be first — before DB session middleware)
|
|
app.add_middleware(InstallModeMiddleware)
|
|
|
|
# Security headers middleware (pure ASGI — injects headers on every HTTP response)
|
|
class SecurityHeadersMiddleware:
|
|
"""Pure ASGI middleware: inject security-related response headers.
|
|
|
|
- X-Content-Type-Options: nosniff — prevents MIME-type sniffing
|
|
- X-Frame-Options: DENY — prevents clickjacking via iframes
|
|
- Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage
|
|
- Permissions-Policy: restricts browser features (camera, mic, geolocation)
|
|
|
|
NOTE: Uses pure ASGI (not BaseHTTPMiddleware) to avoid MissingGreenlet errors.
|
|
Intercepts ASGI response messages to inject headers before they reach the client.
|
|
"""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
# Wrap send to inject security headers into the response
|
|
async def send_with_headers(message):
|
|
if message["type"] == "http.response.start":
|
|
headers = list(message.get("headers", []))
|
|
headers.append((b"x-content-type-options", b"nosniff"))
|
|
headers.append((b"x-frame-options", b"DENY"))
|
|
headers.append((b"referrer-policy", b"strict-origin-when-cross-origin"))
|
|
headers.append((b"permissions-policy", b"camera=(), microphone=(), geolocation=()"))
|
|
message["headers"] = headers
|
|
await send(message)
|
|
|
|
await self.app(scope, receive, send_with_headers)
|
|
|
|
# ── Vuetify SPA uses hash history: /app/login → /app/#/login ──
|
|
|
|
_SPA_HASH_SEGMENTS = frozenset({
|
|
"login", "servers", "terminal", "files", "push", "scripts",
|
|
"credentials", "schedules", "retries", "commands", "alerts",
|
|
"audit", "settings",
|
|
})
|
|
|
|
|
|
class SpaHashRedirectMiddleware:
|
|
"""Redirect bare /app/<route> to /app/#/<route> (Vue hash router)."""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
path = scope.get("path", "")
|
|
if path.startswith("/app/") and path not in ("/app/", "/app/index.html"):
|
|
segment = path[len("/app/") :].strip("/")
|
|
if segment in _SPA_HASH_SEGMENTS:
|
|
qs = scope.get("query_string", b"").decode()
|
|
target = f"/app/#/{segment}"
|
|
if qs:
|
|
target = f"{target}?{qs}"
|
|
response = RedirectResponse(url=target, status_code=302)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
# ── AppAuth: protect /app/ pages behind refresh-cookie auth ──
|
|
|
|
# Public /app/ paths that anyone can access without login
|
|
_APP_PUBLIC_PATHS = frozenset({
|
|
"/app/install.html",
|
|
"/app/forgot-password.html",
|
|
"/app/", # Vuetify SPA entry (index.html)
|
|
"/app/index.html", # Vuetify SPA entry explicit
|
|
})
|
|
_APP_PUBLIC_PREFIXES = (
|
|
"/app/vendor/", # third-party JS/CSS (install.html dependencies)
|
|
"/app/wallpapers/", # Bing daily wallpaper cache (no auth needed)
|
|
)
|
|
|
|
|
|
def _extract_cookie(scope: dict, name: str) -> str | None:
|
|
"""Extract a cookie value from ASGI scope headers."""
|
|
for header_name, header_value in scope.get("headers", []):
|
|
if header_name == b"cookie":
|
|
val = header_value.decode("latin-1", errors="replace")
|
|
for part in val.split("; "):
|
|
if part.startswith(f"{name}="):
|
|
return part[len(name) + 1:]
|
|
return None
|
|
|
|
|
|
def _is_app_public_path(path: str) -> bool:
|
|
"""Check if the /app/ path is publicly accessible (no login required)."""
|
|
if path in _APP_PUBLIC_PATHS:
|
|
return True
|
|
for prefix in _APP_PUBLIC_PREFIXES:
|
|
if path.startswith(prefix):
|
|
return True
|
|
# Allow non-HTML static assets (CSS, JS, images, fonts)
|
|
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
if ext in ("css", "js", "svg", "png", "ico", "woff", "woff2", "ttf", "eot", "otf"):
|
|
return True
|
|
return False
|
|
|
|
|
|
class AppAuthMiddleware:
|
|
"""Pure ASGI middleware: verify nexus_refresh cookie on /app/ HTML pages.
|
|
|
|
Without a valid refresh cookie → blank HTML 404 (don't leak that the app exists).
|
|
Whitelisted paths (login, install, vendor, static assets) are always allowed.
|
|
|
|
NOTE: Uses pure ASGI (not BaseHTTPMiddleware) to avoid MissingGreenlet errors.
|
|
"""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
path = scope.get("path", "")
|
|
|
|
# Only protect /app/ HTML pages
|
|
if not path.startswith("/app/"):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
if is_install_mode():
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
if _is_app_public_path(path):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
# Check nexus_refresh cookie
|
|
token = _extract_cookie(scope, REFRESH_COOKIE_NAME)
|
|
if not token:
|
|
response = HTMLResponse(status_code=404)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
# Refresh token format: "token:admin_id:token_version" (opaque, not JWT)
|
|
# Validate format only — full auth happens on /api/ via JwtAuthMiddleware
|
|
parts = token.rsplit(":", 2)
|
|
if len(parts) != 3:
|
|
response = HTMLResponse(status_code=404)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
try:
|
|
admin_id = int(parts[1])
|
|
token_version = int(parts[2])
|
|
except (ValueError, TypeError):
|
|
response = HTMLResponse(status_code=404)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
if admin_id <= 0 or token_version < 0:
|
|
response = HTMLResponse(status_code=404)
|
|
await response(scope, receive, send)
|
|
return
|
|
|
|
# Token format is valid — user has a session; let them through
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
# AppAuth: protect /app/ HTML pages behind refresh-cookie (standalone, before JWT/DB/CORS)
|
|
app.add_middleware(AppAuthMiddleware)
|
|
|
|
# Hash-router SPA: must run before AppAuth (added later = outer = runs first on request)
|
|
app.add_middleware(SpaHashRedirectMiddleware)
|
|
|
|
# F2a-10 / F2d-02: global JWT gate for all /api/* (inside DbSession — uses request.state.db)
|
|
app.add_middleware(JwtAuthMiddleware)
|
|
|
|
# D7: DB session leak fix middleware (must be added BEFORE CORS so it wraps all requests)
|
|
app.add_middleware(DbSessionMiddleware)
|
|
|
|
# CORS — restrict to actual frontend origin (ECC security fix: was allow_origins=["*"])
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()] if settings.CORS_ORIGINS else ["http://localhost:8600"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Register all API routers
|
|
|
|
# D2: Install wizard (always registered — works in both modes)
|
|
app.include_router(install_router)
|
|
|
|
# Normal API routers (require full app init)
|
|
app.include_router(servers_router)
|
|
app.include_router(auth_router)
|
|
app.include_router(agent_router)
|
|
app.include_router(scripts_router)
|
|
app.include_router(execution_records_router)
|
|
app.include_router(settings_router)
|
|
app.include_router(schedule_router)
|
|
app.include_router(preset_router)
|
|
app.include_router(ssh_key_preset_router)
|
|
app.include_router(audit_router)
|
|
app.include_router(alert_history_router)
|
|
app.include_router(retry_router)
|
|
|
|
# WebSocket + Health check routers (new in v6.0)
|
|
app.include_router(websocket_router)
|
|
app.include_router(health_router)
|
|
|
|
# Asset management (Platform, Node, SSH Sessions, Command Logs)
|
|
app.include_router(assets_router)
|
|
|
|
# Web SSH Terminal
|
|
app.include_router(webssh_router)
|
|
|
|
# Sync Engine v2 (Step 4)
|
|
app.include_router(sync_v2_router)
|
|
|
|
# File browser REST (GET + ETag)
|
|
app.include_router(files_router)
|
|
|
|
# Global Search
|
|
app.include_router(search_router)
|
|
|
|
# Terminal Quick Commands
|
|
app.include_router(terminal_router)
|
|
|
|
|
|
# ── Custom 404: return blank HTML to hide backend identity ──
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|
"""Convert 404 JSON to blank HTML — don't leak backend (FastAPI) info."""
|
|
if exc.status_code == 404:
|
|
return HTMLResponse(status_code=404)
|
|
from fastapi.responses import JSONResponse
|
|
from server.utils.http_errors_zh import translate_http_detail
|
|
|
|
detail = exc.detail
|
|
if not request.url.path.startswith("/api/agent/"):
|
|
detail = translate_http_detail(detail)
|
|
return JSONResponse(status_code=exc.status_code, content={"detail": detail})
|
|
|
|
|
|
# ── Validation errors: agent paths at debug to reduce noise from legacy agents ──
|
|
from fastapi.exceptions import RequestValidationError
|
|
from server.utils.validation_errors_zh import translate_validation_errors
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
|
from fastapi.responses import JSONResponse
|
|
client_host = request.client.host if request.client else "unknown"
|
|
path = request.url.path
|
|
errors = exc.errors()
|
|
if path.startswith("/api/agent/"):
|
|
logger.debug(
|
|
"422 %s %s from %s errors=%s",
|
|
request.method, path, client_host, errors,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"422 %s %s from %s content_type=%s errors=%s",
|
|
request.method, path, client_host,
|
|
request.headers.get("content-type", ""), errors,
|
|
)
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={"detail": translate_validation_errors(errors)},
|
|
)
|
|
|
|
# ── Static files: serve web/app/ at /app/ (HTML pages + JS/CSS assets) ──
|
|
# In production, Nginx serves these directly; this is for dev/standalone mode.
|
|
WEB_APP_DIR = ROOT_DIR / "web" / "app"
|
|
if WEB_APP_DIR.is_dir():
|
|
app.mount("/app", StaticFiles(directory=str(WEB_APP_DIR), html=True), name="static_app")
|
|
|
|
# ── Agent static files: serve web/agent/ at /agent/ (install.sh, agent.py, etc.) ──
|
|
# Required by remote install: curl -fsSL {base_url}/agent/install.sh
|
|
WEB_AGENT_DIR = ROOT_DIR / "web" / "agent"
|
|
if WEB_AGENT_DIR.is_dir():
|
|
app.mount("/agent", StaticFiles(directory=str(WEB_AGENT_DIR)), name="static_agent")
|
|
|