Files
OPNexus/backend/app/main.py
T
Julian Boldt b1a96c7563
Release / verify (push) Waiting to run
Release / test (push) Blocked by required conditions
CI / backend (push) Blocked by required conditions
CI / frontend (push) Blocked by required conditions
CI / checks (push) Blocked by required conditions
Release v1.34.0
- **Zum Update:** Das erste Update auf diese Version übernimmt auch die `docker-compose.yml` (siehe unten; braucht wie die
  CLI-Aktualisierung `cosign` und `cosign.pub`), migriert die Datenbank (Revision 0032; ein Rollback spielt die Sicherung davor
  zurück) und stellt das Backend auf einen unprivilegierten Benutzer um. Nichts davon braucht einen Handgriff.
- **Nur ein Backend führt die Hintergrunddienste aus (HA, Stufe H0):** Laufen zwei Backends an derselben Datenbank, verschickten
  beide jeden Alarm und brachen Zyklen mit `UniqueViolation` ab. Jetzt führt nur der Prozess mit der Leader-Sperre in der
  Datenbank die neun Hintergrundschleifen aus; die anderen warten in Bereitschaft und übernehmen innerhalb einer halben Minute,
  wenn der Leader ausfällt. Verliert der Leader seine Datenbankverbindung, hält er alle Schleifen sofort an (geprüft alle 5 Sekunden
  und vor jedem Durchlauf). An einer Replica startet kein Leader. Rolle in `GET /api/health` und als Metrik `opnexus_leader`.
  Zwei gleichzeitige Erststarts legen das Admin-Konto nur einmal an.
- **Umzug und Kaltreserve (HA, Stufe H1):** `opnexus backup --full --with-env --with-ca` nimmt zusätzlich die `.env` und die
  interne CA des Proxys mit, `opnexus restore DATEI --with-env --with-ca` spielt sie zurück (ohne Datenbank-Zugang, Version und
  Adressen des Zielservers; die CA bleibt dieselbe, Browser vertrauen weiter). Neu: `opnexus migrate-host BENUTZER@ZIEL` zieht
  eine Installation in einem Schritt auf einen anderen Server um (Vorprüfung, Quelle anhalten, Sicherung mit Zufalls-Passphrase,
  Restore, Kontrolle). Im Lab 90 Sekunden mit drei Firewalls. `opnexus status` zeigt das Alter der letzten Vollsicherung.
  Handbuch: „Umzug und Notfall“.
- **Betriebseinstellungen im Webinterface:** Was bisher nur in der `.env` oder gar nur im Code stand, lässt sich unter
  Einstellungen › Betriebseinstellungen ändern: Sitzungsdauer und Anmeldesperren, Bestätigungsfrist beim Ausrollen, Zeitlimit für
  Firewall-Anfragen, Messintervalle und Aufbewahrung, Intervall der Versionsprüfung, Heartbeat-Adresse, CA des Mailservers (PEM
  einfügen statt Datei einbinden), Zeitlimit Mailversand. Mit Grenzen, Standardwert, Zurücksetzen und Audit-Eintrag. Ein Wert in der
  `.env` hat weiter Vorrang und wird als „vom Server“ angezeigt. Adressen, TLS und Datenbank-Zugang bleiben bewusst in der `.env`.
- **Heartbeat für einen externen Überwacher:** Die Heartbeat-Adresse (nur `https://`) wird nach jedem erfolgreichen Prüfzyklus
  aufgerufen; bleibt der Aufruf aus, schlägt der Überwacher Alarm. Die Adresse wird nie angezeigt oder protokolliert.
- **Updates bringen die Compose-Datei mit:** Bisher tauschte `opnexus update` nur die Images; Änderungen an der
  `docker-compose.yml` (neue Variablen, Härtung) kamen bei bestehenden Installationen nie an. Jetzt übernimmt das Update die Datei
  des Zielreleases aus dem signierten Updater-Image, sichert die alte als `docker-compose.yml.bak-<version>` und legt sie beim
  Rollback zurück. Eine hier angepasste Datei wird nicht überschrieben (neue Fassung als `docker-compose.yml.new`); eigene
  Anpassungen gehören in `docker-compose.override.yml`.
- **Alarm „Platte des OPNexus-Servers fast voll“** (standardmäßig an, ab 90 %, einstellbar): eine volle Platte hält die Datenbank
  an und damit jeden anderen Alarm. Auch im Dashboard und als Metrik `opnexus_server_disk_used_percent`.
- **Härtung aus einem externen Audit:**
  - **Kein root mehr in den Containern:** Backend (uid 10001) und Frontend (`node`) laufen unprivilegiert, ohne neue Rechte (`no-new-privileges`), das Frontend zusätzlich
    ohne Capabilities. Das Backend startet über `backend/scripts/start.sh` noch kurz als root, stellt die vorhandenen Datenvolumes einmalig um und gibt dann alle Rechte ab:
    ein Update braucht keinen Handgriff, ein Rückweg auf eine ältere Version bleibt möglich. Notausgang für Sonderfälle: `OPNEXUS_RUN_AS_ROOT=1` in der `.env`.
    `wg-hub` (braucht root für WireGuard) und `gui-gateway` (läuft schon als nobody) sind unverändert. Der Proxy (Caddy) und der optionale Updater (Docker-Socket) laufen weiter wie bisher.
  - **Vollsicherung konsistent:** Ändern sich Geheimnisse, während die Sicherung läuft (jemand legt gerade ein Objekt an oder löscht eines), wird sie bis zu dreimal wiederholt;
    gelingt das nicht, bricht sie mit einer klaren Meldung ab, statt ein unstimmiges Archiv zu schreiben.
  - **Zieladress-Schutz überall:** Audit-Export (Webhook und Syslog), Offsite-S3 und der Update-Feed verbinden nur noch zu Adressen, die die Prüfung bestanden haben
    (kein Loopback, Link-Local, Metadaten-Dienst; auch nicht über DNS-Rebinding oder Weiterleitungen). Adressen im eigenen Netz bleiben erlaubt. Der Update-Feed wird beim Lesen auf 1 MB begrenzt, nicht erst danach.
- **Fehlende Basis-Rechte werden sichtbar:** Fehlen dem API-Schlüssel schon die Rechte für die Grundmessungen (Speicher, Last, Platte), schlug das ganze Sampling still fehl und der
  Rechte-Alarm konnte nie auslösen. Jetzt entsteht ein Befund „Grundwerte“ (Recht „Lobby: Dashboard“), der sich nach Korrektur von selbst schließt.
- **Installation:** Die `INSTALL.txt` im Bundle und die Anleitung auf der Website legen die `.env` jetzt mit `install -m 600` an (nur für dich lesbar), statt mit `cp`.
- **Oberfläche:** Die Aktionsknöpfe der Backup-Tabelle auf der Host-Seite werden auf schmalen Bildschirmen als Symbole gezeigt, statt abgeschnitten zu werden.
2026-10-24 18:55:10 +02:00

593 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import logging
import re
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.openapi.utils import get_openapi
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import DBAPIError
from app import runtime_settings
from app.errors import ErrorCode
from app import models # noqa: F401 - registers models with Base.metadata
from app.auth_middleware import AuthMiddleware
from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddleware
from app.config import settings
from app.database import SessionLocal
from app import migrate
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router, audit_export as audit_export_router, backups as backups_router, oidc as oidc_router, governance as governance_router, overlay as overlay_router, variables as variables_router
from app.routers import one_to_one
from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments
from app.routers import outbound_nat
from app.services.outbound_nat_deploy_service import sweep_expired_outbound_nat_deployments
from app.services import auth_service, audit_service
from app.routers import audit, maintenance, settings as settings_router, notifications as notifications_router
from app.routers import system as system_router
from app.services.rule_deploy_service import sweep_expired_deployments
from app.services.alias_deploy_service import sweep_expired_alias_deployments
from app.services.port_forward_deploy_service import sweep_expired_port_forward_deployments
from app.services import notification_service, notification_settings_service
from app.routers import policy_hierarchy, policy_hierarchy_groups
from app.services.policy_hierarchy_deploy_service import sweep_expired_hierarchy_deployments
from app.services import log_ingest_service, log_settings_service
from app.routers import ipsec as ipsec_router
from app.routers import openvpn as openvpn_router
from app.routers import wireguard as wireguard_router
from app.routers import certificates as certificates_router
from app.routers import network as network_router
from app.routers import snapshots as snapshots_router
from app.routers import network_drift as network_drift_router
from app.routers import diagnostics as diagnostics_router
from app.routers import restore as restore_router
from app.version import APP_VERSION
from app.routers import dashboard as dashboard_router
from app.routers import cluster as cluster_router
from app.routers import services as services_router
from app.routers import gateways as gateways_router
from app.routers import resource_samples as resource_samples_router
from app.services import resource_monitor_service
from app.routers import scheduled_maintenance as scheduled_maintenance_router
from app.routers import maintenance_windows as maintenance_windows_router
from app.routers import rollouts as rollouts_router
from app.routers import rule_analysis as rule_analysis_router
from app.routers import rule_cleanup as rule_cleanup_router
from app.routers import config_code as config_code_router
from app.routers import gui_access as gui_access_router
from app.services import scheduled_maintenance_service
from app.services import rollout_engine
from app.services import compliance_service
from app.services import audit_export_service
from app.services import overlay_service, variables_service
from app.services import backup_schedule_service
from app.services import carp_event_service, carp_maintenance_window_service
from app.services import rule_history_service
from app.services import rule_cleanup_service
from app.services import heartbeat_service
from app import leader
# Without this, Python's root logger defaults to WARNING with no handler,
# so every logger.info() call in the 5 background loops below (CARP role
# changes, log-ingest counts, sweep results, ...) is silently dropped —
# `docker compose logs backend` then shows only uvicorn's own access log,
# even after days of the loops actually running. Only warning/exception
# calls survived before this (e.g. the one-time generated-admin-password
# banner), which made debugging "is X even running" needlessly hard.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
_SWEEP_INTERVAL_SECONDS = 10
# Prune retention-expired log rows once every N poll cycles rather than
# every tick — cleanup has no reason to run at poll cadence (default
# 30s), and a DELETE scanning by timestamp on every cycle would be wasted
# work once the table is non-trivially sized.
_LOG_PRUNE_EVERY_N_CYCLES = 20
# Same rationale as _LOG_PRUNE_EVERY_N_CYCLES, but the resource-sampling
# loop's own cadence (runtime_settings.value("resource_poll_interval_seconds"), default 60s).
_RESOURCE_PRUNE_EVERY_N_CYCLES = 20
async def _deploy_revert_sweep_loop():
"""Background safety net for the deploy lockout protection: reverts any
RuleDeployment whose confirm window passed without a human confirming
(see rule_deploy_service.sweep_expired_deployments)."""
while True:
await leader.checkpoint()
try:
async with SessionLocal() as db:
reverted = await sweep_expired_deployments(db)
if reverted:
logger.info("Auto-reverted %d unconfirmed rule deployment(s)", len(reverted))
except Exception:
logger.exception("Deploy-revert sweep failed")
try:
async with SessionLocal() as db:
reverted = await sweep_expired_alias_deployments(db)
if reverted:
logger.info("Auto-reverted %d unconfirmed alias deployment(s)", len(reverted))
except Exception:
logger.exception("Alias deploy-revert sweep failed")
try:
async with SessionLocal() as db:
reverted = await sweep_expired_port_forward_deployments(db)
if reverted:
logger.info("Auto-reverted %d unconfirmed port-forward deployment(s)", len(reverted))
except Exception:
logger.exception("Port-forward deploy-revert sweep failed")
try:
async with SessionLocal() as db:
reverted = await sweep_expired_outbound_nat_deployments(db)
if reverted:
logger.info("Auto-reverted %d unconfirmed outbound-nat deployment(s)", len(reverted))
except Exception:
logger.exception("Outbound NAT deploy-revert sweep failed")
try:
async with SessionLocal() as db:
reverted = await sweep_expired_one_to_one_deployments(db)
if reverted:
logger.info("Auto-reverted %d unconfirmed one-to-one deployment(s)", len(reverted))
except Exception:
logger.exception("1:1 NAT deploy-revert sweep failed")
try:
async with SessionLocal() as db:
reverted = await carp_maintenance_window_service.sweep_expired(db)
if reverted:
logger.info("CARP-Auto-Revert: %d Zeitfenster verarbeitet", len(reverted))
except Exception:
logger.exception("CARP-Auto-Revert-Sweep fehlgeschlagen")
try:
async with SessionLocal() as db:
reverted = await sweep_expired_hierarchy_deployments(db)
if reverted:
logger.info("Auto-reverted %d unconfirmed policy-hierarchy deployment(s)", len(reverted))
except Exception:
logger.exception("Policy-hierarchy deploy-revert sweep failed")
try:
async with SessionLocal() as db:
reverted = await rule_cleanup_service.sweep_expired_cleanups(db)
if reverted:
logger.info("Regel-Bereinigung: %d Operation(en) automatisch zurückgenommen/abgeschlossen", len(reverted))
except Exception:
logger.exception("Rule-cleanup revert sweep failed")
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
async def _notification_poll_loop():
"""Separate, slower loop from the deploy-revert sweep above: this one
makes live HTTP calls to every real firewall per cycle (host status,
certificates, live rules for drift), so it must not run at the sweep's
10s cadence — that cadence is a security control for the deploy-confirm
window, not applicable here. Interval is admin-configurable via
NotificationSettings.poll_interval_seconds, floored by
settings.notification_poll_min_interval_seconds."""
while True:
await leader.checkpoint()
interval = settings.notification_poll_min_interval_seconds
try:
async with SessionLocal() as db:
await notification_service.run_notification_checks(db)
row = await notification_settings_service.get_settings(db)
interval = max(row.poll_interval_seconds, settings.notification_poll_min_interval_seconds)
# Only after a cycle that went through: the external watcher must hear nothing when the checks fail.
await heartbeat_service.ping()
except Exception:
logger.exception("Notification poll failed")
try:
async with SessionLocal() as db:
events = await carp_event_service.detect_and_record(db)
for event in events:
logger.info(
"CARP-Rollenwechsel auf %s: %s -> %s", event.host_name, event.from_status, event.to_status,
)
except Exception:
logger.exception("CARP-Ereignis-Erkennung fehlgeschlagen")
await asyncio.sleep(interval)
async def _log_ingest_poll_loop():
"""Third background loop, mirroring _notification_poll_loop's shape:
a live GET call (diagnostics/firewall/log) to every enabled host per
cycle, so it runs on its own admin-configurable cadence rather than
the 10s deploy-revert sweep's. Interval comes from
LogSettings.poll_interval_seconds, floored by
settings.log_poll_min_interval_seconds. Retention pruning runs only
every _LOG_PRUNE_EVERY_N_CYCLES cycles (see constant above)."""
cycle = 0
while True:
await leader.checkpoint()
interval = settings.log_poll_min_interval_seconds
cycle += 1
try:
async with SessionLocal() as db:
row = await log_settings_service.get_settings(db)
interval = max(row.poll_interval_seconds, settings.log_poll_min_interval_seconds)
enabled = row.enabled
retention_days = row.retention_days
max_size_gb = row.max_size_gb
await db.commit()
if enabled:
result = await log_ingest_service.poll_all_hosts(db)
if result["inserted"]:
logger.info(
"Log-Ingest: %d neue Einträge über %d Host(s) (%d fehlgeschlagen)",
result["inserted"], result["hosts_polled"], result["hosts_failed"],
)
if cycle % _LOG_PRUNE_EVERY_N_CYCLES == 0:
deleted = await log_ingest_service.prune_old_entries(db, retention_days)
if deleted:
logger.info("Log-Retention: %d alte Einträge gelöscht", deleted)
trimmed = await log_ingest_service.enforce_size_limit(db, max_size_gb)
if trimmed:
logger.info("Log-Größenlimit (%.1f GB): %d älteste Einträge gelöscht", max_size_gb, trimmed)
except Exception:
logger.exception("Log-Ingest-Poll fehlgeschlagen")
await asyncio.sleep(interval)
async def _resource_poll_loop():
"""Fourth background loop, same shape as _log_ingest_poll_loop but with
a fixed interval (runtime_settings.value("resource_poll_interval_seconds")) — v1
deliberately has no admin-editable settings row for this, see
resource_monitor_service.py's module docstring."""
cycle = 0
while True:
await leader.checkpoint()
cycle += 1
try:
async with SessionLocal() as db:
result = await resource_monitor_service.poll_all_hosts(db)
if result["hosts_failed"]:
logger.warning(
"Resource-Sampling: %d von %d Host(s) fehlgeschlagen",
result["hosts_failed"], result["hosts_polled"],
)
if cycle % _RESOURCE_PRUNE_EVERY_N_CYCLES == 0:
deleted = await resource_monitor_service.prune_old_samples(db, runtime_settings.value("resource_sample_retention_days"))
if deleted:
logger.info("Resource-Sample-Retention: %d alte Einträge gelöscht", deleted)
pruned = await rule_history_service.prune(db)
if pruned:
logger.info("Regelverlauf-Retention: %d alte Einträge gelöscht", pruned)
except Exception:
logger.exception("Resource-Poll fehlgeschlagen")
await asyncio.sleep(runtime_settings.value("resource_poll_interval_seconds"))
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
_RUNNING_MAINTENANCE_POLL_SECONDS = 15
async def _scheduled_maintenance_poll_loop():
"""Fifth background loop: fires due one-shot ScheduledMaintenance rows.
A fixed 60s cadence is plenty — these are human-picked timestamps, not
a sub-minute-precision cron."""
while True:
await leader.checkpoint()
try:
async with SessionLocal() as db:
result = await scheduled_maintenance_service.fire_due(db)
if result["due"]:
logger.info(
"Geplante Wartung: %d fällig, %d ausgeführt, %d blockiert/fehlgeschlagen",
result["due"], result["fired"], result["blocked_or_failed"],
)
in_progress = await scheduled_maintenance_service.advance_running(db)
except Exception:
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
in_progress = 0
# Firmware rollouts (1.25) only ever create schedules; the next tick of the lines above fires them.
try:
async with SessionLocal() as db:
in_progress += await rollout_engine.advance_runs(db)
except Exception:
logger.exception("Rollout-Poll fehlgeschlagen")
# A handover run is stepped quickly (the partner should take over within a minute or two);
# otherwise the relaxed cadence is plenty.
await asyncio.sleep(_RUNNING_MAINTENANCE_POLL_SECONDS if in_progress else _SCHEDULED_MAINTENANCE_POLL_SECONDS)
_COMPLIANCE_FIRST_RUN_SECONDS = 120
_COMPLIANCE_INTERVAL_SECONDS = 6 * 3600
async def _compliance_poll_loop():
"""Sixth background loop: refreshes every enabled host's security-posture result.
Each check is one read-only config download per firewall, so every six hours
is plenty; the first run waits a little so a restart is not a thundering herd."""
await asyncio.sleep(_COMPLIANCE_FIRST_RUN_SECONDS)
while True:
await leader.checkpoint()
try:
async with SessionLocal() as db:
await compliance_service.check_all(db)
except Exception:
logger.exception("Sicherheitslage-Prüfung fehlgeschlagen")
await asyncio.sleep(_COMPLIANCE_INTERVAL_SECONDS)
_AUDIT_EXPORT_POLL_SECONDS = 10
async def _audit_export_poll_loop():
"""Seventh background loop: forwards new audit events (syslog / webhook) when configured.
Cheap when off (one settings read); keeps draining while batches come back full."""
while True:
await leader.checkpoint()
shipped = 0
try:
async with SessionLocal() as db:
shipped = await audit_export_service.run_once(db)
except Exception:
logger.exception("Audit-Export fehlgeschlagen")
await asyncio.sleep(0 if shipped >= audit_export_service.BATCH else _AUDIT_EXPORT_POLL_SECONDS)
_BACKUP_POLL_SECONDS = 15 * 60
async def _backup_poll_loop():
"""Eighth background loop: takes due scheduled config backups, prunes, copies offsite."""
await asyncio.sleep(90)
while True:
await leader.checkpoint()
try:
async with SessionLocal() as db:
result = await backup_schedule_service.run_due(db)
if result["taken"] or result["failed"] or result["uploaded"]:
logger.info("Geplante Backups: %s", result)
except Exception:
logger.exception("Geplante Backups fehlgeschlagen")
await asyncio.sleep(_BACKUP_POLL_SECONDS)
_RULE_OBSERVE_FIRST_RUN_SECONDS = 150
async def _rule_observe_loop():
"""Ninth background loop: reads the rules and their counters of every enabled firewall (two GETs each),
keeps the usage sampling current and records what changed since the last look (services/rule_history_service.py).
Writes only to the database. The first look at a firewall only stores the starting point."""
await asyncio.sleep(_RULE_OBSERVE_FIRST_RUN_SECONDS)
while True:
await leader.checkpoint()
try:
async with SessionLocal() as db:
result = await rule_history_service.observe_all(db)
if result["events"] or result["failed"]:
logger.info("Regelverlauf: %s", result)
except Exception:
logger.exception("Regelverlauf fehlgeschlagen")
await asyncio.sleep(max(runtime_settings.value("rule_observe_interval_seconds"), settings.rule_observe_min_interval_seconds))
@asynccontextmanager
async def lifespan(app: FastAPI):
conflicts = settings.gui_origin_conflicts()
if conflicts:
# A GUI-gateway slot must never be a trusted OPNexus origin (CORS with credentials).
raise RuntimeError(f"OPNEXUS_GUI_SITES overlaps FRONTEND_ORIGIN: {', '.join(conflicts)} — refusing to start")
await migrate.run()
await runtime_settings.refresh() # values from Settings › Betrieb; refreshed in every process (leader or not)
async with SessionLocal() as db:
await auth_service.bootstrap_admin(db)
try:
await overlay_service.sync(db) # hub config + proxy rule follow the database after every start
except Exception:
logger.exception("Overlay-Konfiguration konnte nicht geschrieben werden")
if any(o.startswith("https://") for o in settings.allowed_origins) and not settings.cookie_secure:
logger.warning(
"FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie "
"would also be sent over plain HTTP. Set COOKIE_SECURE=true."
)
if settings.allow_insecure_firewall_urls:
logger.warning("ALLOW_INSECURE_FIREWALL_URLS is on: firewall API credentials may travel over plain HTTP.")
# Only the process holding the leader lock runs these (app/leader.py); a second backend on the same database waits.
leader.gate = leader.LeaderGate({
"revert-sweep": _deploy_revert_sweep_loop,
"notifications": _notification_poll_loop,
"log-ingest": _log_ingest_poll_loop,
"resources": _resource_poll_loop,
"scheduled-maintenance": _scheduled_maintenance_poll_loop,
"compliance": _compliance_poll_loop,
"audit-export": _audit_export_poll_loop,
"backups": _backup_poll_loop,
"rule-observe": _rule_observe_loop,
})
gate_task = asyncio.create_task(leader.gate.run(), name="leader-gate")
settings_task = asyncio.create_task(runtime_settings.refresh_loop(), name="runtime-settings")
try:
yield
finally:
settings_task.cancel()
gate_task.cancel()
try:
await gate_task
except asyncio.CancelledError:
pass
await leader.gate.stop()
API_DESCRIPTION = (
"REST API of OPNexus, the central management for OPNsense firewall fleets.\n\n"
"**Authentication:** send a personal API token as `Authorization: Bearer opx_...` "
"(create one under *System > API tokens*). Scopes: `read` (GET only), `deploy` "
"(+ roll out rules, NAT, VPN, maintenance), `admin` (+ users, settings, hosts). "
"A token never exceeds its owner's role. Token and account management is session-only.\n\n"
"Errors are `{\"detail\": {\"code\": \"...\", \"params\": {...}}}` with a stable `code`."
)
# The interactive Swagger/ReDoc pages load scripts from a CDN, which the proxy's
# Content-Security-Policy forbids; the schema itself is served (behind auth) for
# any client that renders it (Postman, openapi-generator, an AI assistant).
app = FastAPI(
title="OPNexus", version=APP_VERSION, lifespan=lifespan, description=API_DESCRIPTION,
docs_url=None, redoc_url=None, openapi_url="/api/openapi.json",
)
def _custom_openapi():
if app.openapi_schema is None:
schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes)
schema.setdefault("components", {}).setdefault("securitySchemes", {})["bearerAuth"] = {
"type": "http", "scheme": "bearer", "description": "Personal API token (opx_...)",
}
schema["security"] = [{"bearerAuth": []}]
app.openapi_schema = schema
return app.openapi_schema
app.openapi = _custom_openapi
@app.exception_handler(RequestValidationError)
async def _safe_validation_error(request: Request, exc: RequestValidationError):
"""FastAPI's default 422 body echoes the offending `input` (and validator
`ctx`) back — for password/key/PSK fields that puts the secret into
responses that browsers, proxies and error telemetry may record. Only
where the problem is and what it is are returned."""
return JSONResponse(
status_code=422,
content={"detail": [{"loc": list(err.get("loc", ())), "msg": err.get("msg", ""), "type": err.get("type", "")} for err in exc.errors()]},
)
@app.exception_handler(variables_service.MissingVariables)
async def _missing_variables(request: Request, exc: variables_service.MissingVariables):
"""A deploy that needs a value no target host has defined is refused before anything is written."""
return JSONResponse(status_code=422, content={"detail": {"code": ErrorCode.VARIABLES_MISSING.value, "params": {"details": str(exc)}}})
@app.exception_handler(DBAPIError)
async def _database_text_errors(request: Request, exc: DBAPIError):
"""A NUL byte in any text field cannot be stored by Postgres and used to
surface as a 500 plus a dropped connection (found by fuzzing). That is bad
input, not a server fault. Anything else stays a normal 500."""
if type(exc.orig).__name__.endswith("CharacterNotInRepertoireError") or "0x00" in str(exc.orig):
return JSONResponse(
status_code=422,
content={"detail": {"code": ErrorCode.INVALID_CHARACTERS.value, "params": {}}},
)
raise exc
app.add_middleware(AuthMiddleware)
# Inside CORS so preflights are answered first; outside Auth so a forged
# cross-site write is rejected before any session lookup happens.
app.add_middleware(OriginCheckMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
# Added last so it wraps outermost (Starlette runs the last-added
# middleware first) — CORS must see and answer OPTIONS preflight requests
# before AuthMiddleware ever gets a chance to 401 them.
# An explicit FRONTEND_ORIGIN (required for any non-localhost deployment,
# see config.py) is matched exactly, never as a regex fragment — avoids
# turning operator-supplied input into an accidental regex hole.
_cors_origin_regex = (
"^(" + "|".join(re.escape(o) for o in settings.allowed_origins) + ")$"
if settings.allowed_origins else settings.cors_origin_regex
)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=_cors_origin_regex,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# Browsers hide all but a few standard response headers cross-origin
# by default — the backup-download endpoint's filename (carried in
# Content-Disposition) needs this to reach the frontend's fetch().
expose_headers=["Content-Disposition"],
)
app.include_router(auth.router)
app.include_router(hosts.router, prefix="/api")
app.include_router(rules.router, prefix="/api")
app.include_router(search_router.router, prefix="/api")
app.include_router(users_router.router, prefix="/api")
app.include_router(tokens_router.router, prefix="/api")
app.include_router(metrics_router.router, prefix="/api")
app.include_router(mcp_router.router, prefix="/api")
app.include_router(compliance_router.router, prefix="/api")
app.include_router(audit_export_router.router, prefix="/api")
app.include_router(backups_router.router, prefix="/api")
app.include_router(oidc_router.router, prefix="/api")
app.include_router(governance_router.router, prefix="/api")
app.include_router(overlay_router.router, prefix="/api")
app.include_router(variables_router.router, prefix="/api")
app.include_router(logs.router, prefix="/api")
app.include_router(groups.router, prefix="/api")
app.include_router(aliases.router, prefix="/api")
app.include_router(port_forward.router, prefix="/api")
@app.get("/api/health")
async def health():
# role: leader (runs the background loops), standby/takeover (another process does, or about to start),
# replica (database in recovery), starting. The HTTP status stays 200 in every role: the API works in all of them.
return {"status": "ok", "role": leader.current_role()}
app.include_router(one_to_one.router, prefix="/api")
app.include_router(outbound_nat.router, prefix="/api")
app.include_router(audit.router, prefix="/api")
app.include_router(maintenance.router, prefix="/api")
app.include_router(settings_router.router, prefix="/api")
app.include_router(system_router.router, prefix="/api")
app.include_router(notifications_router.router, prefix="/api")
app.include_router(policy_hierarchy_groups.router, prefix="/api")
app.include_router(policy_hierarchy.router, prefix="/api")
# No background sweep loop for this router — see
# ipsec_deploy_service.py's module docstring: there is no pending/
# auto-revert state here to sweep.
app.include_router(ipsec_router.router, prefix="/api")
# Same as above — no sweep loop, see openvpn_deploy_service.py's module
# docstring (identical no-pending/auto-revert rationale as IPsec).
app.include_router(openvpn_router.router, prefix="/api")
# Same as above — no sweep loop, see wireguard_deploy_service.py's module
# docstring (identical no-pending/auto-revert rationale as IPsec/OpenVPN).
app.include_router(wireguard_router.router, prefix="/api")
# Trust-Store + ACME certificate management — single-host entities (no
# multi-host targeting, see the models' own docstrings), no sweep loop
# either: Trust-Store has no restart/apply step to auto-revert at all, and
# ACME's register/sign are one-shot external-network triggers, not
# config-write-then-reconfigure flows with a "config live, reload
# unconfirmed" middle state to sweep.
app.include_router(certificates_router.managed_router, prefix="/api")
app.include_router(certificates_router.acme_accounts_router, prefix="/api")
app.include_router(certificates_router.acme_certificates_router, prefix="/api")
app.include_router(restore_router.router, prefix="/api")
app.include_router(dashboard_router.router, prefix="/api")
app.include_router(cluster_router.router, prefix="/api")
app.include_router(services_router.router, prefix="/api")
app.include_router(gateways_router.router, prefix="/api")
app.include_router(network_router.router, prefix="/api")
app.include_router(snapshots_router.router, prefix="/api")
app.include_router(network_drift_router.router, prefix="/api")
app.include_router(diagnostics_router.router, prefix="/api")
app.include_router(resource_samples_router.router, prefix="/api")
app.include_router(scheduled_maintenance_router.router, prefix="/api")
app.include_router(maintenance_windows_router.router, prefix="/api")
app.include_router(rollouts_router.router, prefix="/api")
app.include_router(rule_analysis_router.router, prefix="/api")
app.include_router(rule_cleanup_router.router, prefix="/api")
app.include_router(config_code_router.router, prefix="/api")
app.include_router(gui_access_router.router, prefix="/api")
app.include_router(gui_access_router.internal_router)