Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Regel-Analyse: Bereinigung (nur Ansicht):** Auf **Regelwerk › Regel-Analyse** gibt es den Tab **Bereinigung** (`?tab=cleanup`). Er listet je Firewall die
Regeln, die einen Blick wert sind: **ungenutzt** (OPNexus hat in 7/30/90/180 Tagen keinen Treffer gesehen), **verdeckt** (eine frühere Quick-Regel
trifft schon alles, was sie trifft), **doppelt** und **lange deaktiviert**. Jeder Befund trägt eine Sicherheit (**sicher / vermutlich / unbekannt**, Tooltip mit
dem Grund): „sicher“ gibt es nur mit vollständigen, frischen Belegen, bei einer Regel mit Zeitplan, ohne geladenen pf-Regelsatz oder bei Aliasen bleibt es
höchstens „vermutlich“. Die Tabelle zeigt auch den letzten Treffer und ob die Regel entfernbar wäre (nicht: automatische Regeln, Regeln im Altformat, von OPNexus
verwaltete Regeln – die entfernt man über die Regelseite). Es wird nur gelesen; **Entfernen folgt in einer späteren Version**. Die Sicherheitslage verlinkt ihre
Befunde „ungenutzt/verdeckt/doppelt“ direkt dorthin; per API `GET /api/hosts/{id}/rule-analysis/cleanup?unused_days=30`, per MCP `rule_cleanup_candidates`.
- **Änderungsverlauf je Regel:** OPNexus liest alle 15 Minuten die Regeln jeder Firewall und merkt sich, was sich geändert hat – auch bei Regeln, die nicht von OPNexus
stammen: angelegt, geändert (mit Vorher/Nachher), aktiviert, deaktiviert, entfernt, jeweils mit der Herkunft „über OPNexus“ (passender Audit-Eintrag) oder „extern“.
Auf der Live-Regel-Ansicht (Regelwerk › Live über alle Hosts) öffnet die Zeilenaktion **Verlauf** die Ereignisse zusammen mit den gespeicherten Fassungen und dem Audit der
verwalteten Regel. Die erste Beobachtung speichert nur den Ausgangszustand; der Verlauf wird 365 Tage aufbewahrt (`GET /api/hosts/{id}/live-rules/{uuid}/history`).
Eine Umsortierung (andere Reihenfolge oder Gruppe) gilt als „verschoben“, nicht als Änderung. Eine leere oder unvollständige Antwort der Firewall (ohne
automatische Regeln) wird verworfen; verschwinden mehr als die Hälfte der Regeln auf einmal, zählt erst die zweite gleiche Beobachtung. Die Herkunft „über OPNexus“
erklärt nur Audit-Einträge seit der vorherigen Beobachtung, und jeder Eintrag nur eine Änderung.
Schemaänderungen 0027 und 0028 (Tabellen und ein Feld für den Beobachtungszeitraum) laufen beim Start.
- **Regelnutzung genauer:** Die Zähler kommen jetzt aus `ruleStats` (von der Firewall schon je Regel summiert; ältere Versionen weiterhin über den pf-Regelsatz) und werden alle
15 Minuten statt alle 6 Stunden gelesen. Regeln im Altformat (noch nicht migriert) werden ebenfalls erfasst. Das Beobachtungsfenster für „ungenutzt“ beginnt neu, wenn eine Regel eingeschaltet oder geändert wird, und nach einem pf-Neuladen mit langer Lücke bei den Zählern.
- **Verdeckt/doppelt in der Sicherheitslage genauer:** Der Vergleich kennt jetzt die echte Reihenfolge (Floating vor Interface-Gruppen vor Interface-Regeln), Netze und
Portbereiche als Mengen (z. B. 10.1.0.0/16 liegt in 10.0.0.0/8), `inet46` und `tcp/udp`, und übergeht Regeln mit Zeitplan, Markierung oder Limits. Es findet dadurch
mehr verdeckte Regeln als bisher, nie weniger belegte.
566 lines
27 KiB
Python
566 lines
27 KiB
Python
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.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 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
|
|
|
|
# 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 (settings.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:
|
|
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")
|
|
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:
|
|
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)
|
|
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:
|
|
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 (settings.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:
|
|
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, settings.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(settings.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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(settings.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()
|
|
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.")
|
|
|
|
sweep_task = asyncio.create_task(_deploy_revert_sweep_loop())
|
|
notification_task = asyncio.create_task(_notification_poll_loop())
|
|
log_ingest_task = asyncio.create_task(_log_ingest_poll_loop())
|
|
resource_task = asyncio.create_task(_resource_poll_loop())
|
|
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
|
|
compliance_task = asyncio.create_task(_compliance_poll_loop())
|
|
audit_export_task = asyncio.create_task(_audit_export_poll_loop())
|
|
backup_task = asyncio.create_task(_backup_poll_loop())
|
|
rule_observe_task = asyncio.create_task(_rule_observe_loop())
|
|
try:
|
|
yield
|
|
finally:
|
|
sweep_task.cancel()
|
|
notification_task.cancel()
|
|
log_ingest_task.cancel()
|
|
resource_task.cancel()
|
|
scheduled_maintenance_task.cancel()
|
|
compliance_task.cancel()
|
|
audit_export_task.cancel()
|
|
backup_task.cancel()
|
|
rule_observe_task.cancel()
|
|
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task, audit_export_task, backup_task, rule_observe_task):
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
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():
|
|
return {"status": "ok"}
|
|
|
|
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(gui_access_router.router, prefix="/api")
|
|
app.include_router(gui_access_router.internal_router)
|