Files
OPNexus/backend/app/main.py
T
Julian Boldt e26b26eca6
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 v1.10.1-rc2
Siehe CHANGELOG.md
2026-10-07 09:34:26 +02:00

358 lines
16 KiB
Python

import asyncio
import logging
import re
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
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
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 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 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.services import scheduled_maintenance_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
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"],
)
except Exception:
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
await asyncio.sleep(_SCHEDULED_MAINTENANCE_POLL_SECONDS)
@asynccontextmanager
async def lifespan(app: FastAPI):
await migrate.run()
async with SessionLocal() as db:
await auth_service.bootstrap_admin(db)
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())
try:
yield
finally:
sweep_task.cancel()
notification_task.cancel()
log_ingest_task.cancel()
resource_task.cancel()
scheduled_maintenance_task.cancel()
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task):
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="OPNexus", version=APP_VERSION, lifespan=lifespan)
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(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(resource_samples_router.router, prefix="/api")
app.include_router(scheduled_maintenance_router.router, prefix="/api")