- **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.
198 lines
12 KiB
Python
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()
|