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
Vorgesehen als MINOR-Release **1.27.0** „Firewall-Oberfläche über OPNexus“. - **Firewall-Oberfläche über OPNexus:** Auf der Host-Seite öffnet **Oberfläche öffnen** die Weboberfläche der Firewall in einem neuen Tab, auch bei Firewalls hinter NAT im Management-Tunnel. Nur für Admins; jede Firewall läuft in einer eigenen Browser-Origin (Platz = Port), Cookies bleiben auf dem Server, das Ticket gilt einmalig 30 Sekunden, Inaktivitäts- und Gesamtlimit sind einstellbar, Ticket, Start und Ende stehen im Audit (Art „GUI-Zugriff“). Standardmäßig **aus**: braucht das Compose-Profil `gui`, `OPNEXUS_GUI_SITES`, `GUI_PORTS`, `GUI_GATEWAY_TOKEN`, `COOKIE_SECURE=true` und den Schalter unter Einstellungen (Handbuch, „Firewall-Oberfläche über OPNexus“). Live getestet auf OPNsense 26.7. - **Einschränkungen:** kein WebSocket, keine automatische Anmeldung, höchstens 4 Plätze gleichzeitig. OPNexus-Adresse vorher in die Whitelist des Anmeldeschutzes (sshguard) der Firewall eintragen, sonst können Fehlanmeldungen die API-Zugriffe von OPNexus aussperren. Nach einem Neustart des wg-hub braucht der Tunnel 2 bis 4 Minuten. - **Hinweis für Betreiber mit eigenen Skripten:** Bei HTTPS heißt das Sitzungs-Cookie jetzt `__Host-opnexus-session` (bestehende Sitzungen werden ohne Abmelden übernommen), und Cookie-Anfragen an `/api` mit `Sec-Fetch-Site: same-site/cross-site` werden abgelehnt. Zugriffe mit API-Token sind nicht betroffen.
541 lines
26 KiB
Python
541 lines
26 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
|
|
|
|
# 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)
|
|
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)
|
|
|
|
|
|
@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())
|
|
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()
|
|
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task, audit_export_task, backup_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)
|