Files
OPNexus/backend/app/config.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

198 lines
12 KiB
Python

from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
@property
def allowed_origins(self) -> list[str]:
return [o.strip().rstrip("/") for o in self.frontend_origin.split(",") if o.strip()]
@property
def gui_site_origins(self) -> list[str]:
return [o.strip().rstrip("/") for o in self.opnexus_gui_sites.split(",") if o.strip()]
def gui_origin_conflicts(self) -> list[str]:
"""Slot origins of the GUI gateway that are also listed in FRONTEND_ORIGIN.
Such an origin would get CORS with credentials and pass the Sec-Fetch-Site
exception — a firewall GUI could then read OPNexus' API. Startup is refused."""
allowed = {o.lower() for o in self.allowed_origins}
return [o for o in self.gui_site_origins if o.lower() in allowed]
database_url: str = (
"postgresql+asyncpg://opnexus:opnexus@postgres:5432/opnexus"
)
# ── Schema migrations (app/migrate.py) ────────────────────────────
# Vor jeder Migration an einer bestehenden Datenbank wird per pg_dump
# ein Sicherheits-Backup abgelegt (dasselbe Volume wie der
# postgres-backup-Service). Schlägt es fehl, wird NICHT migriert.
migration_backup_dir: str = "/backups"
# Nur für Entwicklung/Notfall: Backup überspringen.
skip_migration_backup: bool = False
migration_backup_keep: int = 5
# ── Update check (app/services/update_check_service.py) ───────────
# Release-Feed im Format von GitHub/Forgejo `releases/latest`, z. B.
# https://api.github.com/repos/<owner>/<repo>/releases/latest
# https://git.example.com/api/v1/repos/<owner>/<repo>/releases/latest
# Leer = die Funktion ist aus: OPNexus macht dann KEINE ausgehende
# Anfrage. Gesendet wird nur ein GET mit User-Agent "OPNexus/<Version>",
# keine Instanz- oder Nutzerdaten.
update_check_url: str = ""
update_check_interval_hours: int = 12
# Registry der Release-Images (OPNEXUS_IMAGE_REPO). Leer = OPNexus wird lokal aus dem
# Quellcode gebaut; dann gibt es keine Updates per Klick (nur der Hinweis in den Einstellungen).
opnexus_image_repo: str = ""
# ── Heartbeat (app/services/heartbeat_service.py) ──────────────────
# Optionale Adresse eines externen Überwachers (healthchecks-Stil, ntfy, Uptime Kuma Push …). OPNexus ruft sie
# nach jedem erfolgreichen Prüfzyklus der Benachrichtigungen per GET auf: bleibt der Aufruf aus, ist OPNexus
# ausgefallen oder kommt nicht mehr an seine Datenbank. Nur https://, die Adresse wird nie protokolliert.
# Leer = aus.
heartbeat_url: str = ""
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
# nie Zugriff auf den Docker-Socket; es reicht nur "aktualisieren" /
# "zurückrollen" an diesen Dienst weiter, authentifiziert mit dem Token,
# den der Updater in sein Zustands-Volume schreibt.
updater_url: str = "http://updater:8080"
updater_token_file: str = "/updater/token"
# Cookies now carry the session, so this can no longer be a bare "*"
# (the Fetch spec forbids credentialed requests against a wildcard
# response) — see main.py's allow_origin_regex. Default only matches
# localhost/127.0.0.1 on the compose-default frontend port, i.e. local
# dev out of the box. A real deployment reachable under any other
# hostname MUST set FRONTEND_ORIGIN instead (see below) — a bare
# port-only regex would accept a credentialed cross-site request from
# *any* host serving on that port.
cors_origin_regex: str = r"^https?://(localhost|127\.0\.0\.1):3100$"
# Exact origin (scheme://host[:port], no trailing slash) of the
# deployed frontend, e.g. "https://opnexus.example.com". When set,
# this replaces cors_origin_regex entirely with an exact match — set
# it for any non-localhost deployment.
# Several origins may be given comma-separated, e.g. when OPNexus is
# reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan".
frontend_origin: str = ""
opnsense_secret_dir: str = "/data/opnsense-secrets"
# Management overlay (WireGuard hub in the optional `wg-hub` container, see services/overlay_service.py).
overlay_dir: str = "/data/overlay"
overlay_proxy_url: str = "http://wg-hub:8888"
# Lab only: allow an http:// endpoint for offsite backups (credentials in the clear).
allow_insecure_offsite: bool = False
opnsense_backup_dir: str = "/data/opnsense-backups"
opnsense_timeout: float = 10.0
# Firewall API credentials are sent as HTTP Basic auth. Plain http://
# addresses are therefore refused (on create AND on every request) unless
# this is switched on — only meant for isolated lab setups.
allow_insecure_firewall_urls: bool = False
# ── Auth ──────────────────────────────────────────────────────────
admin_username: str = "admin"
# Empty → bootstrap_admin() generates one and logs it once.
admin_password: str = ""
session_lifetime: int = 60 * 60 * 24 * 30 # 30 days, seconds
# No TLS termination in front of this tool by default (see README) —
# a Secure cookie would just never be sent. Set to true once HTTPS is
# in place.
cookie_secure: bool = False
login_max_attempts: int = 5
login_ip_max_attempts: int = 20
login_window_seconds: int = 300
login_lockout_seconds: int = 300
# Off by default: request.client.host is the actual TCP peer, safe to
# trust with no proxy in front. Behind a reverse proxy (see README's
# "Produktivbetrieb"), that peer is the proxy itself, collapsing every
# real client onto one IP for the login-throttle bucket — set this to
# true ONLY when the proxy is trusted to always set/overwrite
# X-Forwarded-For itself (every proxy setup in the README's example
# does). Never enable this if the app is reachable directly (no
# proxy) — any client could then spoof the header to dodge the
# per-IP lockout entirely.
trust_proxy_headers: bool = False
# ── GUI gateway (1.27, optional container "gui-gateway") ─────────
# Slot origins the gateway is served on, comma-separated, e.g.
# "https://192.0.2.10:9441,https://192.0.2.10:9442". Empty = feature unavailable.
opnexus_gui_sites: str = ""
# Shared secret between backend and gateway for /internal/gui/* (at least
# 32 characters). Empty = the internal endpoints answer 404.
gui_gateway_token: str = ""
# ── Deploy lockout protection ────────────────────────────────────
deploy_confirm_window_seconds: int = 180
# ── Notifications ─────────────────────────────────────────────────
# Infra-only knobs (deployment concerns); delivery config itself
# (webhook URL, SMTP settings, per-event toggles, poll interval) is
# admin-editable via NotificationSettings — see app/models/notification.py.
notification_secret_dir: str = "/data/notification-secrets"
totp_secret_dir: str = "/data/totp-secrets"
# Floor against an admin setting an absurdly low poll interval, which
# would hammer both real firewalls with live status/cert/rule-drift
# calls every cycle.
notification_poll_min_interval_seconds: int = 60
notification_smtp_timeout_seconds: float = 10.0
# SMTP over SSL/STARTTLS always verifies the server certificate and host
# name against the container's trust store. A mail server with a private
# CA needs that CA's PEM file mounted and referenced here.
smtp_ca_file: str = ""
# Sending SMTP credentials over an unencrypted connection (security "none")
# is refused unless this is switched on explicitly.
allow_insecure_smtp_auth: bool = False
# ── Log ingest ────────────────────────────────────────────────────
# Floor against an admin setting an absurdly low poll interval — same
# rationale as notification_poll_min_interval_seconds, but for the
# digest-based firewall-log ingest loop (see log_ingest_service.py).
# A single GET per enabled host per cycle, so a lower floor than
# notifications' is acceptable — this is the same call the log page
# already makes on-demand today, just automated.
log_poll_min_interval_seconds: int = 10
# ── IPsec VPN management ────────────────────────────────────────────
# Pre-shared-key secrets, one file per tunnel id — same convention as
# opnsense_secret_dir above, see ipsec_psk_store.py.
ipsec_psk_secret_dir: str = "/data/ipsec-psk-secrets"
# ── OpenVPN instance management ─────────────────────────────────────
# Static-key (tls-auth/tls-crypt hardening) key material, one file per
# instance id — same convention as ipsec_psk_secret_dir above, see
# openvpn_key_store.py.
openvpn_key_secret_dir: str = "/data/openvpn-key-secrets"
# ── WireGuard tunnel management ─────────────────────────────────────
# Server private key + optional per-peer pre-shared key, both stored
# one-file-per-tunnel-id under this directory — same convention as
# ipsec_psk_secret_dir/openvpn_key_secret_dir above, see
# wireguard_key_store.py.
wireguard_key_secret_dir: str = "/data/wireguard-key-secrets"
# ── Rule observation (usage counters + change history of the live rules) ─
# Two read-only GETs per enabled firewall per cycle (searchRule, ruleStats). The interval is an operator
# setting (env RULE_OBSERVE_INTERVAL_SECONDS); it can not go below the floor, which keeps a typo from
# polling every firewall in a tight loop.
rule_observe_interval_seconds: int = 900
rule_observe_min_interval_seconds: int = 60
# ── Resource monitoring ──────────────────────────────────────────────
# Fixed interval, no admin-editable settings row/UI for this — v1
# deliberately kept minimal (see resource_monitor_service.py).
resource_poll_interval_seconds: int = 60
resource_sample_retention_days: int = 30
# ── Trust-Store + ACME certificate management ────────────────────────
# Private-key material for action="import" ManagedCertificate rows,
# one file per entity id — same convention as ipsec_psk_secret_dir
# above, see cert_key_store.py.
cert_key_secret_dir: str = "/data/cert-key-secrets"
# External-Account-Binding HMAC for AcmeAccount rows, one file per
# entity id — see acme_secret_store.py.
acme_secret_dir: str = "/data/acme-secrets"
# env_ignore_empty: Compose passes unset variables as "" ("${X:-}"); they must mean "not set", so the value from
# the web interface (app/runtime_settings.py) applies and a number field does not fail to parse.
model_config = SettingsConfigDict(case_sensitive=False, env_ignore_empty=True)
settings = Settings()