Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983e05a816 | ||
|
|
dd6a64d953 | ||
|
|
6964ec6537 | ||
|
|
5e5d51b57f | ||
|
|
1aba618ad5 | ||
|
|
ba954c05bf | ||
|
|
63d014b14c | ||
|
|
4838dad588 | ||
|
|
c4c8c4ff3a | ||
|
|
295555cfd6 | ||
|
|
f5dbe8a41a | ||
|
|
552c88e425 | ||
|
|
b2c69f51a0 | ||
|
|
218a1f14ac | ||
|
|
79783d6d62 | ||
|
|
c15417869b | ||
|
|
cf326d2d17 | ||
|
|
8fccb1a547 | ||
|
|
cf59bdb38e | ||
|
|
c662a58426 | ||
|
|
3b76f9f5fa | ||
|
|
b341ccd49f | ||
|
|
20fd575420 | ||
|
|
1fc2d2c87c |
No files matched your search
+21
-6
@@ -36,7 +36,7 @@ PROXY_BIND_ADDRESS=0.0.0.0
|
||||
|
||||
# Host interface the backend/frontend ports are published on. Defaults
|
||||
# to 127.0.0.1 (this host only) if unset. Set to 0.0.0.0 for access from
|
||||
# other machines without a reverse proxy (see README's "Produktivbetrieb"
|
||||
# other machines without a reverse proxy (see the handbook, "Zugriff aus dem Netz und TLS"
|
||||
# for the safer TLS-proxy alternative), or to one specific interface IP.
|
||||
BIND_ADDRESS=
|
||||
# Host-Ports von Backend und Frontend (nur nötig, wenn 8100/3100 schon belegt
|
||||
@@ -44,15 +44,15 @@ BIND_ADDRESS=
|
||||
# BACKEND_PORT=8100
|
||||
# FRONTEND_PORT=3100
|
||||
|
||||
# Set to true once this is running behind TLS (see README's
|
||||
# "Produktivbetrieb" section) — otherwise the session cookie is sent
|
||||
# Set to true once this is running behind TLS (see the handbook,
|
||||
# "Zugriff aus dem Netz und TLS") — otherwise the session cookie is sent
|
||||
# unencrypted and browsers won't even attach a Secure cookie without
|
||||
# HTTPS in the first place.
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Set to true ONLY when running behind a reverse proxy you trust to
|
||||
# always set/overwrite X-Forwarded-For itself (every example in README's
|
||||
# "Produktivbetrieb" does). Used to attribute failed logins to the real
|
||||
# always set/overwrite X-Forwarded-For itself (every example in the handbook's
|
||||
# "Zugriff aus dem Netz und TLS" does). Used to attribute failed logins to the real
|
||||
# client IP for the login lockout instead of the proxy's own IP. Leave
|
||||
# false (default) if the app is reachable directly with no proxy in
|
||||
# front — otherwise any client could spoof this header to dodge the
|
||||
@@ -71,10 +71,20 @@ TRUST_PROXY_HEADERS=false
|
||||
# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht
|
||||
# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit
|
||||
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
||||
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update`.
|
||||
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
|
||||
# (optional, siehe unten) per Klick im Webinterface.
|
||||
# Einfacher: im Webinterface unter Einstellungen › OPNexus aktualisieren einschalten.
|
||||
# Ein Wert hier hat Vorrang und sperrt den Schalter dort.
|
||||
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||
# UPDATE_CHECK_INTERVAL_HOURS=12
|
||||
|
||||
# ── Updates aus dem Webinterface (optional, braucht Release-Images) ───
|
||||
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
||||
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
|
||||
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
|
||||
# AUS. Braucht außerdem die Versionsprüfung (Webinterface oder UPDATE_CHECK_URL, oben). Siehe Handbuch, "Updates per Klick".
|
||||
# COMPOSE_PROFILES=updater
|
||||
|
||||
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
||||
# Firewall-API-Zugangsdaten gehen per HTTP-Basic-Auth raus, deshalb sind
|
||||
# http://-Adressen für Firewalls abgelehnt (beim Anlegen UND bei jeder
|
||||
@@ -85,3 +95,8 @@ TRUST_PROXY_HEADERS=false
|
||||
# SMTP_CA_FILE=/data/smtp-ca.pem
|
||||
# SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt.
|
||||
# ALLOW_INSECURE_SMTP_AUTH=false
|
||||
|
||||
# Management-Tunnel (WireGuard-Hub für Firewalls hinter NAT), optional.
|
||||
# Zum Aktivieren (Handbuch „Firewalls hinter NAT“): COMPOSE_PROFILES=overlay
|
||||
# und den UDP-Port aus dem Internet erreichbar machen.
|
||||
#OVERLAY_PORT=51820
|
||||
@@ -59,3 +59,5 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 scripts/check_i18n.py
|
||||
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
|
||||
# Updater-Container (Zustandsübergänge, Ablehnungen, Auth) ohne Docker-Daemon.
|
||||
- run: python3 -m unittest discover -s updater -p 'test_*.py'
|
||||
@@ -12,7 +12,7 @@
|
||||
# Berechtigung "write:package" eintragen)
|
||||
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
|
||||
# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration.
|
||||
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy}:<version>
|
||||
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy,updater}:<version>
|
||||
# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen).
|
||||
#
|
||||
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
service: [backend, frontend, proxy]
|
||||
service: [backend, frontend, proxy, updater]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
+359
@@ -31,6 +31,365 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.23.8] "Harrier" — 2026-10-20
|
||||
|
||||
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
|
||||
die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
|
||||
`docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
|
||||
|
||||
## [1.23.7] "Harrier" — 2026-10-20
|
||||
|
||||
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
|
||||
|
||||
## [1.23.6] "Harrier" — 2026-10-20
|
||||
|
||||
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
|
||||
und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
|
||||
`UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
|
||||
|
||||
## [1.23.5] "Harrier" — 2026-10-20
|
||||
|
||||
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
|
||||
und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
|
||||
bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
|
||||
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
|
||||
darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
|
||||
|
||||
## [1.23.4] "Harrier" — 2026-10-20
|
||||
|
||||
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
|
||||
im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
|
||||
anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
|
||||
Prüfung weicht ab“.
|
||||
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
|
||||
einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
|
||||
|
||||
## [1.23.3] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
|
||||
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
|
||||
Garage, MinIO, RustFS und Versity S3 Gateway.
|
||||
|
||||
## [1.23.2] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
|
||||
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
|
||||
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
|
||||
|
||||
## [1.23.1] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
|
||||
- Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
|
||||
- Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
|
||||
- SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
|
||||
- Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
|
||||
- Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
|
||||
- Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
|
||||
- Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
|
||||
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
|
||||
|
||||
## [1.23.0] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
|
||||
Befunden (je Firewall und Prüfung, mit „behoben“-Meldung; standardmäßig aus; Migration `0017`); druckbarer **Sicherheitsbericht**
|
||||
(Knopf „Bericht“, Drucken/PDF über den Browser).
|
||||
|
||||
## [1.22.0] "Peregrine" — 2026-10-20
|
||||
|
||||
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
|
||||
(Host-Seite › Standort-Variablen, listet die verwendeten Platzhalter automatisch). Je Host aufgelöst in Vorschau, Ausrollen, Zählern und
|
||||
Audit; fehlt ein Wert, wird nichts gesendet (Vorschau „Variable fehlt“, Ausrollen wird vor jedem Schreiben abgelehnt, zusätzlich eine
|
||||
Sperre im API-Client). Neue Tabelle `host_variables` (Migration `0016`).
|
||||
|
||||
## [1.21.0] "Osprey" — 2026-10-20
|
||||
|
||||
- **Management-Tunnel** (System › Management-Tunnel): Firewalls hinter NAT/CGNAT verwalten. Ein optionaler WireGuard-Hub auf dem
|
||||
OPNexus-Server (Compose-Profil `overlay`, Container `wg-hub` aus dem Backend-Image mit `NET_ADMIN`) nimmt die Firewalls entgegen, die sich
|
||||
nach außen einwählen; OPNexus erreicht deren API über einen kleinen CONNECT-Proxy des Hubs, der nur ins Tunnel-Netz verbindet. Je Firewall
|
||||
eine Tunnel-Adresse und ein eigener Pre-Shared Key, Anleitung mit kopierbaren Werten, Live-Status (Handshake, Datenmenge). Der Hub nimmt
|
||||
keine neuen Verbindungen aus dem Tunnel an. Ende-zu-Ende gegen eine echte OPNsense 26.7 geprüft (Handshake, API, Zertifikat festsetzen).
|
||||
Neue Tabellen `overlay_settings`, `overlay_peers` (Migration `0015`); das Backend-Image enthält jetzt `wireguard-tools`, `iproute2`, `iptables`.
|
||||
|
||||
## [1.20.0] "Lilli" — 2026-10-20
|
||||
|
||||
- **Single Sign-on (OIDC)**: Anmeldung über Authentik, Keycloak, Entra ID, Google u. a. (Authorization Code mit PKCE). Die Rolle
|
||||
kommt bei jeder Anmeldung aus den Gruppen des Anbieters, ohne zugeordnete Gruppe kommt niemand hinein; SSO-Konten werden nie mit
|
||||
lokalen Konten verknüpft; lokale Anmeldung bleibt als Notzugang. ID-Token werden streng geprüft (Signatur nur asymmetrisch, Aussteller,
|
||||
Zielgruppe, Ablauf, Nonce, einmaliger State), gegen einen nachgebauten Anbieter getestet (inkl. Fälschungsversuche). Neue Tabellen
|
||||
`oidc_settings`, `oidc_login_states`, Spalten `admin_users.auth_source/oidc_subject` (Migration `0013`). Neue Abhängigkeit: PyJWT.
|
||||
- **Behoben:** `opnexus backup --full` und `opnexus restore` (Vollsicherung, in 1.19.0 beschrieben) fehlten im 1.19.0-Bundle und -Updater-Image,
|
||||
weil die Änderung am Skript nicht mit eingecheckt war; sie sind erst ab dieser Version enthalten.
|
||||
- **Aufgabentrennung**: Schalter, mit dem nur Admins ausrollen dürfen, Operatoren aber vorbereiten (Migration `0014`).
|
||||
|
||||
## [1.19.0] "Lanner" — 2026-10-20
|
||||
|
||||
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende
|
||||
(sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“.
|
||||
|
||||
- **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine
|
||||
Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit.
|
||||
Tokens eines Operators gehen bis zum Bereich „Ausrollen“.
|
||||
- **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens
|
||||
einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`).
|
||||
- **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM,
|
||||
Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen
|
||||
zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte
|
||||
`notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen
|
||||
AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus.
|
||||
- **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer
|
||||
verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen).
|
||||
- **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und
|
||||
Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle
|
||||
`rule_hits` (Migration `0012`).
|
||||
- Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
|
||||
|
||||
## [1.18.0] "Kestrel" — 2026-10-19
|
||||
|
||||
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
|
||||
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.
|
||||
|
||||
- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
|
||||
Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
|
||||
Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
|
||||
Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
|
||||
Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
|
||||
`api_tokens` (Migration `0008`).
|
||||
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
|
||||
offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
|
||||
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
|
||||
Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
|
||||
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
|
||||
erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
|
||||
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
|
||||
0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
|
||||
Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
|
||||
auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
|
||||
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
|
||||
Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
|
||||
Anfrage neu geladen.
|
||||
|
||||
## [1.17.0] "Imperial" — 2026-10-19
|
||||
|
||||
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
|
||||
unbeaufsichtigt aktualisieren. Auf einem Test-Cluster live geprüft (Neustart und ein echtes Update
|
||||
mit Umschaltung), siehe Handbuch „Geplante Wartung“.
|
||||
|
||||
- **Rollenprüfung beim Einplanen:** Ein Host, der in einem Cluster gerade CARP-MASTER ist,
|
||||
lässt sich nicht mehr einplanen (Neustart/Update/Major-Upgrade). Bisher wurde der Plan
|
||||
angenommen und scheiterte erst zur Uhrzeit. Der Planungsdialog zeigt den Grund an und
|
||||
sperrt den Button. Ist die Rolle nicht feststellbar (Host gerade nicht erreichbar), wird
|
||||
weiter angenommen; die volle Prüfung läuft wie bisher zum Zündzeitpunkt.
|
||||
- **Benachrichtigung „Geplante Wartung nicht ausgeführt“:** Eine fehlgeschlagene geplante
|
||||
Wartung meldet sich per Webhook/E-Mail (einzeln schaltbar in den Einstellungen, standardmäßig
|
||||
an). Offen für drei Tage nach dem geplanten Zeitpunkt, dann ohne „behoben“-Meldung geschlossen.
|
||||
Neue Spalte `notification_settings.scheduled_maintenance_failed_enabled` (Migration `0006`).
|
||||
- **Automatische CARP-Umschaltung (Option pro Plan, standardmäßig aus):** Ein geplanter Neustart,
|
||||
Update oder Major-Upgrade auf dem MASTER schaltet vorher auf den Partner um, prüft die
|
||||
Übernahme, führt die Aktion aus, wartet auf die Rückkehr des Hosts und schaltet zurück. Fehler
|
||||
vor dem Start der Aktion stellen den Normalbetrieb wieder her; danach bleibt der Host bewusst im
|
||||
Wartungsmodus (Partner trägt den Verkehr) und es kommt eine Benachrichtigung. Der Ablauf steht in
|
||||
der Datenbank und überlebt einen Neustart des Backends. Neue Spalten an `scheduled_maintenance`
|
||||
(Migration `0007`).
|
||||
- **Backup vor geplantem Update:** Ein geplantes Update oder Major-Upgrade zieht unmittelbar vor
|
||||
dem Start selbst ein Konfigurationsbackup (nur lesend auf der Firewall). Bisher scheiterte jeder
|
||||
Plan, der mehr als 24 Stunden in der Zukunft lag, an der Backup-Aktualität. Schlägt das Backup
|
||||
fehl, wird nicht aktualisiert.
|
||||
|
||||
## [1.16.0] "Hobby" — 2026-10-10
|
||||
|
||||
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
|
||||
Handbuch „Einrichtungsassistent und Passwort-Reset“:
|
||||
|
||||
- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
|
||||
Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
|
||||
verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
|
||||
Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
|
||||
Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
|
||||
überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
|
||||
oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
|
||||
(Migration `0005`).
|
||||
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
|
||||
(`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
|
||||
Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
|
||||
vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
|
||||
`python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
|
||||
|
||||
## [1.15.0] "Gyrfalcon" — 2026-10-10
|
||||
|
||||
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
|
||||
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
|
||||
„Update aus dem Webinterface (optional)“:
|
||||
|
||||
- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
|
||||
neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
|
||||
automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
|
||||
(mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
|
||||
**Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
|
||||
in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
|
||||
**aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
|
||||
hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
|
||||
ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
|
||||
„zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
|
||||
Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
|
||||
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
|
||||
Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
|
||||
zusätzlich `opnexus-updater`.
|
||||
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
|
||||
|
||||
## [1.14.0] "Eagle" — 2026-10-09
|
||||
|
||||
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
|
||||
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:
|
||||
|
||||
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
|
||||
ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
|
||||
Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
|
||||
Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
|
||||
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
|
||||
Engine wie die übrigen Geräte.
|
||||
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
|
||||
falsch, weil `/hostsystem` mit `/hosts` beginnt.
|
||||
|
||||
## [1.13.0] "Buzzard" — 2026-10-09
|
||||
|
||||
Mehr Verwaltung pro Firewall und ein Abgleich der Cluster-Knoten. Alles über
|
||||
dieselbe abgesicherte Engine wie in 1.12.0 und live auf drei Test-Firewalls
|
||||
geprüft, siehe Handbuch „Erweiterung 1.13.0“:
|
||||
|
||||
- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Dazu Gateway-Gruppen,
|
||||
DNS-Weiterleitungen (Unbound) und DHCP-Bereiche (dnsmasq).
|
||||
- **Neue Gruppe „Host-System“:** Tunables (immer mit Bestätigung, Vorgaben nur per
|
||||
„Überschreiben“), Cron, Syslog-Ziele und ZFS-Snapshots (anlegen und löschen; der
|
||||
aktive Snapshot ist geschützt, das Aktivieren bleibt OPNsense selbst überlassen).
|
||||
- **Netzwerk › Abgleich:** zeigt Unterschiede in der Netzwerk-Konfiguration zwischen den
|
||||
Knoten eines Clusters. Neuer Alarm „Netzwerk-Abweichung im Cluster“ (Einstellungen ›
|
||||
Benachrichtigungen, Migration 0004).
|
||||
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts lässt es sich mit
|
||||
einem Klick auch auf dem anderen Cluster-Knoten anlegen (eigene Vorschau, eigenes Backup).
|
||||
- **Kopfzeile:** Mit acht Gruppen zeigt die Navigation die Namen ab 1150 px, darunter
|
||||
nur Icons, damit Suche und Abmelden in einer Zeile bleiben.
|
||||
|
||||
## [1.12.0] "Condor" — 2026-10-09
|
||||
|
||||
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
|
||||
OPNsense-Core-Quellen geprüft und live auf drei Test-Firewalls (26.7.1 und
|
||||
26.7.6) getestet, siehe Handbuch „Netzwerk, DNS und DHCP“:
|
||||
|
||||
- **Neuer Bereich „Netzwerk“:** Inventar je Firewall (Schnittstellen mit
|
||||
Adressen und CARP-Zustand, VLANs, virtuelle IPs, Gateways, Routen, DNS-Einträge,
|
||||
DHCP) und Änderungen daran: anlegen, bearbeiten, löschen für VLANs, virtuelle
|
||||
IPs, Gateways, statische Routen, Schnittstellen-Zuweisung samt IP-Konfiguration,
|
||||
Unbound-DNS-Einträge, dnsmasq-Hosts sowie Kea-Subnetze und -Reservierungen.
|
||||
- **Abgesichert:** Jede Änderung läuft über eine Vorschau mit Diff und einem Token,
|
||||
der den Firewall-Stand erfasst. Vor dem Schreiben wird ein Konfig-Backup
|
||||
gespeichert (ohne Backup keine Änderung), danach angewendet und zurückgelesen.
|
||||
OPNexus erkennt, über welche Schnittstelle es die Firewall erreicht; Änderungen,
|
||||
die diese Verbindung kappen können (Verwaltungs-Schnittstelle, Standard-Gateway,
|
||||
Standardroute, Route über die Firewall-Adresse), brauchen eine ausdrückliche
|
||||
Bestätigung mit Eingabe des Firewall-Namens. Es gibt bewusst **keinen**
|
||||
automatischen Rollback: OPNexus kann eine Änderung nicht zurücknehmen, wenn sie
|
||||
seine eigene Verbindung trennt. Das Backup vor der Änderung ist der Rückweg.
|
||||
- **Schnittstellen-Löschen:** OPNsense entfernt dabei selbst alle Regeln, die die
|
||||
Schnittstelle nutzen. Die Vorschau listet sie, die Bestätigung ist Pflicht.
|
||||
Bereits auf der Firewall vorgemerkte Änderungen blockieren eine neue Änderung.
|
||||
- **IP-Konfiguration per API gibt es erst ab OPNsense 26.7.6.** Auf 26.7.1 ist die
|
||||
Zuweisungsliste nur lesbar; OPNexus erkennt das an den Daten und sperrt die
|
||||
Änderung mit Begründung.
|
||||
- **Von der Firewall übernehmen:** Regeln, die direkt in OPNsense angelegt wurden,
|
||||
lassen sich in OPNexus übernehmen, bearbeiten und ausrollen. Die Übernahme schreibt
|
||||
nichts auf die Firewall. Regeln, die das Modell verfälschen würde (invertierte
|
||||
Quelle/Ziel, mehrere Schnittstellen, keine Quick-Regel, …), werden mit Grund
|
||||
abgelehnt. Neu: Regeln haben ein Protokollieren-Flag (`log`, Migration 0003),
|
||||
damit es beim Bearbeiten nicht still verloren geht; nicht modellierte Felder
|
||||
(Gateway, Zustandsoptionen, …) bleiben auf der Firewall unverändert.
|
||||
- **Geheimnisse:** Das CARP-Passwort (liefert OPNsense im Klartext) und der
|
||||
Kea-DDNS-Schlüssel werden nie zurückgegeben, gespeichert oder auditiert.
|
||||
- **Host-Adresse änderbar:** Hosts › Bearbeiten hat jetzt das Feld „Adresse“. Nach
|
||||
einer Änderung der Verwaltungs-IP muss der Host nicht mehr gelöscht und neu
|
||||
angelegt werden; Verlauf und Zuordnungen bleiben erhalten. Meldet eine Änderung
|
||||
„angewendet, aber nicht prüfbar“, weist die Oberfläche darauf hin.
|
||||
- **Fehler aus den Live-Tests, die Mocks nicht gezeigt haben:** Jede Änderung einer
|
||||
virtuellen IP muss die Adresse (`network`) wiederholen, sonst leert OPNsense sie
|
||||
bzw. lehnt ab; die Prüfung nach dem Anwenden vergleicht jetzt den vollständigen
|
||||
Soll-Zustand statt nur der gesendeten Felder. Dazu: Gateway-Priorität als Zahl,
|
||||
Routen mit `enabled` statt `disabled`, Beschreibungsmuster der Schnittstellen,
|
||||
gesperrte Schnittstellen, Pflicht-Backup vor jeder Änderung.
|
||||
- **Hinweis zu den Versionen:** Die IP-Konfiguration von Schnittstellen per API gibt
|
||||
es erst ab OPNsense 26.7.6. Mit 26.7.5 (die Prod-Firewalls) sind VLANs, virtuelle
|
||||
IPs, Gateways, Routen, DNS und DHCP verwaltbar, Schnittstellen nur lesbar.
|
||||
|
||||
## [1.11.1] "Merlin" — 2026-10-09
|
||||
|
||||
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
|
||||
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
|
||||
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
|
||||
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
|
||||
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
|
||||
Benachrichtigungen und der HA-Config-Sync:
|
||||
|
||||
- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
|
||||
endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
|
||||
`/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
|
||||
Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
|
||||
gegen dieses Muster.
|
||||
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
|
||||
es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
|
||||
`cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
|
||||
Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
|
||||
`/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
|
||||
Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
|
||||
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
|
||||
(VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
|
||||
kein Interface. Der Deploy schaltet den Dienst jetzt ein.
|
||||
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
|
||||
er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
|
||||
unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
|
||||
`SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
|
||||
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
|
||||
Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
|
||||
(„instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
|
||||
Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
|
||||
Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
|
||||
(„Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
|
||||
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
|
||||
einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
|
||||
und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
|
||||
Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
|
||||
sie nicht beurteilbar sind.
|
||||
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
|
||||
Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
|
||||
„Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
|
||||
zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
|
||||
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
|
||||
noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
|
||||
der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
|
||||
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
|
||||
abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
|
||||
Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
|
||||
ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
|
||||
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
|
||||
die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
|
||||
Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
|
||||
auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
|
||||
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
|
||||
Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
|
||||
Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
|
||||
Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
|
||||
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
|
||||
WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
|
||||
allen Seiten, hell und dunkel, nichts mehr.
|
||||
|
||||
- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
|
||||
blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
|
||||
bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
|
||||
Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
|
||||
jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
|
||||
(Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
|
||||
sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
|
||||
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
|
||||
Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
|
||||
das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
|
||||
|
||||
## [1.11.0] "Merlin" — 2026-10-07
|
||||
|
||||
Sicherheitsüberprüfung vom 2026-10-07, umgesetzte Korrekturen.
|
||||
|
||||
+24
-8
@@ -5,9 +5,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> **Stand:** `1.11.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
> **Stand:** `1.23.8` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -16,13 +16,27 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
- **Sichere Änderungen** — jede Änderung läuft *Prüfen → Ausrollen → Bestätigen*; bleibt die Bestätigung im Zeitfenster aus, rollt OPNexus zurück.
|
||||
- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume.
|
||||
- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt.
|
||||
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog, Snapshots und Diagnose (Ping, Traceroute, Port-Prüfung, DNS), ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln lassen sich übernehmen.
|
||||
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
|
||||
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
|
||||
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
|
||||
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
|
||||
- **Automatisierung** — persönliche API-Tokens (Lesen / Ausrollen / Admin), Prometheus-Metriken, ein nur lesender MCP-Endpunkt für KI-Assistenten und ein OpenAPI-Schema.
|
||||
- **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
|
||||
- **Backups & Wiederherstellung** — geplante Konfigurationssicherungen mit Aufbewahrung, verschlüsselte Offsite-Kopie auf S3-kompatiblen Speicher, Änderungsansicht zwischen Sicherungen und eine vollständige verschlüsselte Sicherung von OPNexus selbst (`opnexus backup --full`).
|
||||
- **Single Sign-on** — Anmeldung über deinen Identitätsanbieter (OIDC); die Rolle kommt bei jeder Anmeldung aus dessen Gruppen.
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Operator / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||
|
||||
Alle Screenshots zeigen fiktive Beispieldaten.
|
||||
|
||||
## Ratgeber
|
||||
|
||||
- [OPNsense-Firewalls zentral verwalten](https://opnexus.dev/opnsense-zentral-verwalten/) – wann sich zentrale Verwaltung lohnt und wie sie funktioniert
|
||||
- [OPNsense-HA-Cluster mit CARP verwalten](https://opnexus.dev/opnsense-ha-cluster-carp/) – Rollen, Alarme, geordnete Updates, Abgleich beider Knoten
|
||||
- [OPNsense-Regeln sicher ausrollen](https://opnexus.dev/opnsense-regeln-sicher-ausrollen/) – Vorschau, Bestätigungsfenster und automatischer Rollback
|
||||
- [Das Kommandozeilen-Werkzeug `opnexus`](https://opnexus.dev/docs/cli/) – Installation aktualisieren, zurückrollen und sichern
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Voraussetzung: Docker mit Compose-Plugin.
|
||||
@@ -34,19 +48,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben:
|
||||
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben. Verloren oder nie gesehen? Dieses Kommando setzt ein neues Zufallspasswort und zeigt es an (im Repo-Checkout `scripts/opnexus`, im Release-Bundle `./opnexus`):
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep -A5 "generated password"
|
||||
scripts/opnexus password
|
||||
```
|
||||
|
||||
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls).
|
||||
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
|
||||
|
||||
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls).
|
||||
|
||||
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.11.0
|
||||
OPNEXUS_VERSION=1.23.8
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -57,7 +73,7 @@ Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen,
|
||||
|
||||
## Datenschutz
|
||||
|
||||
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) ist **aus**, solange du ihn nicht setzt; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
||||
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (Schalter unter Einstellungen › OPNexus aktualisieren, oder `UPDATE_CHECK_URL` in der `.env`) ist **aus**, solange du ihn nicht einschaltest; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> **Status:** `1.11.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
> **Status:** `1.23.8` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -16,15 +16,29 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
- **Safe changes** — every change goes *preview → deploy → confirm*; without your confirmation inside the time window OPNexus rolls it back.
|
||||
- **Hierarchical policies** — pre/post rulebases with inheritance across group trees.
|
||||
- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out.
|
||||
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog, snapshots and diagnostics (ping, traceroute, port check, DNS), a comparison of cluster nodes, with a diff preview, a backup before every change and a guard against cutting your own connection; rules created by hand on a firewall can be adopted.
|
||||
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
|
||||
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
|
||||
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
|
||||
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
|
||||
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
|
||||
- **Automation** — personal API tokens (read / deploy / admin), Prometheus metrics, a read-only MCP endpoint for AI assistants and an OpenAPI schema.
|
||||
- **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
|
||||
- **Backups & recovery** — scheduled config backups with retention, an encrypted offsite copy to S3-compatible storage, a redacted change view between backups, and a full encrypted backup/restore of OPNexus itself (`opnexus backup --full`).
|
||||
- **Single sign-on** — sign in with your identity provider (OIDC); roles come from its groups at every login.
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / operator / read-only), keyboard operation, dark mode, German and English UI.
|
||||
|
||||

|
||||

|
||||
|
||||
All screenshots show fictitious sample data.
|
||||
|
||||
## Guides
|
||||
|
||||
- [Manage OPNsense firewalls centrally](https://opnexus.dev/en/manage-opnsense-centrally/) – when central management pays off and how it works
|
||||
- [Manage OPNsense HA clusters with CARP](https://opnexus.dev/en/opnsense-ha-cluster-carp/) – roles, alerts, ordered updates, comparing both nodes
|
||||
- [Roll out OPNsense rules safely](https://opnexus.dev/en/roll-out-opnsense-rules-safely/) – preview, confirmation window and automatic rollback
|
||||
- [The `opnexus` command-line tool](https://opnexus.dev/en/docs/cli/) – update, roll back and back up an installation
|
||||
|
||||
## Quick start
|
||||
|
||||
Requires Docker with the Compose plugin.
|
||||
@@ -36,19 +50,21 @@ cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log:
|
||||
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log. Lost it, or never saw it? This sets a new random one and prints it (use `scripts/opnexus` in a repo checkout, `./opnexus` in the release bundle):
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep -A5 "generated password"
|
||||
scripts/opnexus password
|
||||
```
|
||||
|
||||
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German).
|
||||
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
|
||||
|
||||
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls) (German).
|
||||
|
||||
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.11.0
|
||||
OPNEXUS_VERSION=1.23.8
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -57,9 +73,11 @@ docker compose pull && docker compose up -d --no-build
|
||||
|
||||
`scripts/opnexus update` / `rollback` then update the installation (pull first, database backup before migrations, automatic rollback if the services do not become healthy).
|
||||
|
||||
**Updating from the web interface** is available as an opt-in: set `COMPOSE_PROFILES=updater` in `.env` and an administrator can update (and roll back) under *Settings › Update OPNexus*. It runs as a separate small container that is the only one with Docker access — see the [handbook](docs/HANDBUCH.md#update-aus-dem-webinterface-optional) (German) for the security model.
|
||||
|
||||
## Privacy
|
||||
|
||||
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
||||
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (switch under Settings › Update OPNexus, or `UPDATE_CHECK_URL` in the `.env`) is **off** unless you turn it on; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ WORKDIR /app
|
||||
COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh
|
||||
RUN /tmp/install_pg_client.sh 16 && rm /tmp/install_pg_client.sh
|
||||
|
||||
# Management-Overlay (nur der wg-hub-Container nutzt das, siehe app/overlay_hub.py).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wireguard-tools iproute2 iptables \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -8,13 +8,19 @@ from starlette.responses import JSONResponse
|
||||
from app.database import SessionLocal
|
||||
from app.errors import ErrorCode
|
||||
from app.routers.auth import COOKIE_NAME
|
||||
from app.services import auth_service
|
||||
from app.routers import governance
|
||||
from app.routers.auth import client_ip
|
||||
from app.services import api_token_service, auth_service
|
||||
|
||||
_PUBLIC_PATHS = {
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/me",
|
||||
"/api/health",
|
||||
# SSO: the browser arrives here without a session; every step is checked inside.
|
||||
"/api/auth/oidc/config",
|
||||
"/api/auth/oidc/start",
|
||||
"/api/auth/oidc/callback",
|
||||
}
|
||||
|
||||
# /docs, /redoc and /openapi.json used to be public — fully documenting a
|
||||
@@ -35,6 +41,8 @@ _PUBLIC_PATHS = {
|
||||
_READ_ONLY_ALLOWED_WRITES = {
|
||||
"/api/auth/change-password",
|
||||
"/api/settings",
|
||||
# Read-only JSON-RPC tools (see routers/mcp.py).
|
||||
"/api/mcp",
|
||||
# Self-scoped: a user managing their own second factor.
|
||||
"/api/auth/2fa/setup",
|
||||
"/api/auth/2fa/enable",
|
||||
@@ -48,6 +56,7 @@ _READ_ONLY_ALLOWED_WRITES = {
|
||||
_SECURITY_KINDS = {
|
||||
"ipsec": "ipsec_tunnel", "openvpn": "openvpn_instance", "wireguard": "wireguard_tunnel",
|
||||
"certificates": "certificate", "acme": "acme", "notifications": "notification", "auth": "auth",
|
||||
"tokens": "api_token",
|
||||
}
|
||||
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings"}
|
||||
|
||||
@@ -86,19 +95,37 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
if request.url.path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
api_token = None
|
||||
bearer = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
if token:
|
||||
if bearer is not None:
|
||||
# An API token wins over a cookie; a bad one never falls back to it.
|
||||
async with SessionLocal() as db:
|
||||
resolved = await api_token_service.resolve(db, bearer, client_ip(request))
|
||||
user, api_token = resolved if resolved is not None else (None, None)
|
||||
token = bearer
|
||||
elif token:
|
||||
async with SessionLocal() as db:
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if token:
|
||||
if user is not None and api_token is not None and api_token_service.denies(
|
||||
api_token.scope, request.method, request.url.path
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": {"code": ErrorCode.API_TOKEN_SCOPE_FORBIDDEN.value, "params": {"scope": api_token.scope}}},
|
||||
)
|
||||
if user is not None:
|
||||
parts = request.url.path.strip("/").split("/")
|
||||
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user"}
|
||||
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system", "backups": "backup"}
|
||||
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
||||
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
|
||||
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
|
||||
kind = event["kind"]
|
||||
else:
|
||||
kind = kinds.get(parts[1]) if len(parts) > 1 else None
|
||||
if len(parts) > 2 and parts[1] == "settings":
|
||||
kind = "settings" # where OPNexus stores/sends things (own prefs at /api/settings stay unaudited)
|
||||
action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create")
|
||||
event = {"kind": kind, "action": action, "entity_id": parts[2] if len(parts) > 2 else None,
|
||||
"host_id": parts[2] if kind == "host" and len(parts) > 2 else None,
|
||||
@@ -109,10 +136,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
user.role == "read_only"
|
||||
and request.method not in ("GET", "HEAD", "OPTIONS")
|
||||
and request.url.path not in _READ_ONLY_ALLOWED_WRITES
|
||||
# Own API tokens (the router limits a read_only user to read scope).
|
||||
and not request.url.path.startswith("/api/tokens")
|
||||
):
|
||||
if tracked:
|
||||
# Refused attempts at security-relevant writes stay attributable.
|
||||
ctx = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
try:
|
||||
await audit_service.persist(**event, status="denied", message="Schreibzugriff für Nur-Lese-Konto verweigert")
|
||||
finally:
|
||||
@@ -121,8 +150,28 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
status_code=403,
|
||||
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
|
||||
)
|
||||
if user.role == "operator" and api_token_service.operator_denies(request.method, request.url.path):
|
||||
if tracked:
|
||||
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
try:
|
||||
await audit_service.persist(**event, status="denied", message="Nur für Administratoren")
|
||||
finally:
|
||||
audit_service.context.reset(ctx)
|
||||
return JSONResponse(status_code=403, content={"detail": {"code": ErrorCode.ADMIN_REQUIRED.value, "params": {}}})
|
||||
if user.role == "operator" and api_token_service.is_rollout(request.method, request.url.path):
|
||||
async with SessionLocal() as gdb:
|
||||
allowed = (await governance.get_settings(gdb)).operators_may_deploy
|
||||
if not allowed:
|
||||
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
try:
|
||||
await audit_service.persist(**event, status="denied", message="Ausrollen nur durch Administratoren (Aufgabentrennung)")
|
||||
finally:
|
||||
audit_service.context.reset(ctx)
|
||||
return JSONResponse(status_code=403, content={"detail": {"code": ErrorCode.DEPLOY_NEEDS_ADMIN.value, "params": {}}})
|
||||
request.state.user = user
|
||||
token = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
request.state.api_token = api_token
|
||||
actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150]
|
||||
token = audit_service.context.set({"actor": actor, "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
try:
|
||||
if tracked:
|
||||
await audit_service.persist(**event, status="requested")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Decrypt an offsite backup (``.opxbak``) back into a config.xml.
|
||||
|
||||
docker compose exec -T backend python -m app.backup_decrypt < file.opxbak > config.xml
|
||||
|
||||
The passphrase comes from the BACKUP_PASSPHRASE environment variable or, if that is
|
||||
not set, from the passphrase stored in OPNexus. For a disaster-recovery machine
|
||||
without OPNexus run it with the passphrase you kept elsewhere:
|
||||
|
||||
docker run --rm -i -e BACKUP_PASSPHRASE=... <backend image> python -m app.backup_decrypt < file.opxbak > config.xml
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from app.services import backup_crypto, notification_secret_store
|
||||
|
||||
|
||||
def main() -> int:
|
||||
passphrase = os.environ.get("BACKUP_PASSPHRASE") or notification_secret_store.read_secret("backup_passphrase")
|
||||
if not passphrase:
|
||||
print("Keine Passphrase: BACKUP_PASSPHRASE setzen.", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
sys.stdout.buffer.write(backup_crypto.decrypt(sys.stdin.buffer.read(), passphrase))
|
||||
except backup_crypto.DecryptError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -27,6 +27,17 @@ class Settings(BaseSettings):
|
||||
# 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 = ""
|
||||
|
||||
# ── 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
|
||||
@@ -45,6 +56,11 @@ class Settings(BaseSettings):
|
||||
# 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://
|
||||
|
||||
@@ -6,6 +6,14 @@ from fastapi import HTTPException
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
UPDATER_UNAVAILABLE = "UPDATER_UNAVAILABLE"
|
||||
UPDATER_BUSY = "UPDATER_BUSY"
|
||||
UPDATER_REJECTED = "UPDATER_REJECTED"
|
||||
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
|
||||
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
|
||||
UPDATE_CHECK_MANAGED_BY_SERVER = "UPDATE_CHECK_MANAGED_BY_SERVER"
|
||||
UPDATE_CHECK_URL_INVALID = "UPDATE_CHECK_URL_INVALID"
|
||||
ADMIN_REQUIRED = "ADMIN_REQUIRED"
|
||||
BACKUP_EMPTY = "BACKUP_EMPTY"
|
||||
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
||||
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
|
||||
@@ -43,6 +51,8 @@ class ErrorCode(str, Enum):
|
||||
FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998"
|
||||
FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR = "FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR"
|
||||
GEPLANTE_WARTUNG_NICHT_GEFUNDEN = "GEPLANTE_WARTUNG_NICHT_GEFUNDEN"
|
||||
GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH = "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
|
||||
CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER = "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
|
||||
GROUP_HAS_ASSIGNED_ITEMS = "GROUP_HAS_ASSIGNED_ITEMS"
|
||||
GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN = "GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN"
|
||||
GRUPPENNAME_BEREITS_VERGEBEN = "GRUPPENNAME_BEREITS_VERGEBEN"
|
||||
@@ -99,6 +109,13 @@ class ErrorCode(str, Enum):
|
||||
REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203 = "REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203"
|
||||
REGEL_HAT_KEINE_ZIEL_HOSTS = "REGEL_HAT_KEINE_ZIEL_HOSTS"
|
||||
REGEL_NICHT_GEFUNDEN = "REGEL_NICHT_GEFUNDEN"
|
||||
NETWORK_ACK_REQUIRED = "NETWORK_ACK_REQUIRED"
|
||||
NETWORK_APPLY_UNCERTAIN = "NETWORK_APPLY_UNCERTAIN"
|
||||
NETWORK_BACKUP_FAILED = "NETWORK_BACKUP_FAILED"
|
||||
NETWORK_CHANGE_BLOCKED = "NETWORK_CHANGE_BLOCKED"
|
||||
NETWORK_FIELD_INVALID = "NETWORK_FIELD_INVALID"
|
||||
NETWORK_ITEM_NOT_FOUND = "NETWORK_ITEM_NOT_FOUND"
|
||||
RULE_NOT_ADOPTABLE = "RULE_NOT_ADOPTABLE"
|
||||
REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0"
|
||||
RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN"
|
||||
ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL"
|
||||
@@ -134,7 +151,26 @@ class ErrorCode(str, Enum):
|
||||
OPEN_DEPLOYMENT_DRAFT_UNCHANGED = "OPEN_DEPLOYMENT_DRAFT_UNCHANGED"
|
||||
OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY = "OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY"
|
||||
OPEN_RESTORE_BLOCKS_DEPLOY = "OPEN_RESTORE_BLOCKS_DEPLOY"
|
||||
UPSTREAM_REJECTED = "UPSTREAM_REJECTED"
|
||||
INVALID_CHARACTERS = "INVALID_CHARACTERS"
|
||||
SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
|
||||
SERVICE_LOCKED = "SERVICE_LOCKED"
|
||||
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
|
||||
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED"
|
||||
OFFSITE_TEST_FAILED = "OFFSITE_TEST_FAILED"
|
||||
OIDC_TEST_FAILED = "OIDC_TEST_FAILED"
|
||||
VARIABLES_MISSING = "VARIABLES_MISSING"
|
||||
VARIABLE_INVALID = "VARIABLE_INVALID"
|
||||
OVERLAY_INVALID = "OVERLAY_INVALID"
|
||||
OVERLAY_SUBNET_LOCKED = "OVERLAY_SUBNET_LOCKED"
|
||||
OVERLAY_NAME_TAKEN = "OVERLAY_NAME_TAKEN"
|
||||
OVERLAY_PEER_NOT_FOUND = "OVERLAY_PEER_NOT_FOUND"
|
||||
DEPLOY_NEEDS_ADMIN = "DEPLOY_NEEDS_ADMIN"
|
||||
OIDC_MANAGED_USER = "OIDC_MANAGED_USER"
|
||||
BACKUP_UNLESBAR = "BACKUP_UNLESBAR"
|
||||
API_TOKEN_NOT_FOUND = "API_TOKEN_NOT_FOUND"
|
||||
API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN"
|
||||
API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH"
|
||||
|
||||
|
||||
def api_error(status_code: int, code: ErrorCode, **params: Any) -> HTTPException:
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Full OPNexus backup: the database and every secret store in one encrypted file.
|
||||
|
||||
The nightly database dump is not enough to rebuild an OPNexus server: the firewalls' API secrets, TOTP
|
||||
secrets, VPN/certificate keys and the stored config snapshots live in separate volumes. This packs all of it
|
||||
(``database.dump`` + the data directories + ``manifest.json``) into a tar.gz and encrypts it with a passphrase
|
||||
(AES-256-GCM, services/backup_crypto.py). Without the passphrase the file is useless, so keep it elsewhere.
|
||||
|
||||
Normally driven by the host CLI:
|
||||
|
||||
./opnexus backup --full [FILE] # writes an encrypted .opxfull file
|
||||
./opnexus restore FILE # onto a fresh or existing installation
|
||||
|
||||
or directly (passphrase in FULL_BACKUP_PASSPHRASE):
|
||||
|
||||
python -m app.full_backup create > file.opxfull
|
||||
python -m app.full_backup info < file.opxfull # manifest only
|
||||
python -m app.full_backup restore < file.opxfull
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from app.config import settings
|
||||
from app.services import backup_crypto
|
||||
from app.version import APP_VERSION
|
||||
|
||||
FORMAT = 1
|
||||
MIN_PASSPHRASE = 12
|
||||
# manifest key -> settings attribute holding the directory
|
||||
_DATA_DIRS = {
|
||||
"opnsense_secrets": "opnsense_secret_dir",
|
||||
"opnsense_backups": "opnsense_backup_dir",
|
||||
"ipsec_psk_secrets": "ipsec_psk_secret_dir",
|
||||
"openvpn_key_secrets": "openvpn_key_secret_dir",
|
||||
"wireguard_key_secrets": "wireguard_key_secret_dir",
|
||||
"cert_key_secrets": "cert_key_secret_dir",
|
||||
"acme_secrets": "acme_secret_dir",
|
||||
"totp_secrets": "totp_secret_dir",
|
||||
"notification_secrets": "notification_secret_dir",
|
||||
"overlay": "overlay_dir",
|
||||
}
|
||||
|
||||
|
||||
class FullBackupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _dir(name: str) -> Path:
|
||||
return Path(getattr(settings, _DATA_DIRS[name]))
|
||||
|
||||
|
||||
def _db_args() -> tuple[list[str], dict]:
|
||||
url = make_url(settings.database_url)
|
||||
return (
|
||||
["-h", url.host or "localhost", "-p", str(url.port or 5432), "-U", url.username or "", "-d", url.database or ""],
|
||||
{**os.environ, "PGPASSWORD": url.password or ""},
|
||||
)
|
||||
|
||||
|
||||
def _dump_database(target: Path) -> None:
|
||||
args, env = _db_args()
|
||||
skip = [f"--exclude-table-data={t}" for t in ("firewall_log_entries", "host_resource_samples")]
|
||||
subprocess.run(["pg_dump", *args, "-F", "c", *skip, "-f", str(target)], env=env, check=True, capture_output=True, timeout=1800)
|
||||
|
||||
|
||||
def _restore_database(source: Path) -> None:
|
||||
args, env = _db_args()
|
||||
result = subprocess.run(
|
||||
["pg_restore", *args, "--clean", "--if-exists", "--no-owner", "--exit-on-error", str(source)],
|
||||
env=env, capture_output=True, text=True, timeout=1800,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise FullBackupError(f"pg_restore fehlgeschlagen: {result.stderr.strip()[:400]}")
|
||||
|
||||
|
||||
def _head_revision() -> str:
|
||||
from alembic.script import ScriptDirectory
|
||||
from app import migrate
|
||||
return ScriptDirectory.from_config(migrate._config()).get_current_head()
|
||||
|
||||
|
||||
def create(passphrase: str, *, dump=_dump_database, revision=None) -> bytes:
|
||||
if len(passphrase) < MIN_PASSPHRASE:
|
||||
raise FullBackupError(f"Die Passphrase braucht mindestens {MIN_PASSPHRASE} Zeichen")
|
||||
manifest = {
|
||||
"format": FORMAT, "app_version": APP_VERSION, "db_revision": revision or _head_revision(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(), "data": sorted(_DATA_DIRS),
|
||||
}
|
||||
plain = io.BytesIO()
|
||||
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=plain, mode="w:gz") as tar:
|
||||
raw = json.dumps(manifest, indent=1).encode()
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(raw)
|
||||
tar.addfile(info, io.BytesIO(raw))
|
||||
dump_path = Path(tmp) / "database.dump"
|
||||
dump(dump_path)
|
||||
tar.add(dump_path, arcname="database.dump")
|
||||
for name in _DATA_DIRS:
|
||||
path = _dir(name)
|
||||
if path.is_dir():
|
||||
tar.add(path, arcname=f"data/{name}")
|
||||
return backup_crypto.encrypt(plain.getvalue(), passphrase)
|
||||
|
||||
|
||||
def _open(blob: bytes, passphrase: str) -> tarfile.TarFile:
|
||||
try:
|
||||
plain = backup_crypto.decrypt(blob, passphrase)
|
||||
except backup_crypto.DecryptError as exc:
|
||||
raise FullBackupError(str(exc)) from None
|
||||
try:
|
||||
return tarfile.open(fileobj=io.BytesIO(plain), mode="r:gz")
|
||||
except tarfile.TarError:
|
||||
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Archiv unlesbar)") from None
|
||||
|
||||
|
||||
def _manifest(tar: tarfile.TarFile) -> dict:
|
||||
try:
|
||||
member = tar.extractfile("manifest.json")
|
||||
manifest = json.loads(member.read()) if member else None
|
||||
except (KeyError, ValueError):
|
||||
manifest = None
|
||||
if not isinstance(manifest, dict) or manifest.get("format") != FORMAT:
|
||||
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Manifest fehlt oder ist neuer)")
|
||||
return manifest
|
||||
|
||||
|
||||
def info(blob: bytes, passphrase: str) -> dict:
|
||||
with _open(blob, passphrase) as tar:
|
||||
return _manifest(tar)
|
||||
|
||||
|
||||
def restore(blob: bytes, passphrase: str, *, restore_db=_restore_database, head=None) -> dict:
|
||||
"""Put database and data directories back. The caller stops the backend first."""
|
||||
with _open(blob, passphrase) as tar:
|
||||
manifest = _manifest(tar)
|
||||
if not _older_or_equal(manifest.get("db_revision"), head):
|
||||
raise FullBackupError(
|
||||
f"Die Sicherung stammt aus einer neueren OPNexus-Version ({manifest.get('app_version')}). "
|
||||
"Erst auf diese Version aktualisieren, dann zurückspielen."
|
||||
)
|
||||
members = tar.getmembers()
|
||||
for member in members: # reject anything that could escape: absolute paths, .., links, devices
|
||||
if member.name.startswith(("/", "\\")) or ".." in Path(member.name).parts or not (member.isfile() or member.isdir()):
|
||||
raise FullBackupError(f"Unzulässiger Eintrag im Archiv: {member.name!r}")
|
||||
known = {f"data/{name}" for name in _DATA_DIRS}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dump = tar.extractfile("database.dump")
|
||||
if dump is None:
|
||||
raise FullBackupError("Die Sicherung enthält keine Datenbank")
|
||||
dump_path = Path(tmp) / "database.dump"
|
||||
dump_path.write_bytes(dump.read())
|
||||
restore_db(dump_path)
|
||||
restored = []
|
||||
for member in members:
|
||||
parts = Path(member.name).parts
|
||||
if len(parts) < 2 or parts[0] != "data" or f"data/{parts[1]}" not in known:
|
||||
continue
|
||||
target_root = _dir(parts[1])
|
||||
target = target_root.joinpath(*parts[2:])
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
source = tar.extractfile(member)
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
out.write(source.read())
|
||||
os.utime(target, (member.mtime, member.mtime)) # snapshot ages come from file times
|
||||
restored.append(parts[1])
|
||||
return {**manifest, "files_restored": len(restored)}
|
||||
|
||||
|
||||
def _older_or_equal(revision: str | None, head: str | None) -> bool:
|
||||
"""Revisions are zero-padded numbers ('0009'): a dump from an older schema is fine, the app migrates it."""
|
||||
try:
|
||||
return int(revision) <= int(head or _head_revision())
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _main(argv: list[str]) -> int:
|
||||
passphrase = os.environ.get("FULL_BACKUP_PASSPHRASE", "")
|
||||
if len(argv) != 2 or argv[1] not in ("create", "info", "restore"):
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
if argv[1] == "create":
|
||||
sys.stdout.buffer.write(create(passphrase))
|
||||
elif argv[1] == "info":
|
||||
print(json.dumps(info(sys.stdin.buffer.read(), passphrase), indent=1))
|
||||
else:
|
||||
print(json.dumps(restore(sys.stdin.buffer.read(), passphrase), indent=1))
|
||||
except (FullBackupError, subprocess.SubprocessError, OSError) as exc:
|
||||
print(f"Fehler: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main(sys.argv))
|
||||
+144
-4
@@ -5,8 +5,12 @@ 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
|
||||
@@ -14,7 +18,7 @@ from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddle
|
||||
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 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
|
||||
@@ -33,6 +37,10 @@ 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
|
||||
@@ -42,6 +50,10 @@ 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 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,
|
||||
@@ -222,6 +234,7 @@ async def _resource_poll_loop():
|
||||
|
||||
|
||||
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
|
||||
_RUNNING_MAINTENANCE_POLL_SECONDS = 15
|
||||
|
||||
|
||||
async def _scheduled_maintenance_poll_loop():
|
||||
@@ -237,9 +250,64 @@ async def _scheduled_maintenance_poll_loop():
|
||||
"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")
|
||||
await asyncio.sleep(_SCHEDULED_MAINTENANCE_POLL_SECONDS)
|
||||
in_progress = 0
|
||||
# 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
|
||||
@@ -247,6 +315,10 @@ async def lifespan(app: FastAPI):
|
||||
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 "
|
||||
@@ -260,6 +332,9 @@ async def lifespan(app: FastAPI):
|
||||
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:
|
||||
@@ -268,14 +343,46 @@ async def lifespan(app: FastAPI):
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
app = FastAPI(title="OPNexus", version=APP_VERSION, lifespan=lifespan)
|
||||
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):
|
||||
@@ -289,6 +396,25 @@ async def _safe_validation_error(request: Request, exc: RequestValidationError):
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
@@ -322,6 +448,16 @@ 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")
|
||||
@@ -374,5 +510,9 @@ 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(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")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Personal API tokens for scripts, CI jobs, Prometheus and MCP clients.
|
||||
|
||||
Only the SHA-256 of the token is stored (the token is 256 random bits, so a
|
||||
fast hash is enough and lets a request be resolved with one indexed lookup);
|
||||
the raw value is shown exactly once, at creation. A token belongs to a user and
|
||||
never exceeds that user's role: see services/api_token_service.py for the
|
||||
scopes and AuthMiddleware for where they are enforced.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
# "read" | "deploy" | "admin"
|
||||
scope: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
# First characters of the token, so a list can tell tokens apart. Not secret.
|
||||
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
|
||||
user: Mapped["AdminUser"] = relationship("AdminUser")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Forwarding of the audit trail to a syslog server and/or a webhook (singleton row).
|
||||
|
||||
``cursor_*`` is the position of the last shipped event, so delivery resumes where
|
||||
it stopped (at-least-once). Enabling a channel starts at "now": the existing history
|
||||
is not replayed. The webhook signing secret lives on disk, like every other secret
|
||||
(services/notification_secret_store.py).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditExportSettings(Base):
|
||||
__tablename__ = "audit_export_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
syslog_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
syslog_host: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
syslog_port: Mapped[int] = mapped_column(Integer, nullable=False, default=514)
|
||||
# "udp" | "tcp" | "tls"
|
||||
syslog_protocol: Mapped[str] = mapped_column(String(3), nullable=False, default="udp")
|
||||
webhook_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
webhook_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
webhook_signing_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
cursor_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cursor_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
last_success_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
last_error_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Scheduled config backups and their offsite copy (singleton row + upload ledger).
|
||||
|
||||
The S3 secret key and the encryption passphrase live on disk (notification_secret_store,
|
||||
names ``backup_s3_secret`` / ``backup_passphrase``), never here.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class BackupSettings(Base):
|
||||
__tablename__ = "backup_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scheduled_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
interval_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=24)
|
||||
keep_count: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
|
||||
offsite_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
s3_endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
s3_region: Mapped[str] = mapped_column(String(64), nullable=False, default="us-east-1")
|
||||
s3_bucket: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
s3_prefix: Mapped[str] = mapped_column(String(255), nullable=False, default="opnexus")
|
||||
s3_access_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
s3_path_style: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
s3_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
passphrase_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_offsite_ok_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_offsite_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
|
||||
|
||||
class BackupUpload(Base):
|
||||
"""Which local snapshot already exists offsite (so nothing is uploaded twice)."""
|
||||
|
||||
__tablename__ = "backup_uploads"
|
||||
__table_args__ = (UniqueConstraint("host_id", "snapshot_id", name="uq_backup_upload"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||
snapshot_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
remote_key: Mapped[str] = mapped_column(String(400), nullable=False)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Latest security-posture result per host (see services/compliance_service.py).
|
||||
|
||||
One row per host, replaced on every check. The findings never contain
|
||||
configuration values beyond rule descriptions and the protocol names they
|
||||
report on; secrets from config.xml are never read into them.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ComplianceResult(Base):
|
||||
__tablename__ = "compliance_results"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
|
||||
)
|
||||
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# None when the check could not run (see error).
|
||||
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
findings: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||
# None | "unreachable" | "invalid_config"
|
||||
error: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
error_detail: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Separation of duties (singleton row): may operators roll changes out themselves?"""
|
||||
from sqlalchemy import Boolean, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class GovernanceSettings(Base):
|
||||
__tablename__ = "governance_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# False: an operator prepares changes (edit, preview) but only an admin may deploy them.
|
||||
operators_may_deploy: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
@@ -44,3 +44,7 @@ class OpnsenseHost(Base):
|
||||
policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship(
|
||||
"PolicyHierarchyGroup", back_populates="hosts"
|
||||
)
|
||||
# Values for {{NAME}} placeholders (services/variables_service.py); always loaded with the host.
|
||||
variables: Mapped[list["HostVariable"]] = relationship(
|
||||
"HostVariable", lazy="selectin", cascade="all, delete-orphan", order_by="HostVariable.name"
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Per-host values for ``{{NAME}}`` placeholders in centrally authored objects (see services/variables_service.py)."""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class HostVariable(Base):
|
||||
__tablename__ = "host_variables"
|
||||
__table_args__ = (UniqueConstraint("host_id", "name", name="uq_host_variable"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(41), nullable=False)
|
||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -48,7 +48,12 @@ class NotificationSettings(Base):
|
||||
maintenance_unknown_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
carp_health_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
nat_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
backup_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
# Off by default: switching it on for an existing fleet reports every long-standing finding at once.
|
||||
compliance_alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
@@ -70,7 +75,7 @@ class NotificationProblem(Base):
|
||||
__table_args__ = (UniqueConstraint("problem_type", "dedup_key", name="uq_notification_problem_key"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown
|
||||
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown | ... (see ProblemType)
|
||||
problem_type: Mapped[str] = mapped_column(String(32))
|
||||
dedup_key: Mapped[str] = mapped_column(String(300))
|
||||
status: Mapped[str] = mapped_column(String(16), default="open") # open | resolved
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Single sign-on via OpenID Connect: settings (singleton) and in-flight login attempts.
|
||||
|
||||
The client secret lives on disk (notification_secret_store, name ``oidc_client_secret``), not here.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class OidcSettings(Base):
|
||||
__tablename__ = "oidc_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
display_name: Mapped[str] = mapped_column(String(60), nullable=False, default="SSO")
|
||||
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
|
||||
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
|
||||
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
read_only_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class OidcLoginState(Base):
|
||||
"""One row per started login; consumed (deleted) by the callback, expires after minutes."""
|
||||
|
||||
__tablename__ = "oidc_login_states"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
state_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Management overlay: a WireGuard hub so OPNexus can reach firewalls behind NAT/CGNAT.
|
||||
|
||||
The firewall dials OUT to the hub; OPNexus then talks to its API at the firewall's overlay address. Keys live
|
||||
on disk (services/overlay_service.py), never in the database.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class OverlaySettings(Base):
|
||||
__tablename__ = "overlay_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# host or host:port that firewalls dial (public name/IP of this server and the published UDP port)
|
||||
endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
subnet: Mapped[str] = mapped_column(String(18), nullable=False, default="10.99.0.0/24")
|
||||
listen_port: Mapped[int] = mapped_column(Integer, nullable=False, default=51820)
|
||||
|
||||
|
||||
class OverlayPeer(Base):
|
||||
__tablename__ = "overlay_peers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||
ip: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
|
||||
# the firewall's WireGuard public key, entered once the firewall side exists
|
||||
public_key: Mapped[Optional[str]] = mapped_column(String(44), nullable=True, unique=True)
|
||||
host_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy import Boolean, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
@@ -9,3 +9,6 @@ class UserPreferences(Base):
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
|
||||
theme: Mapped[str] = mapped_column(String(10), default='system')
|
||||
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
|
||||
# Set once the user finished or dismissed the first-run setup assistant
|
||||
# (frontend /setup); it is then no longer offered automatically.
|
||||
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy import Boolean, DateTime, String, false
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -29,6 +29,7 @@ class FirewallRule(Base):
|
||||
destination_net: Mapped[str] = mapped_column(String(128), default="any")
|
||||
destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
log: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""When did a firewall rule last match something? (per host and rule UUID)
|
||||
|
||||
pf's own counters restart whenever the ruleset is reloaded (every apply, every reboot), so
|
||||
"packets = 0" alone proves nothing. OPNexus samples them periodically and keeps the time of the
|
||||
last observed hit, which survives those resets. See services/rule_hit_service.py.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RuleHit(Base):
|
||||
__tablename__ = "rule_hits"
|
||||
__table_args__ = (UniqueConstraint("host_id", "rule_uuid", name="uq_rule_hit"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||
rule_uuid: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_sampled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# None = never seen a hit since OPNexus started watching.
|
||||
last_hit_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_packets: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
last_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
@@ -13,7 +13,7 @@ table only tracks the *schedule* itself, not the resulting operation.
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -40,3 +40,10 @@ class ScheduledMaintenance(Base):
|
||||
# Set to the resulting HostOperation's id once fired, so the UI can
|
||||
# link from the schedule to the operation it produced.
|
||||
host_operation_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
# Opt-in: hand the traffic over to the CARP partner first, run the action, then switch back.
|
||||
carp_handover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
# Only while status == "running" with a handover: handover_wait | job_wait | leave | leave_wait.
|
||||
# State lives here, not in memory, so a backend restart in the middle of a run resumes cleanly.
|
||||
phase: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
phase_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
progress: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Version check configured in the web UI (singleton row, id fixed at 1).
|
||||
|
||||
Off by default: OPNexus makes no outgoing request until an admin switches it on. An
|
||||
UPDATE_CHECK_URL in the server's .env still wins over this row (server-managed)."""
|
||||
from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
|
||||
|
||||
|
||||
class UpdateCheckSettings(Base):
|
||||
__tablename__ = "update_check_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
feed_url: Mapped[str] = mapped_column(String(500), nullable=False, default=DEFAULT_FEED_URL, server_default=DEFAULT_FEED_URL)
|
||||
@@ -32,6 +32,10 @@ class AdminUser(Base):
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
totp_last_step: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
totp_recovery_hashes: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
# "local" (password) or "oidc" (single sign-on: role follows the identity provider's groups at every
|
||||
# login, the password hash is an unusable placeholder). oidc_subject is "<issuer>|<sub>".
|
||||
auth_source: Mapped[str] = mapped_column(String(10), nullable=False, default="local", server_default="local")
|
||||
oidc_subject: Mapped[Optional[str]] = mapped_column(String(600), nullable=True, unique=True, index=True)
|
||||
|
||||
sessions: Mapped[list["Session"]] = relationship(
|
||||
"Session", back_populates="user", cascade="all, delete-orphan"
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""The ``wg-hub`` container: WireGuard interface + a CONNECT proxy for the management overlay.
|
||||
|
||||
Runs from the backend image (``python -m app.overlay_hub``) with CAP_NET_ADMIN and the WireGuard UDP port
|
||||
published. Everything it needs comes from ``<overlay_dir>/hub.json`` (written by the backend, see
|
||||
services/overlay_service.py); it reports back through ``status.json``. It never talks to the database.
|
||||
|
||||
* WireGuard: wg0 with the hub address, peers synced with ``wg syncconf`` whenever hub.json changes. Inbound
|
||||
NEW connections from the tunnel are dropped (iptables), so a compromised firewall cannot reach anything on this
|
||||
container; OPNexus' own requests to the firewalls and their replies are unaffected.
|
||||
* Proxy: an HTTP CONNECT proxy (port 8888, compose network only). It is not an open proxy: it only connects to
|
||||
IPv4 addresses inside the overlay subnet from hub.json.
|
||||
"""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("overlay_hub")
|
||||
INTERFACE = "wg0"
|
||||
PROXY_PORT = 8888
|
||||
APPLY_EVERY = 5
|
||||
|
||||
|
||||
def run(*cmd: str, stdin: str | None = None) -> tuple[int, str]:
|
||||
try:
|
||||
result = subprocess.run(list(cmd), input=stdin, capture_output=True, text=True, timeout=20)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return 127, str(exc)
|
||||
return result.returncode, (result.stdout + result.stderr).strip()
|
||||
|
||||
|
||||
def wg_conf(config: dict) -> str:
|
||||
lines = ["[Interface]", f"PrivateKey = {config['private_key']}", f"ListenPort = {int(config['listen_port'])}"]
|
||||
for peer in config.get("peers", []):
|
||||
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}", f"PresharedKey = {peer['psk']}", f"AllowedIPs = {peer['ip']}/32"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_dump(text: str) -> list[dict]:
|
||||
"""`wg show <if> dump`: first line is the interface, then one tab-separated line per peer."""
|
||||
peers = []
|
||||
for line in text.splitlines()[1:]:
|
||||
f = line.split("\t")
|
||||
if len(f) >= 7:
|
||||
peers.append({"public_key": f[0], "endpoint": None if f[2] == "(none)" else f[2], "handshake_at": int(f[4] or 0),
|
||||
"rx": int(f[5] or 0), "tx": int(f[6] or 0)})
|
||||
return peers
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self, directory: Path, runner=run):
|
||||
self.dir, self.run = directory, runner
|
||||
self.subnet: ipaddress.IPv4Network | None = None
|
||||
self._applied_mtime = 0.0
|
||||
|
||||
def read_config(self) -> dict | None:
|
||||
try:
|
||||
config = json.loads((self.dir / "hub.json").read_text())
|
||||
return config if isinstance(config, dict) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def apply(self, config: dict) -> str | None:
|
||||
"""Bring the interface in line with the config. Returns an error text or None."""
|
||||
self.subnet = ipaddress.ip_network(config["subnet"]) if config.get("enabled") and config.get("subnet") else None
|
||||
exists = self.run("ip", "link", "show", INTERFACE)[0] == 0
|
||||
if not config.get("enabled"):
|
||||
if exists:
|
||||
self.run("ip", "link", "del", INTERFACE)
|
||||
return None
|
||||
steps = []
|
||||
if not exists:
|
||||
steps.append(("ip", "link", "add", INTERFACE, "type", "wireguard"))
|
||||
steps += [("ip", "address", "replace", config["address"], "dev", INTERFACE), ("ip", "link", "set", INTERFACE, "up")]
|
||||
for step in steps:
|
||||
code, out = self.run(*step)
|
||||
if code != 0:
|
||||
return f"{' '.join(step[:3])}: {out}"[:300]
|
||||
conf = self.dir / ".wg0.conf"
|
||||
fd = os.open(conf, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
handle.write(wg_conf(config))
|
||||
try:
|
||||
code, out = self.run("wg", "syncconf", INTERFACE, str(conf))
|
||||
finally:
|
||||
conf.unlink(missing_ok=True)
|
||||
if code != 0:
|
||||
return f"wg syncconf: {out}"[:300]
|
||||
# Nothing may start a connection INTO this container from the tunnel; replies to ours still pass.
|
||||
rule = ("INPUT", "-i", INTERFACE, "-m", "conntrack", "--ctstate", "NEW", "-j", "DROP")
|
||||
if self.run("iptables", "-C", *rule)[0] != 0:
|
||||
code, out = self.run("iptables", "-I", *rule)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
# The hub only needs its own proxy: never route between peers or into the compose network.
|
||||
fwd = ("FORWARD", "-j", "DROP")
|
||||
if self.run("iptables", "-C", *fwd)[0] != 0:
|
||||
code, out = self.run("iptables", "-I", *fwd)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
return None
|
||||
|
||||
def status(self, error: str | None) -> dict:
|
||||
code, out = self.run("wg", "show", INTERFACE, "dump")
|
||||
return {"updated_at": time.time(), "up": code == 0, "peers": parse_dump(out) if code == 0 else [], "error": error}
|
||||
|
||||
def write_status(self, error: str | None) -> None:
|
||||
tmp = self.dir / "status.json.tmp"
|
||||
tmp.write_text(json.dumps(self.status(error)))
|
||||
os.replace(tmp, self.dir / "status.json")
|
||||
|
||||
def tick(self, last_error: str | None) -> str | None:
|
||||
"""One round: re-apply if hub.json changed, then report. Returns the current error."""
|
||||
config = self.read_config()
|
||||
if config is not None:
|
||||
mtime = (self.dir / "hub.json").stat().st_mtime
|
||||
if mtime != self._applied_mtime or last_error:
|
||||
last_error = self.apply(config)
|
||||
self._applied_mtime = mtime
|
||||
self.write_status(last_error)
|
||||
return last_error
|
||||
|
||||
# ── CONNECT proxy ────────────────────────────────────────────────────
|
||||
def allowed(self, host: str) -> bool:
|
||||
try:
|
||||
return self.subnet is not None and ipaddress.ip_address(host) in self.subnet
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 10)
|
||||
method, target, _ = head.split(b"\r\n", 1)[0].decode("latin-1").split(" ", 2)
|
||||
host, _, port_text = target.rpartition(":")
|
||||
if method != "CONNECT" or not port_text.isdigit() or not 0 < int(port_text) < 65536 or not self.allowed(host.strip("[]")):
|
||||
writer.write(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
return
|
||||
try:
|
||||
up_reader, up_writer = await asyncio.wait_for(asyncio.open_connection(host, int(port_text)), 8)
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
writer.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
return
|
||||
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
||||
await writer.drain()
|
||||
await asyncio.gather(self._pipe(reader, up_writer), self._pipe(up_reader, writer))
|
||||
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError, ValueError, OSError):
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@staticmethod
|
||||
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
while data := await src.read(65536):
|
||||
dst.write(data)
|
||||
await dst.drain()
|
||||
except (OSError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
dst.close()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
directory = Path(settings.overlay_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
hub = Hub(directory)
|
||||
server = await asyncio.start_server(hub.handle_client, "0.0.0.0", PROXY_PORT)
|
||||
logger.info("Overlay-Hub gestartet (Proxy auf :%d)", PROXY_PORT)
|
||||
error = None
|
||||
async with server:
|
||||
while True:
|
||||
try:
|
||||
new_error = await asyncio.to_thread(hub.tick, error)
|
||||
except Exception:
|
||||
logger.exception("Overlay-Hub-Runde fehlgeschlagen")
|
||||
new_error = "interner Fehler"
|
||||
if new_error and new_error != error:
|
||||
logger.warning("Overlay: %s", new_error)
|
||||
error = new_error
|
||||
await asyncio.sleep(APPLY_EVERY)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Recovery: give an account a new random password and print it once.
|
||||
|
||||
For a fresh install whose generated first-start password scrolled out of the
|
||||
log, and for an admin who forgot theirs. Needs shell access to the server,
|
||||
which is the trust boundary for this tool anyway (same as app.reset_2fa).
|
||||
All sessions and API tokens of the account are ended. Normally called through the host
|
||||
CLI:
|
||||
|
||||
./opnexus password [username]
|
||||
|
||||
or directly:
|
||||
|
||||
docker compose exec backend python -m app.reset_password [username]
|
||||
|
||||
Without a username the configured ADMIN_USERNAME is used; if that account does
|
||||
not exist and there is exactly one admin, that one.
|
||||
"""
|
||||
import asyncio
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.user import AdminUser
|
||||
from app.services import api_token_service, auth_service
|
||||
|
||||
|
||||
async def main(username: str | None) -> int:
|
||||
async with SessionLocal() as db:
|
||||
if username is None:
|
||||
username = settings.admin_username
|
||||
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
|
||||
if user is None:
|
||||
admins = (await db.execute(select(AdminUser).where(AdminUser.role == "admin"))).scalars().all()
|
||||
if len(admins) != 1:
|
||||
names = ", ".join(sorted(a.username for a in admins)) or "-"
|
||||
print(f"No account named '{username}'. Pass the username (admins: {names}).", file=sys.stderr)
|
||||
return 1
|
||||
user = admins[0]
|
||||
else:
|
||||
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
|
||||
if user is None:
|
||||
print(f"No such user: {username}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
password = secrets.token_urlsafe(18)
|
||||
user.password_hash = await auth_service.hash_password_async(password)
|
||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||
await api_token_service.revoke_all(db, user.id, commit=False)
|
||||
await db.commit()
|
||||
name = user.username
|
||||
has_totp = user.totp_enabled
|
||||
|
||||
print(f"Username: {name}")
|
||||
print(f"Password: {password}")
|
||||
if has_totp:
|
||||
print("Two-factor sign-in is still on (lost the code? python -m app.reset_2fa).", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 2:
|
||||
print("usage: python -m app.reset_password [username]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
sys.exit(asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else None)))
|
||||
@@ -146,7 +146,7 @@ async def delete_alias(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
await undeploy_alias(db, alias)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(alias, attribute_names=["deployments"])
|
||||
await db.delete(alias)
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Audit export settings (admin only): where the audit trail is forwarded to."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.audit_export import AuditExportRead, AuditExportUpdate
|
||||
from app.services import audit_export_service as svc
|
||||
from app.services import notification_secret_store
|
||||
|
||||
router = APIRouter(prefix="/settings/audit-export", tags=["audit-export"])
|
||||
|
||||
|
||||
def _require_admin(user: AdminUser) -> None:
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
@router.get("", response_model=AuditExportRead)
|
||||
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
return await svc.get_settings(db)
|
||||
|
||||
|
||||
@router.put("", response_model=AuditExportRead)
|
||||
async def update(payload: AuditExportUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
row = await svc.get_settings(db)
|
||||
was_active = svc.active(row)
|
||||
for field in ("syslog_enabled", "syslog_host", "syslog_port", "syslog_protocol", "webhook_enabled", "webhook_url"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(row, field, value.strip() if isinstance(value, str) else value)
|
||||
if payload.clear_webhook_signing_secret:
|
||||
notification_secret_store.delete_secret(svc.SECRET_NAME)
|
||||
row.webhook_signing_secret_set = False
|
||||
elif payload.webhook_signing_secret is not None:
|
||||
notification_secret_store.save_secret(svc.SECRET_NAME, payload.webhook_signing_secret)
|
||||
row.webhook_signing_secret_set = True
|
||||
if not svc.active(row):
|
||||
row.cursor_created_at = row.cursor_id = None # next enabling starts from "now" again
|
||||
elif not was_active:
|
||||
row.cursor_created_at, row.cursor_id = datetime.now(timezone.utc), None
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
try:
|
||||
await svc.send_test(await svc.get_settings(db))
|
||||
except svc.ExportError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
return {"success": True}
|
||||
@@ -9,7 +9,7 @@ from app.errors import ErrorCode, api_error
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import AdminUser
|
||||
from app.services import auth_service, totp_service
|
||||
from app.services import api_token_service, auth_service, totp_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,11 +30,11 @@ def client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=settings.session_lifetime,
|
||||
max_age=max_age or settings.session_lifetime,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.cookie_secure,
|
||||
@@ -63,6 +63,14 @@ def _account_success(user: AdminUser, action: str) -> None:
|
||||
|
||||
|
||||
async def resolve_user(request: Request, db: AsyncSession) -> AdminUser:
|
||||
# AuthMiddleware already resolved the caller (session cookie or API token), but
|
||||
# in its own, closed DB session: re-load into this request's session so that
|
||||
# changes made to the user (2FA, password...) are actually persisted.
|
||||
resolved = getattr(request.state, "user", None)
|
||||
if resolved is not None:
|
||||
user = await db.get(AdminUser, resolved.id)
|
||||
if user is not None:
|
||||
return user
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if user is None:
|
||||
@@ -262,6 +270,14 @@ async def logout(request: Request, response: Response, db: AsyncSession = Depend
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
raw = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
|
||||
if raw is not None:
|
||||
resolved = await api_token_service.resolve(db, raw, client_ip(request))
|
||||
if resolved is None:
|
||||
return {"authenticated": False}
|
||||
user, api_token = resolved
|
||||
return {"authenticated": True, "username": user.username, "role": user.role,
|
||||
"via": "token", "scope": api_token.scope, "token_name": api_token.name}
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if user is None:
|
||||
@@ -289,6 +305,7 @@ async def change_password(
|
||||
# an attacker may hold) and the replacement session commit as one unit.
|
||||
user.password_hash = await auth_service.hash_password_async(payload.new_password)
|
||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Scheduled backups: status for everybody, settings and offsite test for admins, "run now" for operators."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.backup_settings import BackupSettingsRead, BackupSettingsUpdate, BackupStatusRead
|
||||
from app.services import backup_diff_service, backup_store
|
||||
from app.services import backup_schedule_service as svc
|
||||
from app.services import notification_secret_store
|
||||
from app.services.s3_client import S3Error
|
||||
|
||||
router = APIRouter(tags=["backups"])
|
||||
|
||||
|
||||
def _require_admin(user: AdminUser) -> None:
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
@router.get("/backups/status", response_model=BackupStatusRead)
|
||||
async def status(db: AsyncSession = Depends(get_db)):
|
||||
return await svc.get_settings(db)
|
||||
|
||||
|
||||
@router.post("/backups/run")
|
||||
async def run_now(db: AsyncSession = Depends(get_db)):
|
||||
"""Back up every enabled host now (and copy offsite if configured)."""
|
||||
return await svc.run_due(db, force=True)
|
||||
|
||||
|
||||
@router.get("/settings/backups", response_model=BackupSettingsRead)
|
||||
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
return await svc.get_settings(db)
|
||||
|
||||
|
||||
@router.put("/settings/backups", response_model=BackupSettingsRead)
|
||||
async def update(payload: BackupSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
row = await svc.get_settings(db)
|
||||
for field in ("scheduled_enabled", "interval_hours", "keep_count", "offsite_enabled", "s3_region", "s3_path_style"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(row, field, value)
|
||||
for field in ("s3_endpoint", "s3_bucket", "s3_access_key", "s3_prefix"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(row, field, value.strip().rstrip("/") if field == "s3_endpoint" else value.strip())
|
||||
if payload.clear_s3_secret:
|
||||
notification_secret_store.delete_secret(svc.S3_SECRET)
|
||||
row.s3_secret_set = False
|
||||
elif payload.s3_secret_key is not None:
|
||||
notification_secret_store.save_secret(svc.S3_SECRET, payload.s3_secret_key)
|
||||
row.s3_secret_set = True
|
||||
if payload.passphrase is not None:
|
||||
notification_secret_store.save_secret(svc.PASSPHRASE, payload.passphrase)
|
||||
row.passphrase_set = True
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/settings/backups/test-offsite")
|
||||
async def test_offsite(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Write, read back and delete a probe object in the bucket."""
|
||||
_require_admin(user)
|
||||
cfg = await svc.get_settings(db)
|
||||
try:
|
||||
key = "/".join(p for p in (cfg.s3_prefix.strip("/"), f".opnexus-test-{int(datetime.now(timezone.utc).timestamp())}") if p)
|
||||
await svc.s3_for(cfg).test(key)
|
||||
except S3Error as exc:
|
||||
raise api_error(502, ErrorCode.OFFSITE_TEST_FAILED, reason=str(exc)) from exc
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/backup-diff")
|
||||
async def backup_diff(
|
||||
host_id: uuid.UUID,
|
||||
a: str | None = Query(default=None, description="Older backup id (default: the one before b)"),
|
||||
b: str | None = Query(default=None, description="Newer backup id (default: the newest)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""What changed between two stored backups of this host. Secrets are replaced by hashes."""
|
||||
if await db.get(OpnsenseHost, host_id) is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
snapshots = [s["id"] for s in backup_store.list_snapshots(host_id)] # newest first
|
||||
new_id = b or (snapshots[0] if snapshots else None)
|
||||
old_id = a or (snapshots[snapshots.index(new_id) + 1] if new_id in snapshots and snapshots.index(new_id) + 1 < len(snapshots) else None)
|
||||
if not new_id or not old_id:
|
||||
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
||||
old, new = backup_store.read_snapshot(host_id, old_id), backup_store.read_snapshot(host_id, new_id)
|
||||
if old is None or new is None:
|
||||
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
||||
try:
|
||||
result = backup_diff_service.diff(old, new)
|
||||
except backup_diff_service.InvalidConfig as exc:
|
||||
raise api_error(422, ErrorCode.BACKUP_UNLESBAR, reason=str(exc)) from exc
|
||||
return {"a": old_id, "b": new_id, **result}
|
||||
@@ -112,7 +112,7 @@ async def delete_managed_certificate(cert_id: uuid.UUID, db: AsyncSession = Depe
|
||||
try:
|
||||
await managed_certificate_service.delete_certificate(db, cert, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
|
||||
|
||||
# ── ACME accounts ──────────────────────────────────────────────────────
|
||||
@@ -197,7 +197,7 @@ async def delete_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(
|
||||
try:
|
||||
await acme_service.delete_account(db, account, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
|
||||
|
||||
# ── ACME certificate requests ─────────────────────────────────────────
|
||||
@@ -298,4 +298,4 @@ async def delete_acme_certificate_request(request_id: uuid.UUID, db: AsyncSessio
|
||||
try:
|
||||
await acme_service.delete_certificate_request(db, req, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Security posture ("Sicherheitslage"): stored results and on-demand checks.
|
||||
|
||||
Reads (GET) only return what the last check stored, so they are cheap and safe
|
||||
for dashboards, Prometheus and MCP. A check (POST) downloads the host's config
|
||||
read-only, evaluates it and stores the result; see services/compliance_service.py.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.rule_hit import RuleHit
|
||||
from app.services import compliance_service, rule_hit_service
|
||||
|
||||
router = APIRouter(tags=["compliance"])
|
||||
|
||||
|
||||
def _read(host: OpnsenseHost, row: ComplianceResult | None, *, detail: bool) -> dict:
|
||||
result = {
|
||||
"host_id": host.id, "host_name": host.name, "cluster_name": host.cluster_name, "enabled": host.enabled,
|
||||
"checked_at": row.checked_at if row else None,
|
||||
"score": row.score if row else None,
|
||||
"grade": compliance_service.grade(row.score) if row and row.score is not None else None,
|
||||
"error": row.error if row else None,
|
||||
"counts": compliance_service.counts(row.findings) if row else None,
|
||||
}
|
||||
if detail:
|
||||
result["findings"] = row.findings if row else []
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/compliance")
|
||||
async def fleet_compliance(db: AsyncSession = Depends(get_db)):
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
rows = {r.host_id: r for r in (await db.scalars(select(ComplianceResult))).all()}
|
||||
return [_read(host, rows.get(host.id), detail=False) for host in hosts]
|
||||
|
||||
|
||||
@router.post("/compliance/check")
|
||||
async def check_fleet(db: AsyncSession = Depends(get_db)):
|
||||
await compliance_service.check_all(db)
|
||||
return await fleet_compliance(db)
|
||||
|
||||
|
||||
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/compliance")
|
||||
async def host_compliance(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host(db, host_id)
|
||||
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host_id))
|
||||
return _read(host, row, detail=True)
|
||||
|
||||
|
||||
@router.post("/hosts/{host_id}/compliance/check")
|
||||
async def check_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host(db, host_id)
|
||||
row = await compliance_service.check_host(db, host)
|
||||
return _read(host, row, detail=True)
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/rule-hits")
|
||||
async def rule_hits(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""When each rule last matched something, as observed by OPNexus (see services/rule_hit_service.py)."""
|
||||
await _host(db, host_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = (await db.scalars(select(RuleHit).where(RuleHit.host_id == host_id))).all()
|
||||
return sorted(
|
||||
(
|
||||
{
|
||||
"rule_uuid": r.rule_uuid, "description": r.description, "enabled": r.enabled,
|
||||
"state": rule_hit_service.classify(r, now), "packets": r.last_packets, "bytes": r.last_bytes,
|
||||
"last_hit_at": r.last_hit_at, "first_seen_at": r.first_seen_at, "last_sampled_at": r.last_sampled_at,
|
||||
}
|
||||
for r in rows
|
||||
),
|
||||
key=lambda item: (item["state"] != "unused", item["last_hit_at"] or datetime.min.replace(tzinfo=timezone.utc), item["description"]),
|
||||
)
|
||||
@@ -184,18 +184,8 @@ async def _carp_auto_revert_actions(db: AsyncSession) -> list[PendingAction]:
|
||||
return actions
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardSummary)
|
||||
async def summary(db: AsyncSession = Depends(get_db)):
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
statuses = await get_statuses(hosts)
|
||||
host_summaries = [
|
||||
DashboardHost(
|
||||
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
|
||||
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
|
||||
)
|
||||
for host in hosts
|
||||
]
|
||||
|
||||
async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
|
||||
"""Everything that needs a human, newest first (shared with the metrics endpoint)."""
|
||||
pending_actions: list[PendingAction] = []
|
||||
pending_actions += await _rule_actions(db)
|
||||
pending_actions += await _nat_actions(db, PortForwardDeployment, PortForwardRule, "port_forward_id", "port_forward", "port-forward")
|
||||
@@ -209,6 +199,22 @@ async def summary(db: AsyncSession = Depends(get_db)):
|
||||
pending_actions += await _stuck_host_operations(db)
|
||||
pending_actions += await _carp_auto_revert_actions(db)
|
||||
pending_actions.sort(key=lambda item: item.created_at, reverse=True)
|
||||
return pending_actions
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardSummary)
|
||||
async def summary(db: AsyncSession = Depends(get_db)):
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
statuses = await get_statuses(hosts)
|
||||
host_summaries = [
|
||||
DashboardHost(
|
||||
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
|
||||
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
|
||||
)
|
||||
for host in hosts
|
||||
]
|
||||
|
||||
pending_actions = await collect_pending_actions(db)
|
||||
|
||||
activity_rows = (
|
||||
await db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(15))
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import diagnostics_service
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/diagnostics", tags=["diagnostics"])
|
||||
IpProto = Literal["inet", "inet6"]
|
||||
|
||||
|
||||
class PingRequest(BaseModel):
|
||||
target: str = Field(min_length=1, max_length=253)
|
||||
ipproto: IpProto = "inet"
|
||||
count: int = Field(default=4, ge=1, le=10)
|
||||
packetsize: int | None = Field(default=None, ge=1, le=9000)
|
||||
source: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class TracerouteRequest(BaseModel):
|
||||
target: str = Field(min_length=1, max_length=253)
|
||||
ipproto: IpProto = "inet"
|
||||
protocol: Literal["udp", "icmp"] = "icmp"
|
||||
source: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class PortprobeRequest(BaseModel):
|
||||
target: str = Field(min_length=1, max_length=253)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
ipproto: IpProto = "inet"
|
||||
source: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class DnsRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=253)
|
||||
server: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
@router.post("/ping")
|
||||
async def ping(host_id: uuid.UUID, p: PingRequest, db: AsyncSession = Depends(get_db)):
|
||||
return await diagnostics_service.ping(await _host(db, host_id), p.target, p.ipproto, p.count, p.packetsize, p.source)
|
||||
|
||||
|
||||
@router.post("/traceroute")
|
||||
async def traceroute(host_id: uuid.UUID, p: TracerouteRequest, db: AsyncSession = Depends(get_db)):
|
||||
return await diagnostics_service.traceroute(await _host(db, host_id), p.target, p.ipproto, p.protocol, p.source)
|
||||
|
||||
|
||||
@router.post("/portprobe")
|
||||
async def portprobe(host_id: uuid.UUID, p: PortprobeRequest, db: AsyncSession = Depends(get_db)):
|
||||
return await diagnostics_service.portprobe(await _host(db, host_id), p.target, p.port, p.ipproto, p.source)
|
||||
|
||||
|
||||
@router.post("/dns")
|
||||
async def dns(host_id: uuid.UUID, p: DnsRequest, db: AsyncSession = Depends(get_db)):
|
||||
return await diagnostics_service.dns_lookup(await _host(db, host_id), p.name, p.server)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Separation of duties: with ``operators_may_deploy`` off, only admins roll changes out (AuthMiddleware enforces it)."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.governance import GovernanceSettings
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
|
||||
router = APIRouter(prefix="/settings/governance", tags=["governance"])
|
||||
|
||||
|
||||
class GovernanceRead(BaseModel):
|
||||
operators_may_deploy: bool
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> GovernanceSettings:
|
||||
row = await db.get(GovernanceSettings, 1)
|
||||
if row is None:
|
||||
row = GovernanceSettings(id=1, operators_may_deploy=True)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
@router.get("", response_model=GovernanceRead)
|
||||
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Readable by everybody signed in (the UI explains why a deploy was refused)."""
|
||||
return await get_settings(db)
|
||||
|
||||
|
||||
@router.put("", response_model=GovernanceRead)
|
||||
async def update(payload: GovernanceRead, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
row = await get_settings(db)
|
||||
row.operators_may_deploy = payload.operators_may_deploy
|
||||
await db.commit()
|
||||
return row
|
||||
@@ -155,6 +155,8 @@ async def update_host(host_id: uuid.UUID, payload: HostUpdate, db: AsyncSession
|
||||
if existing:
|
||||
raise api_error(409, ErrorCode.HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS)
|
||||
host.name = payload.name
|
||||
if payload.base_url is not None:
|
||||
host.base_url = payload.base_url
|
||||
if payload.verify_tls is not None:
|
||||
host.verify_tls = payload.verify_tls
|
||||
if payload.clear_tls_pin:
|
||||
|
||||
@@ -174,7 +174,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
try:
|
||||
await undeploy_tunnel(db, tunnel)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(tunnel, attribute_names=["deployments"])
|
||||
ipsec_psk_store.delete_secret(tunnel.id)
|
||||
await db.delete(tunnel)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Read-only MCP server (Model Context Protocol, streamable HTTP transport).
|
||||
|
||||
Lets an AI assistant (Claude Code, Claude Desktop, ...) look at the fleet:
|
||||
|
||||
claude mcp add --transport http opnexus https://<opnexus>/api/mcp \
|
||||
--header "Authorization: Bearer opx_..."
|
||||
|
||||
Stateless JSON-RPC over POST (the spec allows plain ``application/json``
|
||||
replies; there is no server-initiated stream, GET answers 405). Every tool is a
|
||||
GET against OPNexus' own API, sent through the app itself with the caller's own
|
||||
credentials, so exactly the caller's permissions apply: a ``read`` token sees
|
||||
what a ``read`` token sees, secret-bearing endpoints stay closed. Nothing here
|
||||
can change state.
|
||||
"""
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.version import APP_VERSION
|
||||
|
||||
router = APIRouter(tags=["mcp"])
|
||||
|
||||
SUPPORTED_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
||||
MAX_RESULT_CHARS = 60_000
|
||||
# Not reachable through the generic reader (credentials management, the MCP endpoint itself).
|
||||
_BLOCKED_PREFIXES = ("/api/tokens", "/api/auth", "/api/mcp")
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "fleet_overview",
|
||||
"description": "Fleet status at a glance: every firewall with online state and CARP role, items that need "
|
||||
"attention (failed or unconfirmed deployments, stuck maintenance) and the latest activity.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"path": "/api/dashboard",
|
||||
},
|
||||
{
|
||||
"name": "list_hosts",
|
||||
"description": "All managed firewalls with name, URL, cluster and whether monitoring is enabled.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"path": "/api/hosts",
|
||||
},
|
||||
{
|
||||
"name": "host_resources",
|
||||
"description": "Recent CPU load, memory and disk usage samples of one firewall (a sample per minute).",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["host_id"],
|
||||
"properties": {
|
||||
"host_id": {"type": "string", "description": "Firewall id from list_hosts"},
|
||||
"since_hours": {"type": "integer", "minimum": 1, "maximum": 720, "default": 1},
|
||||
},
|
||||
},
|
||||
"path": "/api/hosts/{host_id}/resource-samples",
|
||||
"query": ("since_hours",),
|
||||
# The raw per-sample payload (every interface counter) would drown the useful numbers.
|
||||
"drop": ("raw",),
|
||||
},
|
||||
{
|
||||
"name": "carp_events",
|
||||
"description": "History of CARP role changes (MASTER/BACKUP) across the clusters.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"path": "/api/cluster/carp-events",
|
||||
},
|
||||
{
|
||||
"name": "recent_audit",
|
||||
"description": "The audit trail: who changed or deployed what, newest first.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25},
|
||||
"kind": {"type": "string", "description": "e.g. rule, alias, host, user"},
|
||||
"status": {"type": "string", "description": "e.g. accepted, rejected, denied"},
|
||||
"q": {"type": "string", "description": "free-text filter"},
|
||||
},
|
||||
},
|
||||
"path": "/api/audit",
|
||||
"query": ("limit", "kind", "status", "q"),
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search rules, aliases, NAT, VPN, certificates, groups and hosts by text.",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["q"],
|
||||
"properties": {"q": {"type": "string", "description": "at least 2 characters"}},
|
||||
},
|
||||
"path": "/api/search",
|
||||
"query": ("q",),
|
||||
},
|
||||
{
|
||||
"name": "api_get",
|
||||
"description": "Read any other OPNexus API endpoint (GET only), e.g. /api/rules, /api/aliases, /api/logs. "
|
||||
"The schema of all endpoints is at /api/openapi.json.",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["path"],
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Starts with /api/"},
|
||||
"query": {"type": "object", "description": "Query parameters", "additionalProperties": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _tool_list() -> list[dict]:
|
||||
return [{k: v for k, v in tool.items() if k in ("name", "description", "inputSchema")} for tool in TOOLS]
|
||||
|
||||
|
||||
def _text(text: str, is_error: bool = False) -> dict:
|
||||
if len(text) > MAX_RESULT_CHARS:
|
||||
text = text[:MAX_RESULT_CHARS] + f"\n… truncated ({len(text)} characters in total); narrow the request."
|
||||
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
||||
|
||||
|
||||
async def _get(request: Request, path: str, query: dict, drop: tuple = ()) -> dict:
|
||||
if not path.startswith("/api/") or ".." in path or "?" in path or "#" in path or path.startswith(_BLOCKED_PREFIXES):
|
||||
return _text("This path cannot be read through MCP.", True)
|
||||
headers = {k: v for k in ("authorization", "cookie") if (v := request.headers.get(k))}
|
||||
url = path + (("?" + urlencode(query)) if query else "")
|
||||
transport = httpx.ASGITransport(app=request.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://opnexus", headers=headers, timeout=30) as client:
|
||||
response = await client.get(url)
|
||||
body = response.text
|
||||
try:
|
||||
data = response.json()
|
||||
if drop and isinstance(data, list):
|
||||
data = [{k: v for k, v in item.items() if k not in drop} if isinstance(item, dict) else item for item in data]
|
||||
body = json.dumps(data, ensure_ascii=False, indent=1)
|
||||
except ValueError:
|
||||
pass
|
||||
return _text(body, response.status_code >= 400)
|
||||
|
||||
|
||||
async def _call_tool(request: Request, name: str, arguments: dict) -> dict:
|
||||
tool = next((t for t in TOOLS if t["name"] == name), None)
|
||||
if tool is None:
|
||||
return _text(f"Unknown tool: {name}", True)
|
||||
if name == "api_get":
|
||||
query = arguments.get("query") or {}
|
||||
if not isinstance(arguments.get("path"), str) or not isinstance(query, dict):
|
||||
return _text("path (string) is required.", True)
|
||||
return await _get(request, arguments["path"], {str(k): str(v) for k, v in query.items()})
|
||||
path = tool["path"]
|
||||
if "{host_id}" in path:
|
||||
host_id = str(arguments.get("host_id", ""))
|
||||
if not host_id.replace("-", "").isalnum():
|
||||
return _text("host_id is required (see list_hosts).", True)
|
||||
path = path.format(host_id=host_id)
|
||||
query = {key: arguments[key] for key in tool.get("query", ()) if arguments.get(key) not in (None, "")}
|
||||
return await _get(request, path, query, tool.get("drop", ()))
|
||||
|
||||
|
||||
def _error(request_id, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
async def _handle(request: Request, message: dict) -> dict | None:
|
||||
"""The JSON-RPC response for one message, None for a notification."""
|
||||
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or "method" not in message:
|
||||
return _error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request")
|
||||
method, request_id = message["method"], message.get("id")
|
||||
params = message.get("params") or {}
|
||||
if "id" not in message: # notification (e.g. notifications/initialized): nothing to answer
|
||||
return None
|
||||
if method == "initialize":
|
||||
requested = params.get("protocolVersion")
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {
|
||||
"protocolVersion": requested if requested in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0],
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "opnexus", "version": APP_VERSION},
|
||||
"instructions": "Read-only access to an OPNsense fleet managed by OPNexus.",
|
||||
}}
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
||||
if method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_list()}}
|
||||
if method == "tools/call":
|
||||
arguments = params.get("arguments") or {}
|
||||
if not isinstance(params.get("name"), str) or not isinstance(arguments, dict):
|
||||
return _error(request_id, -32602, "name (string) and arguments (object) are required")
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": await _call_tool(request, params["name"], arguments)}
|
||||
return _error(request_id, -32601, f"Method not found: {method}")
|
||||
|
||||
|
||||
@router.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except ValueError:
|
||||
return JSONResponse(_error(None, -32700, "Parse error"), status_code=400)
|
||||
if isinstance(payload, list):
|
||||
replies = [reply for message in payload if (reply := await _handle(request, message)) is not None]
|
||||
return JSONResponse(replies) if replies else Response(status_code=202)
|
||||
reply = await _handle(request, payload)
|
||||
return JSONResponse(reply) if reply is not None else Response(status_code=202)
|
||||
|
||||
|
||||
@router.get("/mcp")
|
||||
async def mcp_no_stream():
|
||||
# No server-initiated messages: tell clients not to expect an SSE stream.
|
||||
return Response(status_code=405, headers={"Allow": "POST"})
|
||||
@@ -0,0 +1,18 @@
|
||||
"""GET /api/metrics — Prometheus text exposition (see services/metrics_service.py).
|
||||
|
||||
Authenticated like everything else; the intended client is a Prometheus job
|
||||
with ``authorization: {credentials: <read token>}``.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services import metrics_service
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
|
||||
@router.get("/metrics", response_class=PlainTextResponse)
|
||||
async def metrics(db: AsyncSession = Depends(get_db)):
|
||||
return PlainTextResponse(await metrics_service.render(db), media_type=metrics_service.CONTENT_TYPE)
|
||||
@@ -0,0 +1,90 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.routers.hosts import _upstream_error
|
||||
from app.schemas.network import (
|
||||
HostNetwork, NetworkChangeExecute, NetworkChangePreview, NetworkChangeRequest, NetworkChangeResult,
|
||||
)
|
||||
from app.services import network_change_service, network_service
|
||||
from app.services.policy_service import policy_lock
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/network", tags=["network"])
|
||||
|
||||
|
||||
@router.get("", response_model=HostNetwork)
|
||||
async def host_network(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
return await network_service.get_host_network(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
async def _host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
@router.post("/changes/preview", response_model=NetworkChangePreview)
|
||||
async def preview_change(host_id: uuid.UUID, payload: NetworkChangeRequest, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
plan = await network_change_service.preview(db, host, payload.kind, payload.action, payload.uuid, payload.fields)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
return {k: v for k, v in plan.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
@router.post("/changes", response_model=NetworkChangeResult)
|
||||
async def execute_change(host_id: uuid.UUID, payload: NetworkChangeExecute, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host_or_404(db, host_id)
|
||||
return await network_change_service.execute(
|
||||
db, host, payload.kind, payload.action, payload.uuid, payload.fields, payload.preview_token, payload.acknowledge)
|
||||
|
||||
|
||||
@router.get("/template/{kind}")
|
||||
async def change_template(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Blank form for one kind, with the choices this firewall accepts for its select fields."""
|
||||
if kind not in network_change_service.FIELDS:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
return await network_change_service.template(host, kind)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
@router.get("/item/{kind}/{item_uuid}")
|
||||
async def current_item(host_id: uuid.UUID, kind: str, item_uuid: str, db: AsyncSession = Depends(get_db)):
|
||||
"""The object's current values on the firewall, for pre-filling an edit form."""
|
||||
if kind not in network_change_service.FIELDS or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", item_uuid):
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
return await network_change_service.item(host, kind, item_uuid)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
@router.get("/objects/{kind}")
|
||||
async def list_objects(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Rows of a generic object kind (tunables, cron jobs, LAGG, ...), reduced to their display columns."""
|
||||
if kind not in network_change_service.OBJECT_COLUMNS:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
return await network_change_service.list_objects(host, kind)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
@@ -0,0 +1,29 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import network_drift_service
|
||||
|
||||
router = APIRouter(prefix="/network", tags=["network"])
|
||||
|
||||
|
||||
@router.get("/drift")
|
||||
async def network_drift(db: AsyncSession = Depends(get_db)):
|
||||
"""Per cluster: differences in the network configuration between its nodes (live read of every node)."""
|
||||
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
|
||||
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
|
||||
for host in hosts:
|
||||
if host.cluster_name:
|
||||
by_cluster[host.cluster_name].append(host)
|
||||
clusters = []
|
||||
for name, members in sorted(by_cluster.items()):
|
||||
if len(members) < 2:
|
||||
continue
|
||||
differences, compared, unreachable = await network_drift_service.compute_cluster(members)
|
||||
clusters.append({"name": name, "hosts": [h.name for h in members], "compared": compared,
|
||||
"unreachable": unreachable, "differences": differences})
|
||||
return {"clusters": clusters}
|
||||
@@ -0,0 +1,143 @@
|
||||
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import _set_session_cookie, client_ip, current_user
|
||||
from app.config import settings
|
||||
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
|
||||
from app.security_middleware import _origin_allowed
|
||||
from app.services import auth_service, notification_secret_store, oidc_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["oidc"])
|
||||
CALLBACK_PATH = "/api/auth/oidc/callback"
|
||||
STATE_COOKIE = "opnexus-oidc-state"
|
||||
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
|
||||
# must not keep a 30-day session.
|
||||
SSO_SESSION_SECONDS = 12 * 3600
|
||||
|
||||
|
||||
def _login_error(code: str) -> RedirectResponse:
|
||||
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
def _printable(value: str) -> str:
|
||||
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
|
||||
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
|
||||
|
||||
|
||||
@router.get("/auth/oidc/config")
|
||||
async def public_config(db: AsyncSession = Depends(get_db)):
|
||||
"""For the login page: is there an SSO button, and what does it say. Nothing else is revealed."""
|
||||
cfg = await oidc_service.get_settings(db)
|
||||
ready = oidc_service.configured(cfg)
|
||||
return {"enabled": ready, "name": cfg.display_name if ready else None}
|
||||
|
||||
|
||||
@router.get("/auth/oidc/start")
|
||||
async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db)):
|
||||
cfg = await oidc_service.get_settings(db)
|
||||
parts = urlsplit(origin)
|
||||
# The redirect URI is built from the browser's own origin (so any hostname the operator uses works), but only
|
||||
# an origin this server is actually reachable at is accepted: no path, no credentials, same host as the request.
|
||||
if (not oidc_service.configured(cfg) or parts.path not in ("", "/") or parts.query or parts.fragment or parts.username
|
||||
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||
except oidc_service.OidcError as exc:
|
||||
logger.warning("OIDC start fehlgeschlagen: %s", exc)
|
||||
return _login_error(exc.code)
|
||||
response = RedirectResponse(url, status_code=302)
|
||||
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
|
||||
# code+state pair is useless without the cookie.
|
||||
response.set_cookie(STATE_COOKIE, state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
async def callback(request: Request, code: str = "", state: str = "", error: str = "", db: AsyncSession = Depends(get_db)):
|
||||
ip = client_ip(request)
|
||||
if auth_service.throttle.retry_after(ip, "oidc") > 0:
|
||||
return _login_error("throttled")
|
||||
cfg = await oidc_service.get_settings(db)
|
||||
if not oidc_service.configured(cfg):
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
if error or not code or not state:
|
||||
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
|
||||
if not secrets.compare_digest(request.cookies.get(STATE_COOKIE, ""), state):
|
||||
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
|
||||
user = await oidc_service.complete(db, cfg, code, state)
|
||||
except oidc_service.OidcError as exc:
|
||||
auth_service.throttle.record_failure(ip, "oidc")
|
||||
logger.warning("OIDC-Anmeldung abgelehnt (%s): %s", exc.code, exc)
|
||||
return _login_error(exc.code)
|
||||
auth_service.throttle.reset(ip, "oidc")
|
||||
await auth_service.finalize_login(db, user, ip)
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
|
||||
)
|
||||
response = RedirectResponse("/dashboard", status_code=302)
|
||||
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
|
||||
return response
|
||||
|
||||
|
||||
def _require_admin(user: AdminUser) -> None:
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
@router.get("/settings/oidc", response_model=OidcSettingsRead)
|
||||
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
return await oidc_service.get_settings(db)
|
||||
|
||||
|
||||
@router.put("/settings/oidc", response_model=OidcSettingsRead)
|
||||
async def update(payload: OidcSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
cfg = await oidc_service.get_settings(db)
|
||||
for field in ("enabled", "display_name", "issuer", "client_id", "scopes", "groups_claim", "admin_group", "operator_group", "read_only_group"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(cfg, field, value.strip() if isinstance(value, str) else value)
|
||||
if payload.clear_client_secret:
|
||||
notification_secret_store.delete_secret(oidc_service.SECRET_NAME)
|
||||
cfg.client_secret_set = False
|
||||
elif payload.client_secret is not None:
|
||||
notification_secret_store.save_secret(oidc_service.SECRET_NAME, payload.client_secret)
|
||||
cfg.client_secret_set = True
|
||||
await db.commit()
|
||||
oidc_service.clear_caches()
|
||||
return cfg
|
||||
|
||||
|
||||
@router.post("/settings/oidc/test")
|
||||
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Fetch the provider's discovery document and signing keys: proves the issuer URL, not the login."""
|
||||
_require_admin(user)
|
||||
cfg = await oidc_service.get_settings(db)
|
||||
if not cfg.issuer:
|
||||
raise api_error(422, ErrorCode.OIDC_TEST_FAILED, reason="Kein Aussteller eingetragen")
|
||||
try:
|
||||
oidc_service.clear_caches()
|
||||
doc = await oidc_service.discovery(cfg.issuer)
|
||||
await oidc_service._jwk_for(doc, None)
|
||||
except oidc_service.OidcError as exc:
|
||||
raise api_error(502, ErrorCode.OIDC_TEST_FAILED, reason=str(exc)) from exc
|
||||
return {"success": True, "authorization_endpoint": doc["authorization_endpoint"]}
|
||||
@@ -168,7 +168,7 @@ async def delete_one_to_one_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(
|
||||
try:
|
||||
await undeploy_one_to_one(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
@@ -268,4 +268,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
return split_rows(await opnsense_client.search_one_to_one_rules(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
@@ -176,7 +176,7 @@ async def delete_instance(instance_id: uuid.UUID, db: AsyncSession = Depends(get
|
||||
try:
|
||||
await undeploy_instance(db, instance)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(instance, attribute_names=["deployments"])
|
||||
openvpn_key_store.delete_secret(instance.id)
|
||||
await db.delete(instance)
|
||||
|
||||
@@ -175,7 +175,7 @@ async def delete_outbound_nat_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
|
||||
try:
|
||||
await undeploy_outbound_nat(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
@@ -275,7 +275,7 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
return split_rows(await opnsense_client.search_outbound_nat_rules(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/mode")
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Management overlay API (see services/overlay_service.py). Writes and the pre-shared key are admin-only."""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.overlay import OverlayPeer
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.overlay import OverlaySettingsRead, OverlaySettingsUpdate, PeerCreate, PeerRead, PeerUpdate
|
||||
from app.services import overlay_service as svc
|
||||
|
||||
router = APIRouter(prefix="/overlay", tags=["overlay"])
|
||||
|
||||
|
||||
def _require_admin(user: AdminUser) -> None:
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
async def _settings_read(db: AsyncSession) -> dict:
|
||||
cfg = await svc.get_settings(db)
|
||||
net = svc.parse_subnet(cfg.subnet)
|
||||
return {
|
||||
"enabled": cfg.enabled, "endpoint": cfg.endpoint, "subnet": cfg.subnet, "listen_port": cfg.listen_port,
|
||||
"hub_public_key": svc.hub_public_key() if cfg.enabled else None, "hub_address": str(svc.hub_address(net)),
|
||||
"hub_state": svc.hub_state(svc.read_status()),
|
||||
}
|
||||
|
||||
|
||||
async def _peers(db: AsyncSession) -> list[dict]:
|
||||
status = svc.read_status()
|
||||
rows = (await db.execute(select(OverlayPeer, OpnsenseHost.name).outerjoin(OpnsenseHost, OverlayPeer.host_id == OpnsenseHost.id).order_by(OverlayPeer.ip))).all()
|
||||
return [
|
||||
{"id": p.id, "name": p.name, "ip": p.ip, "public_key": p.public_key, "host_id": p.host_id, "host_name": host_name,
|
||||
**svc.peer_status(status, p.public_key)}
|
||||
for p, host_name in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def overview(db: AsyncSession = Depends(get_db)):
|
||||
return {"settings": await _settings_read(db), "peers": await _peers(db)}
|
||||
|
||||
|
||||
@router.put("/settings", response_model=OverlaySettingsRead)
|
||||
async def update_settings(payload: OverlaySettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
cfg = await svc.get_settings(db)
|
||||
if payload.subnet is not None and payload.subnet.strip() != cfg.subnet:
|
||||
try:
|
||||
svc.parse_subnet(payload.subnet)
|
||||
except svc.OverlayError as exc:
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
||||
if (await db.scalar(select(OverlayPeer.id).limit(1))) is not None:
|
||||
raise api_error(409, ErrorCode.OVERLAY_SUBNET_LOCKED) # peers already hold addresses in the old network
|
||||
cfg.subnet = payload.subnet.strip()
|
||||
for field in ("enabled", "listen_port"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(cfg, field, value)
|
||||
if payload.endpoint is not None:
|
||||
cfg.endpoint = payload.endpoint.strip() or None
|
||||
if cfg.enabled and not cfg.endpoint:
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Ohne Endpunkt wissen die Firewalls nicht, wohin sie sich verbinden sollen")
|
||||
await db.commit()
|
||||
await svc.sync(db)
|
||||
return await _settings_read(db)
|
||||
|
||||
|
||||
@router.post("/peers", status_code=201)
|
||||
async def create_peer(payload: PeerCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
cfg = await svc.get_settings(db)
|
||||
taken = set((await db.scalars(select(OverlayPeer.ip))).all())
|
||||
try:
|
||||
ip = svc.next_free_ip(svc.parse_subnet(cfg.subnet), taken)
|
||||
except svc.OverlayError as exc:
|
||||
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
||||
peer = OverlayPeer(name=payload.name.strip(), ip=ip)
|
||||
db.add(peer)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise api_error(409, ErrorCode.OVERLAY_NAME_TAKEN) from None
|
||||
await svc.sync(db)
|
||||
return {"id": peer.id, "name": peer.name, "ip": peer.ip, "instructions": svc.instructions(cfg, peer)}
|
||||
|
||||
|
||||
@router.get("/peers/{peer_id}/instructions")
|
||||
async def peer_instructions(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
peer = await db.get(OverlayPeer, peer_id)
|
||||
if peer is None:
|
||||
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||
return svc.instructions(await svc.get_settings(db), peer)
|
||||
|
||||
|
||||
@router.patch("/peers/{peer_id}")
|
||||
async def update_peer(peer_id: uuid.UUID, payload: PeerUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
peer = await db.get(OverlayPeer, peer_id)
|
||||
if peer is None:
|
||||
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||
if payload.public_key is not None:
|
||||
key = payload.public_key.strip()
|
||||
if key and not svc.valid_public_key(key):
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist kein WireGuard-Schlüssel (44 Zeichen, Base64)")
|
||||
if key and key == svc.hub_public_key():
|
||||
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist der Schlüssel des Hubs, nicht der der Firewall")
|
||||
peer.public_key = key or None
|
||||
if payload.clear_host:
|
||||
peer.host_id = None
|
||||
elif payload.host_id is not None:
|
||||
if await db.get(OpnsenseHost, payload.host_id) is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
peer.host_id = payload.host_id
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason="Dieser Schlüssel gehört schon zu einem anderen Eintrag") from None
|
||||
await svc.sync(db)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/peers/{peer_id}", status_code=204)
|
||||
async def delete_peer(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
peer = await db.get(OverlayPeer, peer_id)
|
||||
if peer is None:
|
||||
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||
await db.delete(peer)
|
||||
await db.commit()
|
||||
svc.delete_peer_secret(peer_id)
|
||||
await svc.sync(db)
|
||||
@@ -294,7 +294,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
await undeploy_hierarchy_rule(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
|
||||
@@ -170,7 +170,7 @@ async def delete_port_forward_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
|
||||
try:
|
||||
await undeploy_port_forward(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
@@ -270,4 +270,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
return split_rows(await opnsense_client.search_port_forward_rules(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
@@ -17,6 +17,8 @@ from app.services.alias_service import check_alias_consistency, describe_alias_c
|
||||
from app.services.interface_service import check_interface_consistency
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment
|
||||
from app.services import rule_adopt_service
|
||||
from app.schemas.rule import AdoptRequest, UnmanagedRule
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||
|
||||
@@ -59,6 +61,7 @@ def _to_read(
|
||||
destination_net=rule.destination_net,
|
||||
destination_port=rule.destination_port,
|
||||
enabled=rule.enabled,
|
||||
log=rule.log,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
target_host_ids=[host.id for host in rule.targets],
|
||||
@@ -84,6 +87,42 @@ async def list_rules(db: AsyncSession = Depends(get_db)):
|
||||
return [_to_read(rule, rule.targets, rule.deployments) for rule in rules]
|
||||
|
||||
|
||||
@router.get("/unmanaged", response_model=list[UnmanagedRule])
|
||||
async def list_unmanaged_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""Rules found on the host that OPNexus does not manage yet (live read)."""
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
return await rule_adopt_service.list_unmanaged(db, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
|
||||
|
||||
@router.post("/adopt", response_model=RuleRead, status_code=201)
|
||||
async def adopt_rule(payload: AdoptRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Takes over a hand-made rule. Writes nothing to the firewall."""
|
||||
await policy_lock(db)
|
||||
host = await db.get(OpnsenseHost, payload.host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid)
|
||||
except rule_adopt_service.RuleNotFoundOnHost as exc:
|
||||
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) from exc
|
||||
except rule_adopt_service.NotAdoptable as exc:
|
||||
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
await db.commit()
|
||||
# The new object sits in the session without its relationships loaded; db.get() would return it as-is.
|
||||
rule = (await db.execute(
|
||||
select(FirewallRule).where(FirewallRule.id == rule.id).options(*_load_options())
|
||||
.execution_options(populate_existing=True)
|
||||
)).scalar_one()
|
||||
return _to_read(rule, rule.targets, rule.deployments)
|
||||
|
||||
|
||||
@router.post("", response_model=RuleRead, status_code=201)
|
||||
async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
@@ -101,6 +140,7 @@ async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
|
||||
destination_net=payload.destination_net,
|
||||
destination_port=payload.destination_port,
|
||||
enabled=payload.enabled,
|
||||
log=payload.log,
|
||||
targets=hosts,
|
||||
groups=groups,
|
||||
)
|
||||
@@ -172,7 +212,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
await undeploy_rule(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
|
||||
@@ -11,6 +11,8 @@ from app.errors import ErrorCode, api_error
|
||||
from app.database import get_db
|
||||
from app.models import OpnsenseHost
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.services.host_maintenance_service import peers
|
||||
from app.services.host_status_service import get_statuses
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"])
|
||||
@@ -20,6 +22,8 @@ class Schedule(BaseModel):
|
||||
action: Literal["reboot", "update", "major_update"]
|
||||
run_at: datetime
|
||||
acknowledge_unattended: Literal[True]
|
||||
# Hand the traffic over to the CARP partner first and switch back afterwards (cluster nodes only).
|
||||
carp_handover: bool = False
|
||||
|
||||
|
||||
def _read(sched: ScheduledMaintenance) -> dict:
|
||||
@@ -27,6 +31,7 @@ def _read(sched: ScheduledMaintenance) -> dict:
|
||||
"id": sched.id, "host_id": sched.host_id, "action": sched.action, "run_at": sched.run_at,
|
||||
"status": sched.status, "actor": sched.actor, "created_at": sched.created_at,
|
||||
"result": sched.result, "host_operation_id": sched.host_operation_id,
|
||||
"carp_handover": sched.carp_handover, "phase": sched.phase,
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +66,20 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
|
||||
run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc)
|
||||
if run_at <= now:
|
||||
raise api_error(422, ErrorCode.ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN)
|
||||
if payload.carp_handover:
|
||||
if not host.cluster_name or len(await peers(db, host)) < 2:
|
||||
raise api_error(409, ErrorCode.CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER)
|
||||
elif host.cluster_name:
|
||||
# Without a handover, fire time refuses every cluster node that is not the CARP BACKUP, so a
|
||||
# plan for the current MASTER can only ever end as "failed" at 3 a.m. Say so now.
|
||||
# Only a *known* MASTER is rejected here: an unreachable node or an unclear role
|
||||
# is still judged by the full check when the plan fires.
|
||||
statuses = await get_statuses(await peers(db, host))
|
||||
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
|
||||
raise api_error(409, ErrorCode.GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH)
|
||||
sched = ScheduledMaintenance(
|
||||
host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at,
|
||||
actor=request.state.user.username, created_at=now,
|
||||
actor=request.state.user.username, created_at=now, carp_handover=payload.carp_handover,
|
||||
)
|
||||
db.add(sched)
|
||||
await db.commit()
|
||||
|
||||
@@ -31,6 +31,19 @@ async def _control(host_id: uuid.UUID, name: str, action: str, fn, db: AsyncSess
|
||||
await policy_lock(db)
|
||||
host = await host_or_404(db, host_id)
|
||||
await ensure_no_maintenance(db, [host])
|
||||
# The UI greys out locked services, but the API must not rely on that:
|
||||
# unknown names are accepted by the firewall with "ok", and locked ones
|
||||
# (pf, webgui, routing, ...) can be stopped through it.
|
||||
try:
|
||||
listing = await client.search_services(host)
|
||||
except client.OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
rows = listing.get("rows", []) if isinstance(listing, dict) else []
|
||||
row = next((r for r in rows if name in (r.get("id"), r.get("name"))), None)
|
||||
if row is None:
|
||||
raise api_error(404, ErrorCode.SERVICE_NOT_FOUND, service=name)
|
||||
if row.get("locked") in (1, "1", True):
|
||||
raise api_error(409, ErrorCode.SERVICE_LOCKED, service=name)
|
||||
await audit_service.persist(
|
||||
kind="host", action=action, status="requested",
|
||||
host_id=str(host.id), host_name=host.name,
|
||||
|
||||
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
|
||||
class Preferences(BaseModel):
|
||||
theme: Literal['light','dark','system'] | None = None
|
||||
language: Literal['de','en'] | None = None
|
||||
setup_done: bool | None = None
|
||||
|
||||
@router.get('')
|
||||
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
|
||||
preferences=await db.get(UserPreferences,user.id)
|
||||
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
|
||||
'language':preferences.language if preferences else 'de',
|
||||
'setup_done':preferences.setup_done if preferences else False,
|
||||
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||
'session_lifetime_seconds':settings.session_lifetime}
|
||||
|
||||
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
|
||||
current = await db.get(UserPreferences, user.id)
|
||||
theme = payload.theme or (current.theme if current else 'system')
|
||||
language = payload.language or (current.language if current else 'de')
|
||||
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
|
||||
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
|
||||
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
|
||||
await db.commit()
|
||||
return {'username':username,'theme':theme,'language':language,
|
||||
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
|
||||
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||
'session_lifetime_seconds':settings.session_lifetime}
|
||||
@@ -0,0 +1,55 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.routers.hosts import _upstream_error
|
||||
from app.services import snapshot_service
|
||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/snapshots", tags=["snapshots"])
|
||||
|
||||
|
||||
class SnapshotCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
note: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_snapshots(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host(db, host_id)
|
||||
try:
|
||||
return await snapshot_service.list_snapshots(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_snapshot(host_id: uuid.UUID, payload: SnapshotCreate, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host(db, host_id)
|
||||
await ensure_no_maintenance(db, [host])
|
||||
return await snapshot_service.create(host, payload.name, payload.note)
|
||||
|
||||
|
||||
@router.delete("/{snapshot_uuid}", status_code=204)
|
||||
async def delete_snapshot(host_id: uuid.UUID, snapshot_uuid: str, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host(db, host_id)
|
||||
await ensure_no_maintenance(db, [host])
|
||||
if not snapshot_uuid.replace("-", "").isalnum() or len(snapshot_uuid) > 64:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
await snapshot_service.delete(host, snapshot_uuid)
|
||||
@@ -1,12 +1,160 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services import update_check_service
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.services import update_check_service, updater_client
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/version-check")
|
||||
async def version_check() -> dict:
|
||||
async def version_check(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
||||
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
||||
return await update_check_service.check()
|
||||
return await update_check_service.check(db)
|
||||
|
||||
|
||||
def _admin(user: AdminUser) -> AdminUser:
|
||||
# Der Updater ist Administration der Installation selbst — auch das reine
|
||||
# Lesen (Logs, Rollback-Optionen) bleibt Admins vorbehalten.
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
return user
|
||||
|
||||
|
||||
def _major(version: str | None) -> int | None:
|
||||
try:
|
||||
return int((version or "").split(".")[0])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class UpdateCheckRead(BaseModel):
|
||||
enabled: bool
|
||||
feed_url: str
|
||||
managed_by_server: bool
|
||||
|
||||
|
||||
class UpdateCheckUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
feed_url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
async def _update_check_settings(db: AsyncSession) -> UpdateCheckRead:
|
||||
row = await update_check_service.get_settings(db)
|
||||
managed = update_check_service.managed_by_server()
|
||||
return UpdateCheckRead(
|
||||
enabled=True if managed else row.enabled,
|
||||
feed_url=settings.update_check_url.strip() if managed else row.feed_url,
|
||||
managed_by_server=managed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/update-check", response_model=UpdateCheckRead)
|
||||
async def read_update_check(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Versionsprüfung für „Einstellungen › OPNexus aktualisieren“ (nur Admins)."""
|
||||
_admin(user)
|
||||
return await _update_check_settings(db)
|
||||
|
||||
|
||||
@router.put("/update-check", response_model=UpdateCheckRead)
|
||||
async def update_update_check(payload: UpdateCheckUpdate, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)):
|
||||
_admin(user)
|
||||
if update_check_service.managed_by_server():
|
||||
raise api_error(409, ErrorCode.UPDATE_CHECK_MANAGED_BY_SERVER)
|
||||
row = await update_check_service.get_settings(db)
|
||||
if payload.feed_url is not None:
|
||||
url = payload.feed_url.strip()
|
||||
if not update_check_service.valid_feed_url(url):
|
||||
raise api_error(422, ErrorCode.UPDATE_CHECK_URL_INVALID)
|
||||
row.feed_url = url
|
||||
if payload.enabled is not None:
|
||||
row.enabled = payload.enabled
|
||||
await db.commit()
|
||||
update_check_service.reset_cache()
|
||||
return await _update_check_settings(db)
|
||||
|
||||
|
||||
class UpdateRequest(BaseModel):
|
||||
version: str = Field(min_length=5, max_length=20)
|
||||
|
||||
|
||||
@router.get("/updater")
|
||||
async def updater_status(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
|
||||
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check(db)
|
||||
result: dict = {
|
||||
"available": False, "reason": None, "mode": None,
|
||||
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
|
||||
"release_url": check["release_url"], "feed_enabled": check["enabled"],
|
||||
"feed_error": check["error"], "checked_at": check["checked_at"],
|
||||
"can_update": False, "major_change": False,
|
||||
"job": None, "phases": [], "log": [], "services": {},
|
||||
"rollback_available": False, "rollback_loses_data": False, "rollback_version": None,
|
||||
}
|
||||
try:
|
||||
updater = await updater_client.status()
|
||||
except updater_client.UpdaterUnavailable as exc:
|
||||
result["reason"] = str(exc)
|
||||
# Without the updater the install mode is still known from the image registry setting; the UI uses it
|
||||
# to not offer enabling the updater on an install that builds from source (it could not update anyway).
|
||||
result["mode"] = "release" if settings.opnexus_image_repo.strip() else "local"
|
||||
return result
|
||||
except updater_client.UpdaterRejected:
|
||||
result["reason"] = "unreachable"
|
||||
return result
|
||||
|
||||
mode = updater.get("mode")
|
||||
running = (updater.get("job") or {}).get("state") == "running"
|
||||
result.update(
|
||||
available=True, mode=mode, job=updater.get("job"), phases=updater.get("phases") or [],
|
||||
log=updater.get("log") or [], services=updater.get("services") or {},
|
||||
rollback_available=bool(updater.get("rollback_available")),
|
||||
rollback_loses_data=bool(updater.get("rollback_loses_data")),
|
||||
rollback_version=((updater.get("last_update") or {}).get("prev_version")),
|
||||
)
|
||||
# Hauptversionssprünge nur bewusst über die Kommandozeile (Release-Notes lesen).
|
||||
result["major_change"] = bool(check["latest"]) and _major(check["latest"]) != _major(check["current"])
|
||||
result["can_update"] = (
|
||||
mode == "release" and not running and bool(check["update_available"]) and not result["major_change"]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/updater/update", status_code=202)
|
||||
async def updater_update(payload: UpdateRequest, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
|
||||
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check(db)
|
||||
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
|
||||
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
||||
if _major(check["latest"]) != _major(check["current"]):
|
||||
raise api_error(409, ErrorCode.UPDATE_MAJOR_CHANGE)
|
||||
return await _forward(updater_client.start_update(check["latest"]))
|
||||
|
||||
|
||||
@router.post("/updater/rollback", status_code=202)
|
||||
async def updater_rollback(user: AdminUser = Depends(current_user)) -> dict:
|
||||
_admin(user)
|
||||
return await _forward(updater_client.start_rollback())
|
||||
|
||||
|
||||
async def _forward(call) -> dict:
|
||||
try:
|
||||
return await call
|
||||
except updater_client.UpdaterUnavailable:
|
||||
raise api_error(503, ErrorCode.UPDATER_UNAVAILABLE)
|
||||
except updater_client.UpdaterRejected as rejected:
|
||||
if rejected.code == "busy":
|
||||
raise api_error(409, ErrorCode.UPDATER_BUSY)
|
||||
raise api_error(409, ErrorCode.UPDATER_REJECTED, reason=rejected.code)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Manage personal API tokens (see services/api_token_service.py for scopes).
|
||||
|
||||
Session-only: an API token cannot reach these endpoints (AuthMiddleware), so a
|
||||
leaked token can never mint a replacement or list/revoke its siblings. A
|
||||
read_only user may manage their own tokens, but only with read scope (an operator up to deploy).
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import _account_failure, _account_success, _check_account_budget, current_user
|
||||
from app.schemas.api_token import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, TokenScope
|
||||
from app.services import api_token_service, auth_service
|
||||
|
||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||
|
||||
|
||||
def _read(token: ApiToken, owner: str) -> dict:
|
||||
return {
|
||||
"id": token.id, "name": token.name, "scope": token.scope, "prefix": token.prefix, "owner": owner,
|
||||
"created_at": token.created_at, "expires_at": token.expires_at,
|
||||
"last_used_at": token.last_used_at, "last_used_ip": token.last_used_ip,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApiTokenRead])
|
||||
async def list_tokens(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Own tokens; an admin sees everybody's (to be able to revoke them)."""
|
||||
query = select(ApiToken, AdminUser.username).join(AdminUser, ApiToken.user_id == AdminUser.id)
|
||||
if user.role != "admin":
|
||||
query = query.where(ApiToken.user_id == user.id)
|
||||
rows = (await db.execute(query.order_by(ApiToken.created_at.desc()))).all()
|
||||
return [_read(token, owner) for token, owner in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED)
|
||||
async def create_token(
|
||||
payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
|
||||
):
|
||||
allowed = {"admin": (TokenScope.read, TokenScope.deploy, TokenScope.admin), "operator": (TokenScope.read, TokenScope.deploy)}
|
||||
if payload.scope not in allowed.get(user.role, (TokenScope.read,)):
|
||||
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_TOO_HIGH)
|
||||
_check_account_budget(user, "create-token")
|
||||
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
|
||||
_account_failure(user, "create-token")
|
||||
raise api_error(401, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
||||
_account_success(user, "create-token")
|
||||
token, raw = await api_token_service.create(
|
||||
db, user, name=payload.name.strip(), scope=payload.scope.value, expires_in_days=payload.expires_in_days
|
||||
)
|
||||
return {**_read(token, user.username), "token": raw}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def revoke_token(
|
||||
token_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
|
||||
):
|
||||
token = await db.get(ApiToken, token_id)
|
||||
if token is None or (token.user_id != user.id and user.role != "admin"):
|
||||
raise api_error(404, ErrorCode.API_TOKEN_NOT_FOUND)
|
||||
await db.delete(token)
|
||||
await db.commit()
|
||||
@@ -9,7 +9,7 @@ from app.database import get_db
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.user import UserCreate, UserPasswordReset, UserRead, UserRoleUpdate
|
||||
from app.services import auth_service, totp_service
|
||||
from app.services import api_token_service, auth_service, totp_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -47,6 +47,8 @@ async def update_role(user_id: uuid.UUID, payload: UserRoleUpdate, db: AsyncSess
|
||||
target = await db.get(AdminUser, user_id)
|
||||
if target is None:
|
||||
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
|
||||
if target.auth_source == "oidc":
|
||||
raise api_error(409, ErrorCode.OIDC_MANAGED_USER) # the identity provider's groups decide, at every login
|
||||
if target.role == "admin" and payload.role.value != "admin" and await _admin_count(db, exclude_id=target.id) == 0:
|
||||
raise api_error(409, ErrorCode.MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN)
|
||||
target.role = payload.role.value
|
||||
@@ -75,10 +77,13 @@ async def reset_password(user_id: uuid.UUID, payload: UserPasswordReset, db: Asy
|
||||
target = await db.get(AdminUser, user_id)
|
||||
if target is None:
|
||||
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
|
||||
if target.auth_source == "oidc":
|
||||
raise api_error(409, ErrorCode.OIDC_MANAGED_USER)
|
||||
target.password_hash = await auth_service.hash_password_async(payload.new_password)
|
||||
# A reset is the answer to a suspected compromise: every session of the
|
||||
# account ends with the old password, atomically with the new hash.
|
||||
await auth_service.revoke_sessions(db, target.id, commit=False)
|
||||
await api_token_service.revoke_all(db, target.id, commit=False)
|
||||
await db.commit()
|
||||
return target
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Site variables of one host: the values for {{NAME}} placeholders, and which placeholders its objects use."""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.alias import FirewallAlias
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_variable import HostVariable
|
||||
from app.models.one_to_one import OneToOneRule
|
||||
from app.models.outbound_nat import OutboundNatRule
|
||||
from app.models.policy import DeviceGroup
|
||||
from app.models.port_forward import PortForwardRule
|
||||
from app.models.rule import FirewallRule
|
||||
from app.services import alias_deploy_service, one_to_one_deploy_service, outbound_nat_deploy_service
|
||||
from app.services import port_forward_deploy_service, rule_deploy_service, variables_service
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
|
||||
from app.services.policy_service import effective_hosts
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/variables", tags=["variables"])
|
||||
|
||||
_KINDS = (
|
||||
("rule", FirewallRule, rule_deploy_service),
|
||||
("alias", FirewallAlias, alias_deploy_service),
|
||||
("port_forward", PortForwardRule, port_forward_deploy_service),
|
||||
("one_to_one", OneToOneRule, one_to_one_deploy_service),
|
||||
("outbound_nat", OutboundNatRule, outbound_nat_deploy_service),
|
||||
)
|
||||
|
||||
|
||||
class VariablesUpdate(BaseModel):
|
||||
variables: dict[str, str] = Field(default_factory=dict, max_length=200)
|
||||
|
||||
|
||||
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
async def _usage(db: AsyncSession, host: OpnsenseHost) -> dict[str, list[dict]]:
|
||||
"""name -> objects (that are deployed to this host) which use it."""
|
||||
used: dict[str, list[dict]] = {}
|
||||
for kind, model, service in _KINDS:
|
||||
entities = (await db.execute(
|
||||
select(model).options(selectinload(model.targets), selectinload(model.groups).selectinload(DeviceGroup.hosts))
|
||||
)).scalars().unique().all()
|
||||
await hydrate_hierarchy_targets(db, entities)
|
||||
for entity in entities:
|
||||
if host.id not in {h.id for h in effective_hosts(entity)}:
|
||||
continue
|
||||
label = getattr(entity, "description", None) or getattr(entity, "name", None) or str(entity.id)
|
||||
for name in variables_service.names_in_payload(service._base_payload(entity)):
|
||||
used.setdefault(name, []).append({"kind": kind, "id": str(entity.id), "label": label})
|
||||
return used
|
||||
|
||||
|
||||
async def _read(db: AsyncSession, host: OpnsenseHost) -> dict:
|
||||
values = variables_service.host_values(host)
|
||||
used = await _usage(db, host)
|
||||
names = sorted(set(values) | set(used))
|
||||
return {"variables": [
|
||||
{"name": n, "value": values.get(n), "used_by": used.get(n, []), "missing": n in used and n not in values}
|
||||
for n in names
|
||||
]}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def read(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
return await _read(db, await _host(db, host_id))
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def replace(host_id: uuid.UUID, payload: VariablesUpdate, db: AsyncSession = Depends(get_db)):
|
||||
"""Replace this host's variables with exactly the given set."""
|
||||
host = await _host(db, host_id)
|
||||
for name, value in payload.variables.items():
|
||||
if not variables_service.NAME_RE.fullmatch(name):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Name „{name}“: Großbuchstaben, Ziffern und _, beginnend mit einem Buchstaben")
|
||||
if not variables_service.VALUE_RE.fullmatch(value):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Wert von {name}: 1–255 Zeichen aus Buchstaben, Ziffern und . : / , @ + - _ Leerzeichen")
|
||||
existing = {v.name: v for v in host.variables}
|
||||
for name in set(existing) - set(payload.variables):
|
||||
host.variables.remove(existing[name])
|
||||
for name, value in payload.variables.items():
|
||||
if name in existing:
|
||||
existing[name].value = value
|
||||
else:
|
||||
host.variables.append(HostVariable(name=name, value=value))
|
||||
await db.commit()
|
||||
await db.refresh(host)
|
||||
return await _read(db, host)
|
||||
@@ -187,7 +187,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
try:
|
||||
await undeploy_tunnel(db, tunnel)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(tunnel, attribute_names=["deployments"])
|
||||
wireguard_key_store.delete_private_key(tunnel.id)
|
||||
wireguard_key_store.delete_psk(tunnel.id)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TokenScope(str, Enum):
|
||||
read = "read"
|
||||
deploy = "deploy"
|
||||
admin = "admin"
|
||||
|
||||
|
||||
class ApiTokenCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
# Least privilege by default.
|
||||
scope: TokenScope = TokenScope.read
|
||||
# None = never expires. The UI defaults to 90 days.
|
||||
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
# Creating a long-lived credential needs the account password again.
|
||||
current_password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class ApiTokenRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
scope: TokenScope
|
||||
prefix: str
|
||||
owner: str
|
||||
created_at: datetime
|
||||
expires_at: datetime | None
|
||||
last_used_at: datetime | None
|
||||
last_used_ip: str | None
|
||||
|
||||
|
||||
class ApiTokenCreated(ApiTokenRead):
|
||||
# The only time the raw token is ever returned.
|
||||
token: str
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AuditExportRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
syslog_enabled: bool
|
||||
syslog_host: str | None
|
||||
syslog_port: int
|
||||
syslog_protocol: Literal["udp", "tcp", "tls"]
|
||||
webhook_enabled: bool
|
||||
webhook_url: str | None
|
||||
webhook_signing_secret_set: bool
|
||||
last_success_at: datetime | None
|
||||
last_error: str | None
|
||||
last_error_at: datetime | None
|
||||
|
||||
|
||||
class AuditExportUpdate(BaseModel):
|
||||
"""Partial update; the signing secret is write-only (set it or clear it)."""
|
||||
|
||||
syslog_enabled: bool | None = None
|
||||
syslog_host: str | None = Field(default=None, max_length=255)
|
||||
syslog_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
syslog_protocol: Literal["udp", "tcp", "tls"] | None = None
|
||||
webhook_enabled: bool | None = None
|
||||
webhook_url: str | None = Field(default=None, max_length=500)
|
||||
webhook_signing_secret: str | None = Field(default=None, min_length=1)
|
||||
clear_webhook_signing_secret: bool = False
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BackupStatusRead(BaseModel):
|
||||
"""What every signed-in user may see: no endpoints, no keys."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
scheduled_enabled: bool
|
||||
interval_hours: int
|
||||
keep_count: int
|
||||
offsite_enabled: bool
|
||||
last_run_at: datetime | None
|
||||
last_offsite_ok_at: datetime | None
|
||||
last_offsite_error: str | None
|
||||
|
||||
|
||||
class BackupSettingsRead(BackupStatusRead):
|
||||
s3_endpoint: str | None
|
||||
s3_region: str
|
||||
s3_bucket: str | None
|
||||
s3_prefix: str
|
||||
s3_access_key: str | None
|
||||
s3_path_style: bool
|
||||
s3_secret_set: bool
|
||||
passphrase_set: bool
|
||||
|
||||
|
||||
class BackupSettingsUpdate(BaseModel):
|
||||
"""Partial update; the S3 secret key and the passphrase are write-only."""
|
||||
|
||||
scheduled_enabled: bool | None = None
|
||||
interval_hours: int | None = Field(default=None, ge=1, le=24 * 30)
|
||||
keep_count: int | None = Field(default=None, ge=3, le=1000)
|
||||
offsite_enabled: bool | None = None
|
||||
s3_endpoint: str | None = Field(default=None, max_length=255)
|
||||
s3_region: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
s3_bucket: str | None = Field(default=None, max_length=255)
|
||||
s3_prefix: str | None = Field(default=None, max_length=255)
|
||||
s3_access_key: str | None = Field(default=None, max_length=255)
|
||||
s3_path_style: bool | None = None
|
||||
s3_secret_key: str | None = Field(default=None, min_length=1, max_length=512)
|
||||
clear_s3_secret: bool = False
|
||||
passphrase: str | None = Field(default=None, min_length=12, max_length=512)
|
||||
@@ -47,6 +47,8 @@ class HostUpdate(BaseModel):
|
||||
recreate is still the way to rotate a host's API key for now."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
# New address of the firewall, e.g. after its management IP was changed.
|
||||
base_url: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
verify_tls: bool | None = None
|
||||
tls_ca_pem: str | None = Field(default=None, max_length=32768)
|
||||
clear_tls_pin: bool = False
|
||||
@@ -56,6 +58,11 @@ class HostUpdate(BaseModel):
|
||||
policy_hierarchy_group_id: uuid.UUID | None = None
|
||||
clear_policy_hierarchy_group_id: bool = False # same ambiguity as clear_cluster_name above
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def _base_url_is_safe(cls, value: str | None) -> str | None:
|
||||
return validate_base_url(value) if value is not None else None
|
||||
|
||||
|
||||
class HostRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -123,6 +130,9 @@ CertStatus = Literal["valid", "expiring_soon", "expired", "unknown"]
|
||||
|
||||
class HostCertificate(BaseModel):
|
||||
uuid: str
|
||||
# OPNsense's own cross-references (OpenVPN cert/ca, Trust-Store caref) use
|
||||
# this id, not the row UUID.
|
||||
refid: str = ""
|
||||
descr: str
|
||||
common_name: str | None
|
||||
issuer: str
|
||||
@@ -135,6 +145,7 @@ class HostCertificate(BaseModel):
|
||||
|
||||
class HostCa(BaseModel):
|
||||
uuid: str
|
||||
refid: str = ""
|
||||
descr: str
|
||||
common_name: str | None
|
||||
valid_from: datetime | None
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NetAddress(BaseModel):
|
||||
address: str # CIDR, e.g. 192.168.1.2/24
|
||||
# Set for CARP addresses only
|
||||
vhid: str | None = None
|
||||
carp_status: str | None = None
|
||||
|
||||
|
||||
class NetInterface(BaseModel):
|
||||
device: str
|
||||
identifier: str | None # OPNsense's own name (wan, lan, opt3) — None when unassigned
|
||||
description: str
|
||||
status: str
|
||||
enabled: bool
|
||||
is_physical: bool
|
||||
mtu: str | None
|
||||
mac: str | None
|
||||
media: str | None
|
||||
vlan_tag: str | None
|
||||
vlan_parent: str | None
|
||||
ipv4_type: str | None # "Static IPv4" / "DHCP" ... (assigned interfaces only)
|
||||
ipv6_type: str | None
|
||||
addresses: list[NetAddress]
|
||||
pending_action: str | None = None
|
||||
# Listed in the firewall's interface assignment (wan, lan, optN) — only those can be edited or removed.
|
||||
assigned: bool = False
|
||||
# This is the interface the firewall answers OPNexus on. Changing it can cut the connection.
|
||||
is_management: bool = False
|
||||
|
||||
|
||||
class NetVlan(BaseModel):
|
||||
uuid: str
|
||||
parent: str
|
||||
tag: str
|
||||
pcp: str | None
|
||||
description: str
|
||||
device: str | None
|
||||
|
||||
|
||||
class NetVip(BaseModel):
|
||||
"""No CARP password here, by design: OPNsense returns it in plain text."""
|
||||
uuid: str
|
||||
interface: str
|
||||
interface_label: str
|
||||
mode: str
|
||||
address: str
|
||||
vhid: str | None
|
||||
advbase: str | None
|
||||
advskew: str | None
|
||||
description: str
|
||||
|
||||
|
||||
class NetGateway(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
description: str
|
||||
interface: str
|
||||
interface_label: str
|
||||
ip_protocol: str
|
||||
address: str
|
||||
is_default: bool
|
||||
disabled: bool
|
||||
priority: str | None
|
||||
status: str
|
||||
|
||||
|
||||
class NetRoute(BaseModel):
|
||||
uuid: str
|
||||
network: str
|
||||
gateway: str
|
||||
description: str
|
||||
disabled: bool
|
||||
|
||||
|
||||
class NetDnsHost(BaseModel):
|
||||
uuid: str
|
||||
hostname: str
|
||||
domain: str
|
||||
record_type: str
|
||||
server: str
|
||||
enabled: bool
|
||||
description: str
|
||||
|
||||
|
||||
class NetDhcpHost(BaseModel):
|
||||
uuid: str
|
||||
host: str
|
||||
domain: str
|
||||
ip: str
|
||||
hwaddr: str
|
||||
lease_time: str
|
||||
description: str
|
||||
|
||||
|
||||
class NetKeaSubnet(BaseModel):
|
||||
uuid: str
|
||||
subnet: str
|
||||
pools: str
|
||||
routers: str
|
||||
dns_servers: str
|
||||
description: str
|
||||
|
||||
|
||||
class NetKeaReservation(BaseModel):
|
||||
uuid: str
|
||||
subnet: str
|
||||
ip_address: str
|
||||
hw_address: str
|
||||
hostname: str
|
||||
description: str
|
||||
|
||||
|
||||
class HostNetwork(BaseModel):
|
||||
host_id: str
|
||||
host_name: str
|
||||
interfaces: list[NetInterface]
|
||||
vlans: list[NetVlan]
|
||||
vips: list[NetVip]
|
||||
gateways: list[NetGateway]
|
||||
routes: list[NetRoute]
|
||||
dns_hosts: list[NetDnsHost] = []
|
||||
dhcp_hosts: list[NetDhcpHost] = []
|
||||
kea_subnets: list[NetKeaSubnet] = []
|
||||
kea_reservations: list[NetKeaReservation] = []
|
||||
management_device: str | None
|
||||
# The firewall's own API can set interface IPs/types (OPNsense >= ~26.7.6). Older 26.7.x
|
||||
# only lists assignments. Detected from the data, not from a version number.
|
||||
can_configure_interfaces: bool
|
||||
# Sections the firewall could not deliver (e.g. older OPNsense without that API) — never silently empty.
|
||||
unavailable: list[str]
|
||||
|
||||
|
||||
class NetworkChangeRequest(BaseModel):
|
||||
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation",
|
||||
"tunable", "cron_job", "syslog_destination", "gateway_group", "lagg", "bridge", "loopback", "gre", "gif", "vxlan", "dns_forward", "dhcp_range"]
|
||||
action: Literal["add", "set", "del"]
|
||||
# Goes into a URL path, so only plain identifiers.
|
||||
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
|
||||
fields: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NetworkChangeExecute(NetworkChangeRequest):
|
||||
preview_token: str = Field(min_length=64, max_length=64)
|
||||
acknowledge: bool = False
|
||||
|
||||
|
||||
class NetworkChangeRisk(BaseModel):
|
||||
code: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NetworkFieldChange(BaseModel):
|
||||
field: str
|
||||
before: str | None
|
||||
after: str | None
|
||||
|
||||
|
||||
class NetworkChangePreview(BaseModel):
|
||||
token: str
|
||||
kind: str
|
||||
action: str
|
||||
uuid: str | None
|
||||
host_id: str
|
||||
host_name: str
|
||||
changes: list[NetworkFieldChange]
|
||||
risks: list[NetworkChangeRisk]
|
||||
warnings: list[NetworkChangeRisk]
|
||||
blocks: list[NetworkChangeRisk]
|
||||
requires_acknowledgement: bool
|
||||
management_device: str | None
|
||||
can_execute: bool
|
||||
|
||||
|
||||
class NetworkChangeResult(BaseModel):
|
||||
status: str
|
||||
uuid: str | None
|
||||
verified: bool
|
||||
mismatches: list[str]
|
||||
backup_id: str
|
||||
@@ -9,7 +9,11 @@ from app.config import settings
|
||||
SmtpSecurity = Literal["none", "starttls", "ssl"]
|
||||
ProblemType = Literal[
|
||||
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
||||
"carp_split_brain", "nat_drift", "carp_auto_revert_failed",
|
||||
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
|
||||
"scheduled_maintenance_failed",
|
||||
"backup_stale",
|
||||
"backup_offsite_failed",
|
||||
"compliance_finding",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,7 +40,11 @@ class NotificationSettingsRead(BaseModel):
|
||||
maintenance_unknown_enabled: bool
|
||||
carp_health_enabled: bool
|
||||
nat_drift_enabled: bool
|
||||
network_drift_enabled: bool = True
|
||||
carp_auto_revert_failed_enabled: bool
|
||||
scheduled_maintenance_failed_enabled: bool = True
|
||||
backup_failed_enabled: bool = True
|
||||
compliance_alerts_enabled: bool = False
|
||||
|
||||
poll_interval_seconds: int
|
||||
updated_at: datetime
|
||||
@@ -71,7 +79,11 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
maintenance_unknown_enabled: bool | None = None
|
||||
carp_health_enabled: bool | None = None
|
||||
nat_drift_enabled: bool | None = None
|
||||
network_drift_enabled: bool | None = None
|
||||
carp_auto_revert_failed_enabled: bool | None = None
|
||||
scheduled_maintenance_failed_enabled: bool | None = None
|
||||
backup_failed_enabled: bool | None = None
|
||||
compliance_alerts_enabled: bool | None = None
|
||||
|
||||
poll_interval_seconds: int | None = Field(
|
||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class OidcSettingsRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
enabled: bool
|
||||
display_name: str
|
||||
issuer: str | None
|
||||
client_id: str | None
|
||||
client_secret_set: bool
|
||||
scopes: str
|
||||
groups_claim: str
|
||||
admin_group: str | None
|
||||
operator_group: str | None
|
||||
read_only_group: str | None
|
||||
|
||||
|
||||
class OidcSettingsUpdate(BaseModel):
|
||||
"""Partial update; the client secret is write-only (set it or clear it)."""
|
||||
|
||||
enabled: bool | None = None
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
issuer: str | None = Field(default=None, max_length=500)
|
||||
client_id: str | None = Field(default=None, max_length=255)
|
||||
client_secret: str | None = Field(default=None, min_length=1, max_length=512)
|
||||
clear_client_secret: bool = False
|
||||
scopes: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
groups_claim: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
admin_group: str | None = Field(default=None, max_length=255)
|
||||
operator_group: str | None = Field(default=None, max_length=255)
|
||||
read_only_group: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("issuer")
|
||||
@classmethod
|
||||
def https_only(cls, value):
|
||||
if value and not value.startswith("https://"):
|
||||
raise ValueError("issuer must be an https:// URL")
|
||||
return value.strip().rstrip("/") if value else value
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OverlaySettingsRead(BaseModel):
|
||||
enabled: bool
|
||||
endpoint: str | None
|
||||
subnet: str
|
||||
listen_port: int
|
||||
hub_public_key: str | None
|
||||
hub_address: str
|
||||
hub_state: str # running | stale | absent
|
||||
|
||||
|
||||
class OverlaySettingsUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
endpoint: str | None = Field(default=None, max_length=255)
|
||||
subnet: str | None = Field(default=None, max_length=18)
|
||||
listen_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
|
||||
|
||||
class PeerCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class PeerUpdate(BaseModel):
|
||||
public_key: str | None = Field(default=None, max_length=44)
|
||||
host_id: uuid.UUID | None = None
|
||||
clear_host: bool = False
|
||||
|
||||
|
||||
class PeerRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
ip: str
|
||||
public_key: str | None
|
||||
host_id: uuid.UUID | None
|
||||
host_name: str | None
|
||||
connected: bool
|
||||
last_handshake_at: datetime | None
|
||||
rx_bytes: int
|
||||
tx_bytes: int
|
||||
remote_endpoint: str | None
|
||||
@@ -17,6 +17,7 @@ class RuleCreate(BaseModel):
|
||||
destination_net: str = Field(default="any", min_length=1, max_length=128)
|
||||
destination_port: str | None = Field(default=None, max_length=32)
|
||||
enabled: bool = True
|
||||
log: bool = False
|
||||
target_host_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
target_group_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
@@ -56,6 +57,7 @@ class RuleRead(BaseModel):
|
||||
destination_net: str
|
||||
destination_port: str | None
|
||||
enabled: bool
|
||||
log: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
target_host_ids: list[uuid.UUID]
|
||||
@@ -72,3 +74,27 @@ class RuleEdit(RuleCreate):
|
||||
|
||||
class DeployApproval(BaseModel):
|
||||
preview_token: str = Field(min_length=64, max_length=64)
|
||||
|
||||
|
||||
class AdoptRequest(BaseModel):
|
||||
host_id: uuid.UUID
|
||||
rule_uuid: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class UnmanagedRule(BaseModel):
|
||||
uuid: str
|
||||
description: str
|
||||
interface: str
|
||||
interface_label: str
|
||||
direction: str
|
||||
action: str
|
||||
protocol: str
|
||||
source_net: str
|
||||
source_port: str
|
||||
destination_net: str
|
||||
destination_port: str
|
||||
enabled: bool
|
||||
log: bool
|
||||
adoptable: bool
|
||||
# Codes such as "negated_source" or "not_quick"; the UI turns them into text.
|
||||
reasons: list[str]
|
||||
@@ -7,6 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
class UserRole(str, Enum):
|
||||
admin = "admin"
|
||||
# Rolls out and operates (rules, NAT, VPN, maintenance, tokens of its own) but cannot
|
||||
# change who may access OPNexus or where it sends data (users, settings, hosts).
|
||||
operator = "operator"
|
||||
read_only = "read_only"
|
||||
|
||||
|
||||
@@ -36,3 +39,4 @@ class UserRead(BaseModel):
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None
|
||||
totp_enabled: bool = False
|
||||
auth_source: str = "local"
|
||||
@@ -18,7 +18,7 @@ from app.models.host import OpnsenseHost
|
||||
from app.models.alias import FirewallAlias
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services import opnsense_client, variables_service
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
@@ -27,7 +27,12 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _build_payload(alias: FirewallAlias) -> dict:
|
||||
def _build_payload(alias: FirewallAlias, host: OpnsenseHost | None = None) -> dict:
|
||||
payload = _base_payload(alias)
|
||||
return variables_service.resolve(payload, host) if host is not None else payload
|
||||
|
||||
|
||||
def _base_payload(alias: FirewallAlias) -> dict:
|
||||
return {
|
||||
"enabled": "1" if alias.enabled else "0",
|
||||
"name": alias.name,
|
||||
@@ -38,7 +43,7 @@ def _build_payload(alias: FirewallAlias) -> dict:
|
||||
|
||||
|
||||
async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: OpnsenseHost) -> AliasDeployment:
|
||||
payload = _build_payload(alias)
|
||||
payload = _build_payload(alias, host)
|
||||
existing = await db.scalar(
|
||||
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id)
|
||||
)
|
||||
@@ -79,6 +84,7 @@ async def deploy_alias(db: AsyncSession, alias: FirewallAlias, hosts: list[Opnse
|
||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
||||
await ensure_no_maintenance(db, hosts)
|
||||
variables_service.ensure_resolvable(_build_payload, alias, hosts) # before anything is written
|
||||
deployments = [await _deploy_to_host(db, alias, host) for host in hosts]
|
||||
await db.flush()
|
||||
for host, deployment in zip(hosts, deployments):
|
||||
@@ -135,7 +141,7 @@ async def undeploy_alias(db: AsyncSession, alias: FirewallAlias) -> None:
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = str(exc)
|
||||
failures.append(host.name)
|
||||
failures.append(f"{host.name} ({str(exc)[:200]})")
|
||||
continue
|
||||
await db.delete(deployment)
|
||||
await db.flush()
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
|
||||
from app.models.policy import AliasRevision
|
||||
from app.services.alias_deploy_service import _build_payload
|
||||
from app.services import variables_service
|
||||
from app.services.policy_service import effective_hosts
|
||||
|
||||
ALIAS_FIELDS = ("name", "type", "content", "description", "enabled")
|
||||
@@ -28,13 +29,17 @@ def revision(db, alias, note):
|
||||
|
||||
|
||||
def preview(alias):
|
||||
payload = _build_payload(alias)
|
||||
deployments = {d.host_id: d for d in alias.deployments}
|
||||
hosts = effective_hosts(alias)
|
||||
blocked = any(d.status in ("pending", "revert_failed") for d in alias.deployments)
|
||||
rows = []
|
||||
for host in hosts:
|
||||
dep = deployments.get(host.id)
|
||||
try:
|
||||
payload = _build_payload(alias, host)
|
||||
except variables_service.MissingVariables as exc:
|
||||
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||
continue
|
||||
before = dep.last_payload if dep else None
|
||||
action = "create" if not dep or not dep.opnsense_alias_uuid else "update"
|
||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||
@@ -61,6 +66,8 @@ def preview(alias):
|
||||
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
||||
if any(r["action"] == "retained" for r in rows):
|
||||
warnings.append("Frühere Ziele behalten diesen Alias, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
||||
if any(r["action"] == "missing_variable" for r in rows):
|
||||
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||
if any(r["action"] == "disabled" for r in rows):
|
||||
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
||||
if any(d.status == "error" for d in alias.deployments):
|
||||
@@ -71,7 +78,7 @@ def preview(alias):
|
||||
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in alias.deployments], key=lambda row: row[0])}
|
||||
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
||||
return {"token": token, "hosts": rows, "warnings": warnings,
|
||||
"can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows),
|
||||
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||
|
||||
|
||||
@@ -79,12 +86,11 @@ def workflow(alias, deployments):
|
||||
hosts = effective_hosts(alias)
|
||||
active = [h for h in hosts if h.enabled]
|
||||
by_host = {d.host_id: d for d in deployments}
|
||||
payload = _build_payload(alias)
|
||||
target_ids = {h.id for h in hosts}
|
||||
counts = {
|
||||
"targets": len(hosts),
|
||||
"active_targets": len(active),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, alias, h)),
|
||||
"pending": sum(d.status == "pending" for d in deployments),
|
||||
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
||||
"retained": sum(d.host_id not in target_ids for d in deployments),
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Personal API tokens: creation, lookup and scope rules.
|
||||
|
||||
Scopes (a token can never do more than its owner's role allows; a read_only
|
||||
user's token is read-only whatever its scope says):
|
||||
|
||||
* ``read`` — GET/HEAD/OPTIONS only, except endpoints that return secrets (raw
|
||||
config backups, notification settings): those need ``admin``. For Prometheus,
|
||||
dashboards, MCP clients.
|
||||
* ``deploy`` — additionally every write that rolls out or operates on the fleet
|
||||
(rules, aliases, NAT, VPN, maintenance, backups...). NOT the writes that
|
||||
change who can access OPNexus or where it sends data: users, settings,
|
||||
notifications, host registration/removal, log settings, OPNexus self-update.
|
||||
* ``admin`` — everything the owner may do, except what is session-only.
|
||||
|
||||
Session-only for every scope: managing tokens themselves and the account
|
||||
endpoints (/api/auth/*), so a leaked token can neither mint more tokens nor
|
||||
change a password or second factor.
|
||||
|
||||
The enforcement lives in AuthMiddleware (one place, like the read_only role).
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.user import AdminUser
|
||||
|
||||
TOKEN_PREFIX = "opx_"
|
||||
SCOPES = ("read", "deploy", "admin")
|
||||
MAX_TOKENS_PER_USER = 50
|
||||
# last_used_at is only rewritten when older than this, so a scraper hitting
|
||||
# /api/metrics every few seconds does not turn into a write per request.
|
||||
_LAST_USED_GRANULARITY = timedelta(minutes=1)
|
||||
|
||||
_SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
|
||||
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
|
||||
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
|
||||
# notification settings the webhook URL/SMTP data): admin scope only.
|
||||
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)|^/api/settings/|^/api/overlay/peers/[^/]+/instructions$")
|
||||
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
|
||||
_MCP_PATH = "/api/mcp"
|
||||
_ADMIN_ONLY_WRITES = re.compile(
|
||||
r"^/api/(users|settings|system|notifications|overlay)(/|$)"
|
||||
r"|^/api/logs/settings$"
|
||||
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def denies(scope: str, method: str, path: str) -> bool:
|
||||
"""True if a token with this scope must not make this request."""
|
||||
if _SESSION_ONLY.match(path):
|
||||
return True
|
||||
if path == _MCP_PATH:
|
||||
return False
|
||||
if method in _SAFE_METHODS:
|
||||
return scope != "admin" and bool(_ADMIN_SCOPE_READS.match(path))
|
||||
if scope == "read":
|
||||
return True
|
||||
if scope == "deploy" and _ADMIN_ONLY_WRITES.match(path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def operator_denies(method: str, path: str) -> bool:
|
||||
"""True if an `operator` account must not make this request: the writes that
|
||||
change who may access OPNexus or where it sends data. (Their own theme at
|
||||
PUT /api/settings stays open; account endpoints are not matched here.)"""
|
||||
return method not in _SAFE_METHODS and path != "/api/settings" and bool(_ADMIN_ONLY_WRITES.match(path))
|
||||
|
||||
|
||||
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
|
||||
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
|
||||
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
|
||||
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
|
||||
_ROLLOUT_POST = re.compile(
|
||||
r"/(deploy|push|sign|poll|register|reassert-ordering"
|
||||
r"|deployments/[^/]+/(remove|retry-rollback)"
|
||||
r"|maintenance/(execute|carp-execute|schedule)"
|
||||
r"|network/changes"
|
||||
r"|services/[^/]+/(start|stop|restart))$"
|
||||
)
|
||||
_ROLLOUT_DELETE = re.compile(
|
||||
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
|
||||
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
|
||||
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
|
||||
)
|
||||
|
||||
|
||||
def is_rollout(method: str, path: str) -> bool:
|
||||
if method == "POST":
|
||||
return bool(_ROLLOUT_POST.search(path))
|
||||
return method == "DELETE" and bool(_ROLLOUT_DELETE.match(path))
|
||||
|
||||
|
||||
def bearer_from_header(header: str) -> str | None:
|
||||
"""The token of an ``Authorization: Bearer opx_...`` header, else None."""
|
||||
scheme, _, value = header.partition(" ")
|
||||
value = value.strip()
|
||||
if scheme.lower() == "bearer" and value.startswith(TOKEN_PREFIX):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession, user: AdminUser, *, name: str, scope: str, expires_in_days: int | None
|
||||
) -> tuple[ApiToken, str]:
|
||||
count = len((await db.scalars(select(ApiToken.id).where(ApiToken.user_id == user.id))).all())
|
||||
if count >= MAX_TOKENS_PER_USER:
|
||||
raise api_error(409, ErrorCode.API_TOKEN_LIMIT_REACHED, limit=MAX_TOKENS_PER_USER)
|
||||
raw = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
token = ApiToken(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
scope=scope,
|
||||
token_hash=hash_token(raw),
|
||||
prefix=raw[: len(TOKEN_PREFIX) + 6],
|
||||
expires_at=_now() + timedelta(days=expires_in_days) if expires_in_days else None,
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
await db.refresh(token)
|
||||
return token, raw
|
||||
|
||||
|
||||
async def resolve(db: AsyncSession, raw: str, ip: str | None) -> tuple[AdminUser, ApiToken] | None:
|
||||
"""The owner and token for a raw bearer token, or None if unknown/expired."""
|
||||
token = await db.scalar(select(ApiToken).where(ApiToken.token_hash == hash_token(raw)))
|
||||
if token is None or (token.expires_at is not None and token.expires_at <= _now()):
|
||||
return None
|
||||
user = await db.get(AdminUser, token.user_id)
|
||||
if user is None:
|
||||
return None
|
||||
if token.last_used_at is None or _now() - token.last_used_at > _LAST_USED_GRANULARITY:
|
||||
token.last_used_at = _now()
|
||||
token.last_used_ip = ip
|
||||
await db.commit()
|
||||
return user, token
|
||||
|
||||
|
||||
async def revoke_all(db: AsyncSession, user_id, *, commit: bool = True) -> None:
|
||||
"""Delete every token of an account (password reset: answer to a suspected compromise)."""
|
||||
await db.execute(delete(ApiToken).where(ApiToken.user_id == user_id))
|
||||
if commit:
|
||||
await db.commit()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Ships new audit events to syslog (RFC 5424, UDP / TCP / TLS) and/or a webhook.
|
||||
|
||||
A poll loop (main.py) calls ``run_once``: events after the stored cursor, oldest
|
||||
first, in batches. A channel failing leaves the cursor where it is, so the same
|
||||
events are tried again next round (at-least-once; every event carries its id so a
|
||||
receiver can de-duplicate). If both channels are on, both must succeed to advance.
|
||||
|
||||
What is sent: who, what kind/action/status, which entity and host, the message and
|
||||
a correlation id. NOT ``details`` — those hold before/after payloads of firewall
|
||||
objects, which have no business in a log aggregator.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.audit_export import AuditExportSettings
|
||||
from app.services import notification_secret_store
|
||||
from app.version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLETON_ID = 1
|
||||
BATCH = 200
|
||||
SECRET_NAME = "audit_webhook_signing_secret"
|
||||
_FACILITY_LOCAL0 = 16
|
||||
_BAD_STATUSES = {"denied", "rejected", "error", "revert_failed", "failed"}
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> AuditExportSettings:
|
||||
row = await db.get(AuditExportSettings, SINGLETON_ID)
|
||||
if row is None:
|
||||
row = AuditExportSettings(
|
||||
id=SINGLETON_ID, syslog_enabled=False, syslog_port=514, syslog_protocol="udp",
|
||||
webhook_enabled=False, webhook_signing_secret_set=False,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def event_dict(event: AuditEvent) -> dict:
|
||||
return {
|
||||
"id": str(event.id), "time": event.created_at.isoformat(), "correlation_id": event.correlation_id,
|
||||
"actor": event.actor, "kind": event.kind, "action": event.action, "status": event.status,
|
||||
"entity_id": event.entity_id, "entity_name": event.entity_name,
|
||||
"host_id": event.host_id, "host_name": event.host_name, "message": event.message,
|
||||
}
|
||||
|
||||
|
||||
def syslog_line(event: dict, hostname: str = "opnexus") -> bytes:
|
||||
"""RFC 5424: <PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID - MSG (JSON)."""
|
||||
severity = 4 if event["status"] in _BAD_STATUSES else 6
|
||||
pri = _FACILITY_LOCAL0 * 8 + severity
|
||||
return f"<{pri}>1 {event['time']} {hostname} opnexus - AUDIT - {json.dumps(event, ensure_ascii=False)}".encode()
|
||||
|
||||
|
||||
def _frame_tcp(lines: list[bytes]) -> bytes:
|
||||
"""RFC 6587 octet counting: '<len> <message>' per message."""
|
||||
return b"".join(str(len(line)).encode() + b" " + line for line in lines)
|
||||
|
||||
|
||||
def _send_syslog_sync(row: AuditExportSettings, lines: list[bytes]) -> None:
|
||||
host, port = row.syslog_host, row.syslog_port
|
||||
if row.syslog_protocol == "udp":
|
||||
with socket.socket(socket.AF_INET6 if ":" in (host or "") else socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
for line in lines:
|
||||
sock.sendto(line[:8000], (host, port))
|
||||
return
|
||||
with socket.create_connection((host, port), timeout=settings.opnsense_timeout) as raw:
|
||||
if row.syslog_protocol == "tls":
|
||||
context = ssl.create_default_context(cafile=settings.smtp_ca_file or None)
|
||||
with context.wrap_socket(raw, server_hostname=host) as tls:
|
||||
tls.sendall(_frame_tcp(lines))
|
||||
else:
|
||||
raw.sendall(_frame_tcp(lines))
|
||||
|
||||
|
||||
async def send_syslog(row: AuditExportSettings, events: list[dict]) -> None:
|
||||
if not row.syslog_host:
|
||||
raise ExportError("Kein Syslog-Ziel konfiguriert")
|
||||
try:
|
||||
await asyncio.to_thread(_send_syslog_sync, row, [syslog_line(e) for e in events])
|
||||
except (OSError, ssl.SSLError) as exc:
|
||||
raise ExportError(f"Syslog-Zustellung fehlgeschlagen ({type(exc).__name__})") from None
|
||||
|
||||
|
||||
async def send_webhook(row: AuditExportSettings, events: list[dict]) -> None:
|
||||
if not row.webhook_url:
|
||||
raise ExportError("Keine Webhook-URL konfiguriert")
|
||||
raw = json.dumps({"source": "opnexus", "version": APP_VERSION, "events": events}, ensure_ascii=False).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if row.webhook_signing_secret_set:
|
||||
secret = notification_secret_store.read_secret(SECRET_NAME)
|
||||
if not secret:
|
||||
raise ExportError("Signaturschlüssel ist konfiguriert, aber nicht lesbar – bitte neu setzen")
|
||||
headers["X-OPNexus-Signature"] = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.opnsense_timeout) as client:
|
||||
(await client.post(row.webhook_url, content=raw, headers=headers)).raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
# The URL often is the credential: never put it in a message.
|
||||
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
|
||||
raise ExportError(f"Webhook-Zustellung fehlgeschlagen ({reason})") from None
|
||||
|
||||
|
||||
def active(row: AuditExportSettings) -> bool:
|
||||
return bool((row.syslog_enabled and row.syslog_host) or (row.webhook_enabled and row.webhook_url))
|
||||
|
||||
|
||||
async def _ship(row: AuditExportSettings, events: list[dict]) -> None:
|
||||
if row.syslog_enabled and row.syslog_host:
|
||||
await send_syslog(row, events)
|
||||
if row.webhook_enabled and row.webhook_url:
|
||||
await send_webhook(row, events)
|
||||
|
||||
|
||||
async def run_once(db: AsyncSession, lag_seconds: float = 3.0) -> int:
|
||||
"""Ship one batch; returns how many events went out (0 when idle or failing).
|
||||
Only events at least ``lag_seconds`` old are shipped: an event is stamped when it
|
||||
is created, a moment before its transaction commits, and the cursor must not
|
||||
jump past one that becomes visible a little later."""
|
||||
row = await get_settings(db)
|
||||
if not active(row):
|
||||
return 0
|
||||
if row.cursor_created_at is None:
|
||||
# First round after enabling: start from now, do not replay history.
|
||||
row.cursor_created_at = datetime.now(timezone.utc)
|
||||
row.cursor_id = None
|
||||
await db.commit()
|
||||
return 0
|
||||
after = AuditEvent.created_at > row.cursor_created_at
|
||||
if row.cursor_id:
|
||||
after = or_(after, and_(AuditEvent.created_at == row.cursor_created_at, AuditEvent.id > uuid.UUID(row.cursor_id)))
|
||||
ripe = AuditEvent.created_at <= datetime.now(timezone.utc) - timedelta(seconds=lag_seconds)
|
||||
# Ordering by (created_at, id) must match the cursor comparison above.
|
||||
events = (await db.scalars(select(AuditEvent).where(after, ripe).order_by(AuditEvent.created_at, AuditEvent.id).limit(BATCH))).all()
|
||||
if not events:
|
||||
return 0
|
||||
try:
|
||||
await _ship(row, [event_dict(e) for e in events])
|
||||
except ExportError as exc:
|
||||
row.last_error, row.last_error_at = str(exc)[:300], datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
logger.warning("Audit-Export: %s", exc)
|
||||
return 0
|
||||
row.cursor_created_at, row.cursor_id = events[-1].created_at, str(events[-1].id)
|
||||
row.last_success_at, row.last_error = datetime.now(timezone.utc), None
|
||||
await db.commit()
|
||||
return len(events)
|
||||
|
||||
|
||||
async def send_test(row: AuditExportSettings) -> None:
|
||||
event = {
|
||||
"id": "00000000-0000-0000-0000-000000000000", "time": datetime.now(timezone.utc).isoformat(),
|
||||
"correlation_id": "test", "actor": "opnexus", "kind": "test", "action": "test", "status": "accepted",
|
||||
"entity_id": None, "entity_name": None, "host_id": None, "host_name": None,
|
||||
"message": "Dies ist ein Test des Audit-Exports von OPNexus.",
|
||||
}
|
||||
if not (row.syslog_enabled or row.webhook_enabled):
|
||||
raise ExportError("Kein Kanal eingeschaltet")
|
||||
await _ship(row, [event])
|
||||
@@ -15,10 +15,27 @@ from app.models.audit import AuditEvent
|
||||
context = ContextVar("audit_context", default=None)
|
||||
|
||||
|
||||
# Column widths of audit_events. entity_id/host_id/action are taken from the
|
||||
# request path BEFORE the router validated it, so a junk segment (300 chars,
|
||||
# or a NUL byte which Postgres text cannot store) used to turn into a 500 or a
|
||||
# dropped connection instead of a plain 404/422 (found by fuzzing, 2026-10-09).
|
||||
_LIMITS = {"correlation_id": 36, "actor": 150, "kind": 32, "action": 40, "status": 32,
|
||||
"entity_id": 36, "entity_name": 255, "host_id": 36, "host_name": 255}
|
||||
|
||||
|
||||
def _clean(key, value):
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
value = value.replace("\x00", "")
|
||||
limit = _LIMITS.get(key)
|
||||
return value[:limit] if limit else value
|
||||
|
||||
|
||||
def make_event(**values):
|
||||
ctx = context.get() or {}
|
||||
return AuditEvent(actor=ctx.get("actor", "system:rollback"),
|
||||
correlation_id=ctx.get("id", str(uuid.uuid4())), **values)
|
||||
values = {k: _clean(k, v) for k, v in values.items()}
|
||||
return AuditEvent(actor=_clean("actor", ctx.get("actor", "system:rollback")),
|
||||
correlation_id=_clean("correlation_id", ctx.get("id", str(uuid.uuid4()))), **values)
|
||||
|
||||
|
||||
async def persist(**values):
|
||||
@@ -71,7 +88,10 @@ def deployment_history(session, flush_context, instances):
|
||||
if entity is not None and action == "deploy":
|
||||
from importlib import import_module
|
||||
builder = import_module(f"app.services.{kind}_deploy_service")._build_payload
|
||||
details["requested_payload"] = builder(entity)
|
||||
try:
|
||||
details["requested_payload"] = builder(entity, host) if host is not None else builder(entity)
|
||||
except Exception: # a missing variable must not break the audit write; record the unresolved intent instead
|
||||
details["requested_payload"] = builder(entity)
|
||||
session.add(make_event(kind=kind, action=action, status="removed" if deleted else dep.status,
|
||||
entity_id=str(entity_id), entity_name=(getattr(entity, "name", None) or getattr(entity, "description", None)),
|
||||
host_id=str(dep.host_id), host_name=host.name if host else None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authentication core: password hashing, server-side sessions, brute-force
|
||||
protection and the initial admin bootstrap.
|
||||
|
||||
Two roles ("admin", "read_only"), no orgs, no API keys — see
|
||||
Two roles ("admin", "read_only"), no orgs; API tokens live in api_token_service — see
|
||||
AdminUser/AuthMiddleware for how a role is enforced. Originally mirrored
|
||||
InfraPanel's own app/services/auth_service.py (single-admin-only); the role
|
||||
column and its enforcement are OPNexus-specific additions on top of that.
|
||||
@@ -236,6 +236,7 @@ async def create_session(
|
||||
user_agent: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
verified_hash: str | None = None,
|
||||
lifetime_seconds: int | None = None,
|
||||
) -> tuple[str, datetime]:
|
||||
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
|
||||
|
||||
@@ -253,7 +254,7 @@ async def create_session(
|
||||
await db.rollback()
|
||||
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
||||
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
|
||||
expires_at = _now() + timedelta(seconds=settings.session_lifetime)
|
||||
expires_at = _now() + timedelta(seconds=lifetime_seconds or settings.session_lifetime)
|
||||
session = Session(
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(token),
|
||||
@@ -367,7 +368,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
|
||||
logger.warning(
|
||||
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
|
||||
" generated password:\n\n %s\n\n"
|
||||
" Log in and change it now. This is shown only once.\n%s",
|
||||
" Log in and change it now. This is shown only once.\n"
|
||||
" Lost it? ./opnexus password (sets and prints a new one)\n%s",
|
||||
"=" * 64, settings.admin_username, password, "=" * 64,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Client-side encryption of config backups before they leave the server.
|
||||
|
||||
File format (``.opxbak``): ``OPXBAK1`` | salt (16) | nonce (12) | AES-256-GCM ciphertext+tag.
|
||||
The key is scrypt(passphrase, salt). The header is authenticated as associated data, so a
|
||||
file cannot be re-labelled. The passphrase never leaves OPNexus' secret store; without it
|
||||
an offsite copy is unreadable, which is the point — and also why it must be kept somewhere
|
||||
else too (see the handbook). Decrypt with ``python -m app.backup_decrypt``.
|
||||
"""
|
||||
import os
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||
|
||||
MAGIC = b"OPXBAK1"
|
||||
_SALT, _NONCE = 16, 12
|
||||
# scrypt cost: ~64 MiB, well above interactive defaults; a backup is encrypted rarely.
|
||||
_N, _R, _P = 2**16, 8, 1
|
||||
|
||||
|
||||
class DecryptError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _key(passphrase: str, salt: bytes) -> bytes:
|
||||
return Scrypt(salt=salt, length=32, n=_N, r=_R, p=_P).derive(passphrase.encode())
|
||||
|
||||
|
||||
def encrypt(plaintext: bytes, passphrase: str) -> bytes:
|
||||
salt, nonce = os.urandom(_SALT), os.urandom(_NONCE)
|
||||
header = MAGIC + salt + nonce
|
||||
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, plaintext, header)
|
||||
|
||||
|
||||
def decrypt(blob: bytes, passphrase: str) -> bytes:
|
||||
header_len = len(MAGIC) + _SALT + _NONCE
|
||||
if len(blob) < header_len + 16 or not blob.startswith(MAGIC):
|
||||
raise DecryptError("Keine OPNexus-Backup-Datei (Kopfzeile fehlt).")
|
||||
header, body = blob[:header_len], blob[header_len:]
|
||||
salt = header[len(MAGIC): len(MAGIC) + _SALT]
|
||||
nonce = header[len(MAGIC) + _SALT:]
|
||||
try:
|
||||
return AESGCM(_key(passphrase, salt)).decrypt(nonce, body, header)
|
||||
except InvalidTag:
|
||||
raise DecryptError("Falsche Passphrase oder beschädigte Datei.") from None
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Readable, redacted diff between two stored config backups of the same host.
|
||||
|
||||
Both files are parsed (defusedxml), the volatile ``revision`` block is dropped, and every leaf that
|
||||
looks like a secret (password, key, token, PSK, community...) or is a long blob (certificates,
|
||||
private keys) is replaced by a short hash. A changed secret therefore still shows up as a change,
|
||||
but its value never leaves the server. Output is a unified diff, capped in size.
|
||||
"""
|
||||
import difflib
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
from xml.etree.ElementTree import Element, indent, tostring
|
||||
|
||||
import defusedxml.ElementTree as safe_ET
|
||||
|
||||
MAX_LINES = 3000
|
||||
MAX_BYTES = 32 * 1024 * 1024
|
||||
_SECRET = re.compile(r"pass|pwd|psk|secret|key|token|prv|hash|community|bindpw|privpass|otp|seed|cred", re.I)
|
||||
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
|
||||
_BLOB = 120
|
||||
|
||||
|
||||
class InvalidConfig(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _redact(root: Element, salt: bytes) -> None:
|
||||
for el in root.iter():
|
||||
if len(el) == 0 and el.text and el.text.strip():
|
||||
text = el.text.strip()
|
||||
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
|
||||
# Salted per diff: both sides of one diff share the salt (so a change shows), but the value cannot be
|
||||
# looked up in a dictionary or compared across hosts/requests.
|
||||
el.text = "‹" + hmac.new(salt, text.encode(), hashlib.sha256).hexdigest()[:12] + "›"
|
||||
|
||||
|
||||
def normalise(xml_bytes: bytes, salt: bytes) -> list[str]:
|
||||
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
|
||||
raise InvalidConfig("leer oder zu groß")
|
||||
try:
|
||||
root = safe_ET.fromstring(xml_bytes)
|
||||
except Exception as exc: # defusedxml raises several parse/forbidden-entity types
|
||||
raise InvalidConfig(type(exc).__name__) from None
|
||||
if root.tag != "opnsense":
|
||||
raise InvalidConfig("keine OPNsense-Konfiguration")
|
||||
for volatile in root.findall("revision"):
|
||||
root.remove(volatile)
|
||||
_redact(root, salt)
|
||||
indent(root, space=" ")
|
||||
return tostring(root, encoding="unicode").splitlines()
|
||||
|
||||
|
||||
def diff(old: bytes, new: bytes) -> dict:
|
||||
salt = os.urandom(16)
|
||||
a, b = normalise(old, salt), normalise(new, salt)
|
||||
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
|
||||
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||||
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||||
return {"identical": not lines, "added": added, "removed": removed, "truncated": len(lines) > MAX_LINES, "lines": lines[:MAX_LINES]}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Scheduled config backups: take, keep the newest N, copy encrypted offsite.
|
||||
|
||||
Called by a poll loop (main.py). Per enabled host a new snapshot is taken when the newest one
|
||||
is older than the interval; the oldest beyond ``keep_count`` are deleted. When an offsite target
|
||||
is set, every local snapshot that is not offsite yet is encrypted (backup_crypto) and uploaded
|
||||
(s3_client); the ledger ``backup_uploads`` remembers what is already there. Nothing is ever
|
||||
deleted offsite: use the bucket's lifecycle rules for that.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.backup_settings import BackupSettings, BackupUpload
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import backup_crypto, backup_store, notification_secret_store
|
||||
from app.services import opnsense_client as client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.s3_client import S3Client, S3Error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLETON_ID = 1
|
||||
S3_SECRET, PASSPHRASE = "backup_s3_secret", "backup_passphrase"
|
||||
MAX_UPLOADS_PER_RUN = 20
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> BackupSettings:
|
||||
row = await db.get(BackupSettings, SINGLETON_ID)
|
||||
if row is None:
|
||||
row = BackupSettings(
|
||||
id=SINGLETON_ID, scheduled_enabled=True, interval_hours=24, keep_count=30, offsite_enabled=False,
|
||||
s3_region="us-east-1", s3_prefix="opnexus", s3_path_style=True, s3_secret_set=False, passphrase_set=False,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def offsite_ready(cfg: BackupSettings) -> bool:
|
||||
return bool(cfg.offsite_enabled and cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and cfg.s3_secret_set and cfg.passphrase_set)
|
||||
|
||||
|
||||
def s3_for(cfg: BackupSettings) -> S3Client:
|
||||
secret = notification_secret_store.read_secret(S3_SECRET)
|
||||
if not (cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and secret):
|
||||
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten)")
|
||||
return S3Client(endpoint=cfg.s3_endpoint, region=cfg.s3_region, bucket=cfg.s3_bucket, access_key=cfg.s3_access_key,
|
||||
secret_key=secret, path_style=cfg.s3_path_style)
|
||||
|
||||
|
||||
def remote_key(cfg: BackupSettings, host: OpnsenseHost, snapshot_id: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", host.name).strip("-") or "host"
|
||||
prefix = cfg.s3_prefix.strip("/")
|
||||
return "/".join(p for p in (prefix, f"{safe}-{str(host.id)[:8]}", snapshot_id.removesuffix(".xml") + ".opxbak") if p)
|
||||
|
||||
|
||||
async def _take(host: OpnsenseHost) -> str:
|
||||
content = await client.download_backup(host)
|
||||
if b"<opnsense" not in content[:4096]:
|
||||
raise OpnsenseAPIError("Antwort ist keine OPNsense-Konfiguration")
|
||||
return backup_store.save_snapshot(host.id, content)
|
||||
|
||||
|
||||
async def _prune(db: AsyncSession, host: OpnsenseHost, keep: int) -> int:
|
||||
old = backup_store.list_snapshots(host.id)[keep:]
|
||||
for snapshot in old:
|
||||
backup_store.delete_snapshot(host.id, snapshot["id"])
|
||||
if old:
|
||||
rows = (await db.scalars(select(BackupUpload).where(BackupUpload.host_id == host.id, BackupUpload.snapshot_id.in_([s["id"] for s in old])))).all()
|
||||
for row in rows:
|
||||
await db.delete(row)
|
||||
return len(old)
|
||||
|
||||
|
||||
async def upload_pending(db: AsyncSession, cfg: BackupSettings, hosts: list[OpnsenseHost]) -> int:
|
||||
"""Encrypt and upload local snapshots that are not offsite yet (newest first, bounded per run)."""
|
||||
passphrase = notification_secret_store.read_secret(PASSPHRASE)
|
||||
if not passphrase:
|
||||
raise S3Error("Passphrase fehlt oder ist nicht lesbar – bitte neu setzen")
|
||||
s3 = s3_for(cfg)
|
||||
done, budget = 0, MAX_UPLOADS_PER_RUN
|
||||
for host in hosts:
|
||||
have = set((await db.scalars(select(BackupUpload.snapshot_id).where(BackupUpload.host_id == host.id))).all())
|
||||
for snapshot in backup_store.list_snapshots(host.id):
|
||||
if snapshot["id"] in have:
|
||||
continue
|
||||
if budget == 0:
|
||||
return done
|
||||
content = backup_store.read_snapshot(host.id, snapshot["id"])
|
||||
if content is None:
|
||||
continue
|
||||
key = remote_key(cfg, host, snapshot["id"])
|
||||
await s3.put(key, backup_crypto.encrypt(content, passphrase))
|
||||
db.add(BackupUpload(host_id=host.id, snapshot_id=snapshot["id"], remote_key=key, uploaded_at=datetime.now(timezone.utc)))
|
||||
await db.commit()
|
||||
budget -= 1
|
||||
done += 1
|
||||
return done
|
||||
|
||||
|
||||
async def run_due(db: AsyncSession, *, force: bool = False) -> dict:
|
||||
cfg = await get_settings(db)
|
||||
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
|
||||
taken = failed = pruned = 0
|
||||
if cfg.scheduled_enabled or force:
|
||||
now = datetime.now(timezone.utc)
|
||||
for host in hosts:
|
||||
snapshots = backup_store.list_snapshots(host.id)
|
||||
if not force and snapshots and now - snapshots[0]["created_at"] < timedelta(hours=cfg.interval_hours):
|
||||
pruned += await _prune(db, host, cfg.keep_count)
|
||||
continue
|
||||
try:
|
||||
await _take(host)
|
||||
taken += 1
|
||||
except OpnsenseAPIError as exc:
|
||||
failed += 1
|
||||
logger.warning("Geplantes Backup von %s fehlgeschlagen: %s", host.name, exc)
|
||||
pruned += await _prune(db, host, cfg.keep_count)
|
||||
cfg.last_run_at = now
|
||||
uploaded = 0
|
||||
if cfg.offsite_enabled:
|
||||
try:
|
||||
if not offsite_ready(cfg):
|
||||
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten, Passphrase)")
|
||||
uploaded = await upload_pending(db, cfg, hosts)
|
||||
cfg.last_offsite_error = None
|
||||
if uploaded or cfg.last_offsite_ok_at is None:
|
||||
cfg.last_offsite_ok_at = datetime.now(timezone.utc)
|
||||
except S3Error as exc:
|
||||
cfg.last_offsite_error = str(exc)[:300]
|
||||
logger.warning("Offsite-Backup: %s", exc)
|
||||
await db.commit()
|
||||
return {"hosts": len(hosts), "taken": taken, "failed": failed, "pruned": pruned, "uploaded": uploaded}
|
||||
@@ -42,7 +42,8 @@ def list_snapshots(host_id: uuid.UUID) -> list[dict]:
|
||||
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
"size": stat.st_size,
|
||||
})
|
||||
return sorted(items, key=lambda i: i["created_at"], reverse=True)
|
||||
# Names are sortable timestamps: they break ties between files written within one mtime tick.
|
||||
return sorted(items, key=lambda i: (i["created_at"], i["id"]), reverse=True)
|
||||
|
||||
|
||||
def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
||||
@@ -53,3 +54,11 @@ def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
||||
if backup_id != path.name or not path.is_file():
|
||||
return None
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def delete_snapshot(host_id: uuid.UUID, backup_id: str) -> bool:
|
||||
path = _host_dir(host_id) / backup_id
|
||||
if backup_id != path.name or not path.is_file():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.carp_maintenance_window import CarpMaintenanceWindow
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.services import audit_service, host_status_service
|
||||
from app.services import opnsense_client as client
|
||||
|
||||
@@ -63,6 +64,26 @@ async def close_windows_for_manual_leave(db: AsyncSession, host: OpnsenseHost) -
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _close_enter_operations(db: AsyncSession, host: OpnsenseHost, message: str) -> None:
|
||||
"""Closes the host's still-open `carp_enter` maintenance record once the
|
||||
window has been resolved by the timer. Without this the record stays
|
||||
"submitted" forever: acknowledge cannot succeed (the host is no longer in
|
||||
maintenance) and `ensure_no_maintenance` keeps blocking every deploy and
|
||||
update in the cluster until someone cancels it by hand."""
|
||||
result = await db.execute(
|
||||
select(HostOperation).where(
|
||||
HostOperation.host_id == host.id, HostOperation.action == "carp_enter", HostOperation.status == "submitted",
|
||||
)
|
||||
)
|
||||
for op in result.scalars().all():
|
||||
op.status = "cancelled"
|
||||
op.message = message
|
||||
await audit_service.persist(
|
||||
kind="host", action="carp_enter", status="cancelled",
|
||||
host_id=str(host.id), host_name=host.name, entity_id=str(op.id), message=message,
|
||||
)
|
||||
|
||||
|
||||
async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
|
||||
result = await db.execute(
|
||||
select(CarpMaintenanceWindow).where(
|
||||
@@ -88,6 +109,7 @@ async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
|
||||
window.status = "cancelled_manual"
|
||||
window.resolved_at = _now()
|
||||
window.message = "Host war beim Ablauf bereits nicht mehr im CARP-Wartungsmodus."
|
||||
await _close_enter_operations(db, host, window.message)
|
||||
resolved.append(window)
|
||||
continue
|
||||
result_payload = await client.toggle_carp_maintenance(host)
|
||||
@@ -96,6 +118,7 @@ async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
|
||||
window.status = "reverted"
|
||||
window.resolved_at = _now()
|
||||
window.message = "Automatisch nach Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet."
|
||||
await _close_enter_operations(db, host, window.message)
|
||||
await audit_service.persist(
|
||||
kind="host", action="carp_auto_revert", status="submitted",
|
||||
host_id=str(host.id), host_name=host.name, entity_id=str(window.id),
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Security posture of a firewall: hardening checks and rule hygiene.
|
||||
|
||||
The checks read the firewall's own config.xml (one read-only download, parsed
|
||||
in memory with defusedxml, never stored) plus two facts OPNexus already knows:
|
||||
whether firmware updates are pending and how old the newest local backup is.
|
||||
The XML layout was verified against real OPNsense 26.7 configs: rules live in
|
||||
``OPNsense/Firewall/Filter/rules/rule`` (MVC) or, on installs not yet migrated,
|
||||
in ``filter/rule`` (legacy). Both are normalised to the same shape below.
|
||||
|
||||
``evaluate`` is pure (bytes in, findings out). A finding is
|
||||
``{id, severity, passed, params}``; ``passed`` is None when a check could not be
|
||||
decided. The score starts at 100 and loses a penalty per failed check, so one
|
||||
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import defusedxml.ElementTree as safe_ET
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import backup_store, rule_hit_service
|
||||
from app.services import opnsense_client as client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
MAX_CONFIG_BYTES = 32 * 1024 * 1024
|
||||
BACKUP_MAX_AGE_DAYS = 7
|
||||
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
|
||||
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
||||
MAX_EXAMPLES = 5
|
||||
# German titles for notifications (the UI translates the check ids itself).
|
||||
TITLES_DE = {
|
||||
"webgui_https": "Web-Oberfläche nur über HTTPS", "ssh_hardened": "SSH abgesichert", "snmp_community": "SNMP-Community nicht „public“",
|
||||
"wan_any_any": "Keine Allow-all-Regel am WAN", "internal_any_any": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
|
||||
"rules_shadowed": "Keine verdeckten Regeln", "rules_duplicate": "Keine doppelten Regeln", "rules_documented": "Regeln sind beschriftet",
|
||||
"rules_unused": "Regeln in Benutzung", "ntp_configured": "Zeitserver gesetzt", "remote_logging": "Logs liegen auch woanders",
|
||||
"ids_enabled": "Einbruchserkennung aktiv", "firmware_current": "Firmware aktuell", "backup_recent": "Aktuelles Backup",
|
||||
}
|
||||
SEVERITIES_DE = {"critical": "kritisch", "high": "hoch", "medium": "mittel", "low": "niedrig", "info": "Hinweis"}
|
||||
|
||||
|
||||
def grade(score: int) -> str:
|
||||
return "good" if score >= 90 else "fair" if score >= 70 else "poor"
|
||||
|
||||
|
||||
def _text(el: Element | None, path: str) -> str | None:
|
||||
node = el.find(path) if el is not None else None
|
||||
if node is None or node.text is None or not node.text.strip():
|
||||
return None
|
||||
return node.text.strip()
|
||||
|
||||
|
||||
# ── Rule normalisation ───────────────────────────────────────────────────
|
||||
|
||||
def _endpoint(node: Element | None) -> tuple[str, bool, str | None]:
|
||||
"""(network, negated, port) of a legacy <source>/<destination> element."""
|
||||
if node is None:
|
||||
return "any", False, None
|
||||
negated = node.find("not") is not None
|
||||
port = _text(node, "port")
|
||||
if node.find("any") is not None:
|
||||
return "any", negated, port
|
||||
for tag in ("network", "address"):
|
||||
value = _text(node, tag)
|
||||
if value:
|
||||
return value, negated, port
|
||||
return "any", negated, port
|
||||
|
||||
|
||||
def _mvc_rule(rule: Element) -> dict:
|
||||
return {
|
||||
"enabled": _text(rule, "enabled") != "0",
|
||||
"action": (_text(rule, "action") or "pass").lower(),
|
||||
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
|
||||
"direction": _text(rule, "direction") or "in",
|
||||
"ipprotocol": _text(rule, "ipprotocol") or "inet",
|
||||
"protocol": (_text(rule, "protocol") or "any").lower(),
|
||||
"src": _text(rule, "source_net") or "any", "src_not": _text(rule, "source_not") == "1",
|
||||
"sport": _text(rule, "source_port"),
|
||||
"dst": _text(rule, "destination_net") or "any", "dst_not": _text(rule, "destination_not") == "1",
|
||||
"dport": _text(rule, "destination_port"),
|
||||
"floating": False,
|
||||
"quick": _text(rule, "quick") != "0",
|
||||
"descr": _text(rule, "description") or "",
|
||||
"order": int(_text(rule, "sequence") or 0),
|
||||
}
|
||||
|
||||
|
||||
def _legacy_rule(rule: Element, index: int) -> dict:
|
||||
src, src_not, sport = _endpoint(rule.find("source"))
|
||||
dst, dst_not, dport = _endpoint(rule.find("destination"))
|
||||
return {
|
||||
"enabled": rule.find("disabled") is None,
|
||||
"action": (_text(rule, "type") or "pass").lower(),
|
||||
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
|
||||
"direction": _text(rule, "direction") or "in",
|
||||
"ipprotocol": _text(rule, "ipprotocol") or "inet",
|
||||
"protocol": (_text(rule, "protocol") or "any").lower(),
|
||||
"src": src, "src_not": src_not, "sport": sport, "dst": dst, "dst_not": dst_not, "dport": dport,
|
||||
"floating": _text(rule, "floating") == "yes",
|
||||
"quick": _text(rule, "quick") != "0",
|
||||
"descr": _text(rule, "descr") or "",
|
||||
"order": index,
|
||||
}
|
||||
|
||||
|
||||
def extract_rules(root: Element) -> list[list[dict]]:
|
||||
"""Rule lists in evaluation order, one per source (MVC, legacy)."""
|
||||
mvc = sorted((_mvc_rule(r) for r in root.findall("OPNsense/Firewall/Filter/rules/rule")), key=lambda r: r["order"])
|
||||
legacy = [_legacy_rule(r, i) for i, r in enumerate(root.findall("filter/rule"))]
|
||||
return [group for group in (mvc, legacy) if group]
|
||||
|
||||
|
||||
def wan_interfaces(root: Element) -> set[str]:
|
||||
"""Interfaces facing the outside: the one named wan*, or any with an upstream gateway."""
|
||||
result = set()
|
||||
for node in root.findall("interfaces/*"):
|
||||
if node.tag.lower().startswith("wan") or _text(node, "gateway"):
|
||||
result.add(node.tag)
|
||||
return result
|
||||
|
||||
|
||||
def _is_any_any(rule: dict) -> bool:
|
||||
return (
|
||||
rule["enabled"] and rule["action"] == "pass" and rule["src"] == "any" and not rule["src_not"]
|
||||
and rule["dst"] == "any" and not rule["dst_not"] and rule["protocol"] == "any"
|
||||
and not rule["sport"] and not rule["dport"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _covers(a: dict, b: dict) -> bool:
|
||||
"""Every packet matched by rule b is already matched by the earlier rule a.
|
||||
Only exact-or-any comparisons, so it never claims coverage it cannot prove."""
|
||||
if not (a["enabled"] and b["enabled"]) or a["floating"] or b["floating"] or not a["quick"]:
|
||||
return False
|
||||
if not a["interfaces"] or not set(b["interfaces"]) <= set(a["interfaces"]):
|
||||
return False
|
||||
if a["direction"] != b["direction"] or a["ipprotocol"] != b["ipprotocol"]:
|
||||
return False
|
||||
if a["protocol"] != "any" and a["protocol"] != b["protocol"]:
|
||||
return False
|
||||
for side in ("src", "dst"):
|
||||
if a[side + "_not"] or b[side + "_not"]:
|
||||
if (a[side], a[side + "_not"]) != (b[side], b[side + "_not"]):
|
||||
return False
|
||||
elif a[side] != "any" and a[side] != b[side]:
|
||||
return False
|
||||
for port in ("sport", "dport"):
|
||||
if a[port] and a[port] != b[port]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _label(rule: dict) -> str:
|
||||
return rule["descr"] or f"{rule['action']} {rule['src']} → {rule['dst']}"
|
||||
|
||||
|
||||
def analyse_rules(groups: list[list[dict]]) -> dict:
|
||||
shadowed, duplicates = [], []
|
||||
for rules in groups:
|
||||
for j, later in enumerate(rules):
|
||||
for earlier in rules[:j]:
|
||||
if _covers(earlier, later):
|
||||
(duplicates if earlier["action"] == later["action"] and _covers(later, earlier) else shadowed).append(_label(later))
|
||||
break
|
||||
return {"shadowed": shadowed, "duplicates": duplicates}
|
||||
|
||||
|
||||
# ── Checks ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _finding(check_id: str, severity: str, passed: bool | None, **params) -> dict:
|
||||
return {"id": check_id, "severity": severity, "passed": passed, "params": params}
|
||||
|
||||
|
||||
def _listing(items: list[str]) -> dict:
|
||||
return {"count": len(items), "examples": items[:MAX_EXAMPLES]}
|
||||
|
||||
|
||||
def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None,
|
||||
unused_rules: dict | None = None) -> list[dict]:
|
||||
if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip():
|
||||
raise ValueError("config.xml missing or too large")
|
||||
root = safe_ET.fromstring(xml_bytes)
|
||||
if root.tag != "opnsense":
|
||||
raise ValueError("not an OPNsense config.xml")
|
||||
findings: list[dict] = []
|
||||
|
||||
# Management access
|
||||
protocol = _text(root, "system/webgui/protocol")
|
||||
findings.append(_finding("webgui_https", "high", protocol == "https", protocol=protocol or "http"))
|
||||
|
||||
ssh = root.find("system/ssh")
|
||||
ssh_enabled = ssh is not None and _text(ssh, "enabled") in ("enabled", "1")
|
||||
root_login = ssh_enabled and _text(ssh, "permitrootlogin") == "1"
|
||||
password_login = ssh_enabled and _text(ssh, "passwordauth") == "1"
|
||||
if root_login and password_login:
|
||||
findings.append(_finding("ssh_hardened", "high", False, reason="root_password"))
|
||||
elif password_login:
|
||||
findings.append(_finding("ssh_hardened", "medium", False, reason="password"))
|
||||
else:
|
||||
findings.append(_finding("ssh_hardened", "high", True))
|
||||
|
||||
community = _text(root, "snmpd/rocommunity")
|
||||
if root.find("snmpd") is not None:
|
||||
findings.append(_finding("snmp_community", "high", (community or "").lower() not in ("", "public")))
|
||||
|
||||
# Rules
|
||||
wans = wan_interfaces(root)
|
||||
groups = extract_rules(root)
|
||||
all_rules = [rule for group in groups for rule in group]
|
||||
wan_open = [_label(r) for r in all_rules if _is_any_any(r) and set(r["interfaces"]) & wans]
|
||||
findings.append(_finding("wan_any_any", "critical", not wan_open, **_listing(wan_open)))
|
||||
inner_open = [_label(r) for r in all_rules if _is_any_any(r) and r["interfaces"] and not set(r["interfaces"]) & wans]
|
||||
findings.append(_finding("internal_any_any", "medium", not inner_open, **_listing(inner_open)))
|
||||
analysis = analyse_rules(groups)
|
||||
findings.append(_finding("rules_shadowed", "medium", not analysis["shadowed"], **_listing(analysis["shadowed"])))
|
||||
findings.append(_finding("rules_duplicate", "low", not analysis["duplicates"], **_listing(analysis["duplicates"])))
|
||||
undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]]
|
||||
findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented)))
|
||||
|
||||
# Usage (from the pf counters OPNexus has been sampling; None = no data yet)
|
||||
if unused_rules is not None:
|
||||
names = unused_rules["unused"]
|
||||
findings.append(_finding("rules_unused", "low", None if (not names and unused_rules["collecting"]) else not names, **_listing(names)))
|
||||
|
||||
# Operations
|
||||
findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers"))))
|
||||
syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"]
|
||||
findings.append(_finding("remote_logging", "low", bool(syslog_targets) or bool(_text(root, "syslog/remoteserver"))))
|
||||
findings.append(_finding("ids_enabled", "info", _text(root, "OPNsense/IDS/general/enabled") == "1"))
|
||||
|
||||
# From what OPNexus already knows
|
||||
findings.append(_finding("firmware_current", "medium", None if firmware_status is None else firmware_status != "update"))
|
||||
if newest_backup_age_s is None:
|
||||
findings.append(_finding("backup_recent", "medium", False, age_days=None))
|
||||
else:
|
||||
age_days = int(newest_backup_age_s // 86400)
|
||||
findings.append(_finding("backup_recent", "medium", age_days < BACKUP_MAX_AGE_DAYS, age_days=age_days))
|
||||
return findings
|
||||
|
||||
|
||||
def score(findings: list[dict]) -> int:
|
||||
return max(0, 100 - sum(PENALTY[f["severity"]] for f in findings if f["passed"] is False))
|
||||
|
||||
|
||||
def counts(findings: list[dict]) -> dict:
|
||||
result = {severity: 0 for severity in SEVERITY_ORDER}
|
||||
for finding in findings:
|
||||
if finding["passed"] is False:
|
||||
result[finding["severity"]] += 1
|
||||
return result
|
||||
|
||||
|
||||
# ── Running it for a host ────────────────────────────────────────────────
|
||||
|
||||
async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
|
||||
"""Download the config, evaluate, store (one row per host, replaced each time)."""
|
||||
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host.id))
|
||||
if row is None:
|
||||
row = ComplianceResult(host_id=host.id)
|
||||
db.add(row)
|
||||
row.checked_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
xml = await client.download_backup(host)
|
||||
try:
|
||||
firmware_status = (await client.get_firmware_status(host)).get("status")
|
||||
except OpnsenseAPIError:
|
||||
firmware_status = None
|
||||
snapshots = backup_store.list_snapshots(host.id)
|
||||
age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None
|
||||
try:
|
||||
await rule_hit_service.sample_host(db, host)
|
||||
except OpnsenseAPIError:
|
||||
pass # usage data is a bonus; the check does not depend on it
|
||||
# Untrusted XML, O(n^2) rule analysis: keep it off the event loop, and treat ANY parse failure as 'invalid'.
|
||||
findings = await asyncio.to_thread(
|
||||
evaluate, xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id),
|
||||
)
|
||||
row.findings, row.score, row.error = findings, score(findings), None
|
||||
except OpnsenseAPIError as exc:
|
||||
row.findings, row.score, row.error = [], None, "unreachable"
|
||||
row.error_detail = str(exc)[:300]
|
||||
except Exception as exc: # ParseError is a SyntaxError, not a ValueError; one bad config must not stop the other hosts
|
||||
row.findings, row.score, row.error = [], None, "invalid_config"
|
||||
row.error_detail = f"{type(exc).__name__}: {exc}"[:300]
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def check_all(db: AsyncSession) -> int:
|
||||
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
|
||||
for host in hosts:
|
||||
await check_host(db, host)
|
||||
return len(hosts)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Network probes run FROM a firewall: ping, traceroute, TCP port probe, DNS lookup.
|
||||
|
||||
Admin-only by construction (every call is a POST, which the read-only role is
|
||||
refused for) and audited with the target, because a firewall that probes
|
||||
arbitrary addresses on request is also a scanning tool. Inputs are checked
|
||||
here before they are sent (the firewall validates them again); results are
|
||||
shown and never stored. Ping runs as a short job that is always stopped and
|
||||
removed again, even when polling fails.
|
||||
"""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import re
|
||||
import time
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import audit_service, opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseDiagnosticRejected
|
||||
|
||||
TARGET = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.:_-]{0,251}[A-Za-z0-9])?$")
|
||||
# At most a few probes at a time per OPNexus process, so the page cannot be used to hammer a firewall.
|
||||
_SLOTS = asyncio.Semaphore(4)
|
||||
|
||||
|
||||
def clean_target(value: str, field: str = "target") -> str:
|
||||
value = (value or "").strip()
|
||||
if not TARGET.match(value):
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
|
||||
return value
|
||||
|
||||
|
||||
def clean_source(value: str | None) -> str:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return str(ipaddress.ip_address(value))
|
||||
except ValueError:
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="source") from None
|
||||
|
||||
|
||||
async def _audit(host: OpnsenseHost, tool: str, target: str, ok: bool, details: dict, message: str | None = None) -> None:
|
||||
await audit_service.persist(kind="diagnostic", action=f"diag.{tool}", status="ok" if ok else "failed",
|
||||
entity_name=target, host_id=str(host.id), host_name=host.name, message=message, details=details)
|
||||
|
||||
|
||||
def _rejected(exc: OpnsenseDiagnosticRejected):
|
||||
field = next(iter(exc.validations), "target").split(".")[-1]
|
||||
return api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
|
||||
|
||||
|
||||
async def _guarded(host: OpnsenseHost, tool: str, target: str, params: dict, call):
|
||||
async with _SLOTS:
|
||||
try:
|
||||
result = await call()
|
||||
except OpnsenseDiagnosticRejected as exc:
|
||||
await _audit(host, tool, target, False, params, "rejected: " + ", ".join(exc.validations))
|
||||
raise _rejected(exc) from exc
|
||||
except OpnsenseAPIError as exc:
|
||||
await _audit(host, tool, target, False, params, str(exc)[:300])
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
await _audit(host, tool, target, True, params)
|
||||
return result
|
||||
|
||||
|
||||
async def traceroute(host: OpnsenseHost, target: str, ipproto: str, protocol: str, source: str | None) -> dict:
|
||||
target, source = clean_target(target), clean_source(source)
|
||||
params = {"target": target, "ipproto": ipproto, "protocol": protocol, "source": source}
|
||||
|
||||
async def call():
|
||||
data = await opnsense_client.diagnostic_set(
|
||||
host, "/diagnostics/traceroute/set", "traceroute",
|
||||
{"hostname": target, "ipproto": ipproto, "protocol": protocol, "source_address": source})
|
||||
resp = data.get("response") or {}
|
||||
hops = [{"ttl": str(r.get("ttl", "")), "address": str(r.get("address", "")), "host": str(r.get("host", "")),
|
||||
"as": str(r.get("AS", "")), "probes": str(r.get("probes", ""))} for r in resp.get("rows", []) if isinstance(r, dict)]
|
||||
return {"target": target, "notice": str(resp.get("notice", "")), "hops": hops}
|
||||
return await _guarded(host, "traceroute", target, params, call)
|
||||
|
||||
|
||||
async def portprobe(host: OpnsenseHost, target: str, port: int, ipproto: str, source: str | None) -> dict:
|
||||
target, source = clean_target(target), clean_source(source)
|
||||
params = {"target": target, "port": port, "ipproto": ipproto, "source": source}
|
||||
|
||||
async def call():
|
||||
data = await opnsense_client.diagnostic_set(
|
||||
host, "/diagnostics/portprobe/set", "portprobe",
|
||||
{"hostname": target, "port": str(port), "ipproto": ipproto, "source_address": source, "source_port": "", "showtext": "0"})
|
||||
message = str((data.get("response") or {}).get("message", ""))
|
||||
# Never forward the remote payload (a service banner): only whether the connection worked.
|
||||
return {"target": target, "port": port, "open": "succeeded" in message.lower(), "message": message}
|
||||
return await _guarded(host, "portprobe", target, params, call)
|
||||
|
||||
|
||||
async def dns_lookup(host: OpnsenseHost, name: str, server: str | None) -> dict:
|
||||
name = clean_target(name, "name")
|
||||
server = clean_source(server)
|
||||
params = {"name": name, "server": server}
|
||||
|
||||
async def call():
|
||||
data = await opnsense_client.diagnostic_set(host, "/diagnostics/dns_diagnostics/set", "dns", {"hostname": name, "server": server})
|
||||
out = {}
|
||||
for rtype, rec in (data.get("response") or {}).items():
|
||||
if isinstance(rec, dict):
|
||||
out[str(rtype)] = {"answers": [str(a) for a in rec.get("answers", [])], "query_time": str(rec.get("query_time", "")),
|
||||
"server": str(rec.get("server", ""))}
|
||||
return {"name": name, "records": out}
|
||||
return await _guarded(host, "dns", name, params, call)
|
||||
|
||||
|
||||
async def ping(host: OpnsenseHost, target: str, ipproto: str, count: int, packetsize: int | None, source: str | None) -> dict:
|
||||
target, source = clean_target(target), clean_source(source)
|
||||
params = {"target": target, "ipproto": ipproto, "count": count, "packetsize": packetsize, "source": source}
|
||||
|
||||
async def call():
|
||||
job = await opnsense_client.ping_job_start(host, {
|
||||
"hostname": target, "fam": "ip6" if ipproto == "inet6" else "ip", "source_address": source,
|
||||
"packetsize": str(packetsize or ""), "disable_frag": "0", "interval": "1", "description": "OPNexus"})
|
||||
try:
|
||||
state = None
|
||||
deadline = time.monotonic() + count + 6
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(1)
|
||||
state = await opnsense_client.ping_job_state(host, job)
|
||||
if state and int(state.get("send") or 0) >= count:
|
||||
break
|
||||
await opnsense_client.ping_job_stop(host, job)
|
||||
await asyncio.sleep(0.5)
|
||||
state = await opnsense_client.ping_job_state(host, job) or state
|
||||
finally:
|
||||
# Never leave a job running on the firewall.
|
||||
try:
|
||||
await opnsense_client.ping_job_stop(host, job)
|
||||
await opnsense_client.ping_job_remove(host, job)
|
||||
except OpnsenseAPIError:
|
||||
pass
|
||||
if not state:
|
||||
raise OpnsenseAPIError("Ping: kein Ergebnis von der Firewall")
|
||||
return {"target": target, "sent": int(state.get("send") or 0), "received": int(state.get("received") or 0),
|
||||
"loss": str(state.get("loss", "")), "min": state.get("min"), "avg": state.get("avg"), "max": state.get("max"),
|
||||
"stddev": state.get("std-dev"), "error": state.get("last_error")}
|
||||
return await _guarded(host, "ping", target, params, call)
|
||||
@@ -59,7 +59,9 @@ async def _shared_change_blockers(db, fleet):
|
||||
return blocked
|
||||
|
||||
|
||||
async def inspect_host(db, host):
|
||||
async def inspect_host(db, host, *, skip_role_checks=False):
|
||||
"""skip_role_checks: for the planned CARP handover, where the node is still MASTER when
|
||||
it is inspected and becomes BACKUP only afterwards (the role is verified separately)."""
|
||||
fleet = await peers(db, host)
|
||||
statuses, info, firmware, running = await asyncio.gather(
|
||||
get_statuses(fleet), client.get_firmware_info(host), client.get_firmware_status(host), client.get_firmware_running(host))
|
||||
@@ -68,7 +70,7 @@ async def inspect_host(db, host):
|
||||
if statuses.get(host.id, {}).get("status") != "online": blocked.append("Host ist nicht erreichbar.")
|
||||
# Fail closed: only the verified ready value permits a disruptive action.
|
||||
if running.get("status") != "ready": blocked.append("Firmware-Auftrag läuft oder Status ist unbekannt.")
|
||||
if host.cluster_name:
|
||||
if host.cluster_name and not skip_role_checks:
|
||||
others = [h for h in fleet if h.id != host.id]
|
||||
if statuses.get(host.id, {}).get("carp_status") != "BACKUP":
|
||||
blocked.append("Cluster-Wartung nur auf dem CARP-BACKUP. Rollenwechsel separat in OPNsense durchführen.")
|
||||
|
||||
@@ -32,6 +32,14 @@ def _extract_carp_status(data: dict | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _vip_states(data: dict | None) -> dict[str, str]:
|
||||
"""Per-VHID CARP state ("MASTER", "BACKUP", "DISABLED", "INIT", ...). The host-level carp_status above
|
||||
collapses these, so one VHID that is down on a node is invisible there."""
|
||||
rows = data.get("rows") if isinstance(data, dict) else None
|
||||
return {str(row.get("vhid")): str(row.get("status", "")).upper()
|
||||
for row in rows or [] if isinstance(row, dict) and row.get("vhid") not in (None, "")}
|
||||
|
||||
|
||||
async def _status_for(host: OpnsenseHost) -> dict:
|
||||
if not host.enabled:
|
||||
return {"status": "disabled", "carp_status": None, "status_detail": None}
|
||||
@@ -43,6 +51,7 @@ async def _status_for(host: OpnsenseHost) -> dict:
|
||||
return {
|
||||
"status": "online",
|
||||
"carp_status": _extract_carp_status(data),
|
||||
"carp_vips": _vip_states(data),
|
||||
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
|
||||
"carp_allowed": _truthy(carp.get("allow"), default=True),
|
||||
"carp_demotion": carp.get("demotion"),
|
||||
|
||||
@@ -254,6 +254,9 @@ async def _deploy_to_host(db: AsyncSession, tunnel: IpsecTunnel, host: OpnsenseH
|
||||
|
||||
try:
|
||||
await _write_config(host, payloads, deployment)
|
||||
# Same trap as WireGuard: with the global IPsec switch off the config
|
||||
# is stored but nothing runs, and reconfigure still answers ok.
|
||||
await opnsense_client.ensure_ipsec_enabled(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = str(exc)
|
||||
@@ -324,7 +327,7 @@ async def undeploy_tunnel(db: AsyncSession, tunnel: IpsecTunnel) -> None:
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = str(exc)
|
||||
failures.append(host.name)
|
||||
failures.append(f"{host.name} ({str(exc)[:200]})")
|
||||
continue
|
||||
await db.delete(deployment)
|
||||
await db.flush()
|
||||
|
||||
@@ -44,6 +44,7 @@ def _row_to_certificate(row: dict) -> HostCertificate:
|
||||
|
||||
return HostCertificate(
|
||||
uuid=row.get("uuid", ""),
|
||||
refid=row.get("refid", ""),
|
||||
descr=row.get("descr") or "(ohne Beschreibung)",
|
||||
common_name=row.get("commonname") or None,
|
||||
issuer=row.get("%caref") or "Selbstsigniert",
|
||||
@@ -68,6 +69,7 @@ def _row_to_ca(row: dict) -> HostCa:
|
||||
|
||||
return HostCa(
|
||||
uuid=row.get("uuid", ""),
|
||||
refid=row.get("refid", ""),
|
||||
descr=row.get("descr") or "(ohne Beschreibung)",
|
||||
common_name=row.get("commonname") or None,
|
||||
valid_from=valid_from,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Prometheus exposition of the fleet state (GET /api/metrics).
|
||||
|
||||
Deliberately database-only, like the dashboard summary: a scrape must never
|
||||
fan out live calls to every firewall (an offline host would stall it past the
|
||||
scraper's timeout and a 15 s scrape interval would hammer the fleet). The
|
||||
inputs are what OPNexus' own pollers already store:
|
||||
|
||||
* host reachability and resources — the 60 s resource sampler (a host that
|
||||
produced no sample for ``STALE_AFTER`` is reported down),
|
||||
* CARP role — the latest observed transition (carp_events),
|
||||
* open problems — the notification engine's problem table,
|
||||
* pending actions — the same list the dashboard shows.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.carp_event import CarpEvent
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.notification import NotificationProblem
|
||||
from app.models.resource_sample import HostResourceSample
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.routers.dashboard import collect_pending_actions
|
||||
from app.services import compliance_service
|
||||
from app.version import APP_VERSION
|
||||
|
||||
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
|
||||
# The sampler runs every 60 s; three missed rounds mean the host is not reachable.
|
||||
STALE_AFTER = timedelta(seconds=180)
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
|
||||
class _Exposition:
|
||||
def __init__(self) -> None:
|
||||
self.lines: list[str] = []
|
||||
|
||||
def family(self, name: str, help_text: str, kind: str = "gauge") -> None:
|
||||
self.lines.append(f"# HELP {name} {help_text}")
|
||||
self.lines.append(f"# TYPE {name} {kind}")
|
||||
|
||||
def sample(self, name: str, value: float, **labels: str) -> None:
|
||||
rendered = ",".join(f'{key}="{_escape(str(val))}"' for key, val in labels.items() if val is not None)
|
||||
number = repr(float(value)) if not float(value).is_integer() else str(int(value))
|
||||
self.lines.append(f"{name}{{{rendered}}} {number}" if rendered else f"{name} {number}")
|
||||
|
||||
def text(self) -> str:
|
||||
return "\n".join(self.lines) + "\n"
|
||||
|
||||
|
||||
async def render(db: AsyncSession) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
out = _Exposition()
|
||||
|
||||
out.family("opnexus_info", "OPNexus version (always 1).")
|
||||
out.sample("opnexus_info", 1, version=APP_VERSION)
|
||||
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
latest_sample = (
|
||||
select(HostResourceSample.host_id, func.max(HostResourceSample.sampled_at).label("at"))
|
||||
.group_by(HostResourceSample.host_id)
|
||||
.subquery()
|
||||
)
|
||||
samples = {
|
||||
sample.host_id: sample
|
||||
for sample in (
|
||||
await db.scalars(
|
||||
select(HostResourceSample).join(
|
||||
latest_sample,
|
||||
(HostResourceSample.host_id == latest_sample.c.host_id)
|
||||
& (HostResourceSample.sampled_at == latest_sample.c.at),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
latest_event = (
|
||||
select(CarpEvent.host_id, func.max(CarpEvent.detected_at).label("at")).group_by(CarpEvent.host_id).subquery()
|
||||
)
|
||||
carp_state = {
|
||||
event.host_id: event.to_status
|
||||
for event in (
|
||||
await db.scalars(
|
||||
select(CarpEvent).join(
|
||||
latest_event, (CarpEvent.host_id == latest_event.c.host_id) & (CarpEvent.detected_at == latest_event.c.at)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
|
||||
out.family("opnexus_hosts", "Number of managed firewalls.")
|
||||
out.sample("opnexus_hosts", len(hosts))
|
||||
out.family("opnexus_host_enabled", "1 if monitoring of the host is enabled.")
|
||||
out.family("opnexus_host_up", "1 if the host answered the resource sampler within the last 3 minutes.")
|
||||
out.family("opnexus_host_last_sample_timestamp_seconds", "Unix time of the last successful resource sample.")
|
||||
out.family("opnexus_host_memory_used_percent", "Memory in use, percent (last sample).")
|
||||
out.family("opnexus_host_load1", "1-minute load average (last sample).")
|
||||
out.family("opnexus_host_disk_used_percent", "Fullest disk, percent (last sample).")
|
||||
out.family("opnexus_host_carp_master", "1 if the host's last observed CARP role is MASTER, 0 if BACKUP; absent if unknown.")
|
||||
for host in hosts:
|
||||
labels = {"host": host.name, "cluster": host.cluster_name or ""}
|
||||
sample = samples.get(host.id)
|
||||
up = bool(host.enabled and sample is not None and now - sample.sampled_at <= STALE_AFTER)
|
||||
out.sample("opnexus_host_enabled", 1 if host.enabled else 0, **labels)
|
||||
out.sample("opnexus_host_up", 1 if up else 0, **labels)
|
||||
if sample is not None:
|
||||
out.sample("opnexus_host_last_sample_timestamp_seconds", sample.sampled_at.timestamp(), **labels)
|
||||
for name, value in (
|
||||
("opnexus_host_memory_used_percent", sample.mem_used_pct),
|
||||
("opnexus_host_load1", sample.load_avg_1m),
|
||||
("opnexus_host_disk_used_percent", sample.disk_used_pct),
|
||||
):
|
||||
if value is not None:
|
||||
out.sample(name, value, **labels)
|
||||
if carp_state.get(host.id) in ("MASTER", "BACKUP"):
|
||||
out.sample("opnexus_host_carp_master", 1 if carp_state[host.id] == "MASTER" else 0, **labels)
|
||||
|
||||
out.family("opnexus_host_compliance_score", "Security posture score 0-100 of the last check (absent if never checked).")
|
||||
out.family("opnexus_host_compliance_findings", "Failed security checks of the last check, by severity.")
|
||||
results = {r.host_id: r for r in (await db.scalars(select(ComplianceResult))).all()}
|
||||
for host in hosts:
|
||||
row = results.get(host.id)
|
||||
if row is None or row.score is None:
|
||||
continue
|
||||
labels = {"host": host.name, "cluster": host.cluster_name or ""}
|
||||
out.sample("opnexus_host_compliance_score", row.score, **labels)
|
||||
for severity, count in compliance_service.counts(row.findings).items():
|
||||
out.sample("opnexus_host_compliance_findings", count, severity=severity, **labels)
|
||||
|
||||
out.family("opnexus_pending_actions", "Deployments and operations that need attention, by kind and status.")
|
||||
counts: dict[tuple[str, str], int] = {}
|
||||
for action in await collect_pending_actions(db):
|
||||
counts[(action.kind, action.status)] = counts.get((action.kind, action.status), 0) + 1
|
||||
for (kind, status), count in sorted(counts.items()):
|
||||
out.sample("opnexus_pending_actions", count, kind=kind, status=status)
|
||||
|
||||
out.family("opnexus_problems_open", "Open problems known to the notification engine, by type.")
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(NotificationProblem.problem_type, func.count())
|
||||
.where(NotificationProblem.status == "open")
|
||||
.group_by(NotificationProblem.problem_type)
|
||||
)
|
||||
).all()
|
||||
for problem_type, count in sorted(rows):
|
||||
out.sample("opnexus_problems_open", count, type=problem_type)
|
||||
|
||||
out.family("opnexus_scheduled_maintenance_pending", "Scheduled maintenance jobs that have not run yet.")
|
||||
pending = await db.scalar(select(func.count()).select_from(ScheduledMaintenance).where(ScheduledMaintenance.status == "pending"))
|
||||
out.sample("opnexus_scheduled_maintenance_pending", pending or 0)
|
||||
|
||||
return out.text()
|
||||
@@ -62,6 +62,10 @@ async def _live_rows_or_none(host: OpnsenseHost, nat_type: str) -> list[dict] |
|
||||
|
||||
|
||||
async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> list[dict]:
|
||||
return (await compute_cluster_drift_with_reachable(hosts, nat_type))[0]
|
||||
|
||||
|
||||
async def compute_cluster_drift_with_reachable(hosts: list[OpnsenseHost], nat_type: str) -> tuple[list[dict], set]:
|
||||
"""Same shape/contract as rule_drift_service.compute_cluster_drift:
|
||||
one row per fingerprint missing from at least one reachable host."""
|
||||
_, fingerprint_fn = _SPECS[nat_type]
|
||||
@@ -78,4 +82,4 @@ async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> lis
|
||||
for entry in by_fingerprint.values():
|
||||
entry["reachable_host_ids"] = reachable_ids
|
||||
|
||||
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)]
|
||||
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)], reachable_ids
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Changes to VLANs, virtual IPs, gateways and static routes of ONE firewall.
|
||||
|
||||
Unlike rules, these objects are specific to a firewall (own IPs, own VLAN
|
||||
layout), so there is no central copy and no multi-host push: the firewall is the
|
||||
source of truth and every change goes straight to it, but only through a
|
||||
preview/execute pair:
|
||||
|
||||
preview reads the live state, computes the diff and the risks, and returns a
|
||||
token that covers exactly that state.
|
||||
execute re-reads the live state, refuses if it no longer matches the token,
|
||||
refuses risky changes without an explicit acknowledgement, saves a
|
||||
fresh config backup, writes the object, applies it, and reads it back
|
||||
to check that the firewall really holds what was asked for.
|
||||
|
||||
The main risk is cutting the connection OPNexus itself uses (the interface or
|
||||
address the firewall is reached on, the default route, a route that swallows the
|
||||
management address). Those cases are detected from the firewall's own
|
||||
interface list and need the acknowledgement. OPNexus cannot undo a change that
|
||||
cuts it off, so there is deliberately NO automatic rollback here — the backup
|
||||
taken before the change is the way back.
|
||||
|
||||
Secrets (the CARP password) are never returned, hashed into the token only, and
|
||||
never written to the audit log.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import audit_service, backup_store, network_service, opnsense_client
|
||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
# Only these fields can be sent; anything else is rejected, never forwarded.
|
||||
FIELDS: dict[str, set[str]] = {
|
||||
"vlan": {"if", "tag", "pcp", "proto", "descr"},
|
||||
"vip": {"interface", "mode", "network", "vhid", "advbase", "advskew", "password", "descr", "nosync", "peer", "peer6"},
|
||||
"gateway": {"disabled", "name", "descr", "interface", "ipprotocol", "gateway", "defaultgw", "fargw",
|
||||
"monitor_disable", "monitor", "priority", "weight"},
|
||||
"route": {"network", "gateway", "descr", "enabled"},
|
||||
"dns_host": {"enabled", "hostname", "domain", "rr", "server", "mx", "mxprio", "addptr", "description"},
|
||||
"dhcp_host": {"host", "domain", "local", "ip", "hwaddr", "client_id", "lease_time", "ignore", "descr"},
|
||||
"kea_subnet": {"subnet", "pools", "valid_lifetime", "next_server", "description", "option_data.routers",
|
||||
"option_data.domain_name_servers", "option_data.domain_name", "option_data.ntp_servers"},
|
||||
"kea_reservation": {"subnet", "ip_address", "hw_address", "client_id", "hostname", "description", "next_server"},
|
||||
"tunable": {"tunable", "value", "descr"},
|
||||
"cron_job": {"enabled", "minutes", "hours", "days", "months", "weekdays", "who", "command", "parameters", "description"},
|
||||
"syslog_destination": {"enabled", "transport", "program", "level", "facility", "hostname", "port", "rfc5424", "description"},
|
||||
"gateway_group": {"name", "item", "item2", "item3", "item4", "item5", "trigger", "poolopts", "descr"},
|
||||
"lagg": {"members", "primary_member", "proto", "lacp_fast_timeout", "use_flowid", "lagghash", "lacp_strict", "mtu", "descr"},
|
||||
"bridge": {"members", "linklocal", "enablestp", "proto", "descr"},
|
||||
"loopback": {"deviceId", "description"},
|
||||
"gre": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
|
||||
"gif": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
|
||||
"vxlan": {"deviceId", "vxlanid", "vxlanlocal", "vxlanlocalport", "vxlanremote", "vxlanremoteport", "vxlangroup", "vxlandev"},
|
||||
"dns_forward": {"enabled", "type", "domain", "server", "port", "verify", "forward_tcp_upstream", "forward_first", "description"},
|
||||
"dhcp_range": {"interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "domain_type", "domain", "description"},
|
||||
"interface": {"if", "descr", "enable", "type4", "ipaddr", "gateway", "type6", "ipaddrv6", "gatewayv6", "mtu",
|
||||
"blockpriv", "blockbogons", "lock"},
|
||||
}
|
||||
SECRET_FIELDS: dict[str, set[str]] = {"vip": {"password"}}
|
||||
# Section of HostNetwork that must be readable for the kind to be managed on this firewall.
|
||||
SECTION = {"vlan": "vlans", "vip": "vips", "gateway": "gateways", "route": "routes", "interface": "assignments",
|
||||
"dns_host": "dns_hosts", "dhcp_host": "dhcp_hosts", "kea_subnet": "kea_subnets",
|
||||
"kea_reservation": "kea_reservations"}
|
||||
# Columns the generic object lists show (the firewall's own field names). Lists never carry fields outside FIELDS.
|
||||
OBJECT_COLUMNS: dict[str, list[str]] = {
|
||||
"tunable": ["tunable", "value", "default_value", "descr"],
|
||||
"cron_job": ["enabled", "minutes", "hours", "days", "months", "weekdays", "command", "description"],
|
||||
"syslog_destination": ["enabled", "transport", "hostname", "port", "program", "level", "description"],
|
||||
"gateway_group": ["name", "item", "item2", "item3", "trigger", "descr"],
|
||||
"lagg": ["laggif", "members", "proto", "descr"],
|
||||
"bridge": ["bridgeif", "members", "proto", "descr"],
|
||||
"loopback": ["deviceId", "description"],
|
||||
"gre": ["greif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
|
||||
"gif": ["gifif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
|
||||
"vxlan": ["deviceId", "vxlanid", "vxlanlocal", "vxlanremote", "vxlangroup", "vxlandev"],
|
||||
"dns_forward": ["enabled", "type", "domain", "server", "port", "description"],
|
||||
"dhcp_range": ["interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "description"],
|
||||
}
|
||||
# These change how the network is wired; the others (DNS/DHCP data) cannot cut the connection to the firewall.
|
||||
WIRING_KINDS = {"vlan", "vip", "gateway", "route", "interface", "lagg", "bridge", "gre", "gif", "vxlan"}
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def validate_fields(kind: str, fields: dict) -> dict[str, str]:
|
||||
allowed = FIELDS[kind]
|
||||
clean: dict[str, str] = {}
|
||||
for key, value in fields.items():
|
||||
if key not in allowed:
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=str(key)[:40])
|
||||
if isinstance(value, bool):
|
||||
value = "1" if value else "0"
|
||||
if value is None:
|
||||
value = ""
|
||||
if not isinstance(value, (str, int)) or isinstance(value, bool):
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
|
||||
text = str(value).strip()
|
||||
if len(text) > 255 or _CONTROL.search(text):
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
|
||||
clean[key] = text
|
||||
return clean
|
||||
|
||||
|
||||
def _explode(node: dict) -> dict:
|
||||
"""Kea nests its DHCP options one level deep; treat them as "option_data.<name>" like the whitelist does."""
|
||||
out: dict = {}
|
||||
for key, value in node.items():
|
||||
if key == "option_data" and isinstance(value, dict):
|
||||
for child, inner in value.items():
|
||||
out[f"option_data.{child}"] = inner
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def flatten(kind: str, node: dict) -> dict[str, str]:
|
||||
"""OPNsense answers select fields as {option: {selected: 0|1}}; reduce to the selected value(s)."""
|
||||
out: dict[str, str] = {}
|
||||
for key, value in _explode(node).items():
|
||||
if key in SECRET_FIELDS.get(kind, set()):
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
selected = [k for k, o in value.items() if isinstance(o, dict) and o.get("selected") and k != ""]
|
||||
if value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
|
||||
out[key] = ",".join(selected)
|
||||
continue
|
||||
out[key] = "" if value is None else str(value)
|
||||
return out
|
||||
|
||||
|
||||
def _ip(text: str):
|
||||
try:
|
||||
return ipaddress.ip_interface(text.strip()).ip
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _network(text: str):
|
||||
try:
|
||||
return ipaddress.ip_network(text.strip(), strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _risk(code: str, **params) -> dict:
|
||||
return {"code": code, "params": params}
|
||||
|
||||
|
||||
def assess(kind: str, action: str, uuid: str | None, before: dict, after: dict,
|
||||
net, mgmt_addresses: set, peers: list[str], context: dict | None = None) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
"""Returns (risks needing acknowledgement, plain warnings, hard blocks)."""
|
||||
risks: list[dict] = []
|
||||
warnings: list[dict] = []
|
||||
blocks: list[dict] = []
|
||||
mgmt_device = net.management_device
|
||||
mgmt_iface = next((i for i in net.interfaces if i.device == mgmt_device), None)
|
||||
mgmt_ident = mgmt_iface.identifier if mgmt_iface else None
|
||||
|
||||
if SECTION.get(kind) in net.unavailable:
|
||||
blocks.append(_risk("api_unavailable", section=SECTION[kind]))
|
||||
if mgmt_device is None and kind in WIRING_KINDS:
|
||||
# We cannot tell what this change could cut, so treat every change to existing objects as risky.
|
||||
if action in ("set", "del"):
|
||||
risks.append(_risk("management_unknown"))
|
||||
|
||||
context = context or {}
|
||||
if kind == "interface":
|
||||
if not net.can_configure_interfaces:
|
||||
blocks.append(_risk("interface_api_unavailable"))
|
||||
if context.get("pending"):
|
||||
blocks.append(_risk("interface_changes_pending"))
|
||||
if action in ("set", "del") and mgmt_ident and uuid == mgmt_ident:
|
||||
risks.append(_risk("management_interface", device=mgmt_device))
|
||||
if action == "del" and before.get("lock") == "1":
|
||||
# OPNsense refuses to remove a locked interface; say so now instead of failing at apply time.
|
||||
blocks.append(_risk("interface_locked"))
|
||||
if action == "del":
|
||||
rules = context.get("rules_using") or []
|
||||
if rules:
|
||||
risks.append(_risk("interface_rules_removed", count=len(rules), names=", ".join(rules[:5])))
|
||||
if action in ("set", "del") and uuid in ("wan", "lan") and uuid != mgmt_ident:
|
||||
warnings.append(_risk("core_interface", name=uuid))
|
||||
|
||||
if kind in ("lagg", "bridge"):
|
||||
members = {m for side in (before, after) for m in side.get("members", "").split(",") if m}
|
||||
if mgmt_device and mgmt_device in members:
|
||||
risks.append(_risk("management_member", device=mgmt_device))
|
||||
elif mgmt_device is None and action in ("set", "del"):
|
||||
pass # already covered by management_unknown above
|
||||
if kind == "tunable":
|
||||
# sysctl values can change how the whole system behaves (and some only apply after a reboot).
|
||||
risks.append(_risk("tunable_change"))
|
||||
if action in ("set", "del") and uuid and "-" not in uuid:
|
||||
# A built-in default (its id is not a UUID): the firewall would create a new entry instead of
|
||||
# changing it, so overriding one is an "add", not an edit.
|
||||
blocks.append(_risk("tunable_default_row"))
|
||||
if kind == "gateway_group" and action == "del":
|
||||
warnings.append(_risk("gateway_group_in_use"))
|
||||
|
||||
if kind == "vlan" and action in ("set", "del"):
|
||||
device = next((v.device for v in net.vlans if v.uuid == uuid), None)
|
||||
if device and device == mgmt_device:
|
||||
risks.append(_risk("management_interface", device=device))
|
||||
|
||||
if kind == "vip":
|
||||
for side in (before, after) if action != "add" else (after,):
|
||||
ip = _ip(side.get("network", "")) if side.get("network") else None
|
||||
if ip is not None and ip in mgmt_addresses:
|
||||
risks.append(_risk("management_address", address=str(ip)))
|
||||
break
|
||||
if "carp" in (before.get("mode"), after.get("mode")):
|
||||
warnings.append(_risk("carp_change"))
|
||||
|
||||
if kind == "gateway":
|
||||
current = next((g for g in net.gateways if g.uuid == uuid), None)
|
||||
if current is not None and action in ("set", "del"):
|
||||
if current.is_default:
|
||||
risks.append(_risk("default_gateway", name=current.name))
|
||||
if mgmt_ident and current.interface == mgmt_ident:
|
||||
risks.append(_risk("management_gateway", name=current.name))
|
||||
if action in ("add", "set") and after.get("defaultgw") == "1" and (current is None or not current.is_default):
|
||||
risks.append(_risk("default_gateway_change"))
|
||||
|
||||
if kind == "route":
|
||||
for side in (before, after) if action != "add" else (after,):
|
||||
network = _network(side.get("network", "")) if side.get("network") else None
|
||||
if network is None:
|
||||
continue
|
||||
if network.prefixlen == 0:
|
||||
risks.append(_risk("default_route", network=str(network)))
|
||||
break
|
||||
if any(a.version == network.version and a in network for a in mgmt_addresses):
|
||||
risks.append(_risk("route_covers_management", network=str(network)))
|
||||
break
|
||||
|
||||
if peers:
|
||||
warnings.append(_risk("cluster_peers", hosts=", ".join(peers)))
|
||||
# De-duplicate while keeping order.
|
||||
seen, unique = set(), []
|
||||
for r in risks:
|
||||
key = json.dumps(r, sort_keys=True)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique, warnings, blocks
|
||||
|
||||
|
||||
def _token(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict, before: dict, mgmt_device) -> str:
|
||||
state = {
|
||||
"host": str(host.id), "url": host.base_url, "kind": kind, "action": action, "uuid": uuid,
|
||||
# hash, so a secret never sits in the token's input in the clear
|
||||
"fields": hashlib.sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest(),
|
||||
"before": before, "mgmt": mgmt_device,
|
||||
}
|
||||
return hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def _public(kind: str, fields: dict) -> dict:
|
||||
return {k: ("(gesetzt)" if k in SECRET_FIELDS.get(kind, set()) and v else v) for k, v in fields.items()
|
||||
if not (k in SECRET_FIELDS.get(kind, set()) and not v)}
|
||||
|
||||
|
||||
async def _peers(db: AsyncSession, host: OpnsenseHost) -> list[str]:
|
||||
if not host.cluster_name:
|
||||
return []
|
||||
rows = await db.scalars(select(OpnsenseHost.name).where(
|
||||
OpnsenseHost.cluster_name == host.cluster_name, OpnsenseHost.id != host.id).order_by(OpnsenseHost.name))
|
||||
return list(rows.all())
|
||||
|
||||
|
||||
async def list_objects(host: OpnsenseHost, kind: str) -> dict:
|
||||
"""Rows of one generic object kind, reduced to the columns above (plus uuid) — never whole rows,
|
||||
so nothing the list does not need (or must not show) leaves the server."""
|
||||
columns = OBJECT_COLUMNS[kind]
|
||||
try:
|
||||
rows = await opnsense_client.search_network_objects(host, kind)
|
||||
except OpnsenseAPIError as exc:
|
||||
if "404" in str(exc):
|
||||
return {"available": False, "columns": columns, "rows": []} # this OPNsense version lacks the API
|
||||
raise
|
||||
out = []
|
||||
for row in rows:
|
||||
item = {"uuid": str(row.get("uuid", ""))}
|
||||
for col in columns:
|
||||
value = row.get(col)
|
||||
item[col] = "" if value is None or isinstance(value, (dict, list)) else str(value)
|
||||
out.append(item)
|
||||
return {"available": True, "columns": columns, "rows": out}
|
||||
|
||||
|
||||
async def _rules_using(host: OpnsenseHost, identifier: str) -> list[str]:
|
||||
"""Descriptions of the user-made rules that name this interface (OPNsense deletes them with it)."""
|
||||
data = await opnsense_client.search_rules(host, all_rows=True)
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [str(r.get("description") or r.get("uuid")) for r in rows
|
||||
if isinstance(r, dict) and not r.get("is_automatic")
|
||||
and identifier in str(r.get("interface") or "").split(",")]
|
||||
|
||||
|
||||
async def preview(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> dict:
|
||||
fields = validate_fields(kind, fields)
|
||||
if action in ("set", "del") and not uuid:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
net = await network_service.get_host_network(host)
|
||||
before: dict[str, str] = {}
|
||||
if action in ("set", "del"):
|
||||
node = await opnsense_client.get_network_item(host, kind, uuid)
|
||||
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
# Only the fields that may be sent: the rest (e.g. Kea's DDNS key secret) never reaches the token or the audit log.
|
||||
before = {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
|
||||
after = {} if action == "del" else ({**before, **fields} if action == "set" else dict(fields))
|
||||
mgmt_addresses = await network_service.management_addresses(host)
|
||||
context: dict = {}
|
||||
if kind == "interface":
|
||||
if net.can_configure_interfaces:
|
||||
context["pending"] = await opnsense_client.interface_changes_pending(host)
|
||||
if action == "del":
|
||||
context["rules_using"] = await _rules_using(host, uuid)
|
||||
risks, warnings, blocks = assess(kind, action, uuid, before, after, net, mgmt_addresses, await _peers(db, host), context)
|
||||
secret = SECRET_FIELDS.get(kind, set())
|
||||
changes = []
|
||||
for key in sorted(set(before) | set(fields)):
|
||||
old, new = before.get(key), fields.get(key) if action != "del" else None
|
||||
if action == "del":
|
||||
if old not in (None, "") and key in FIELDS[kind]:
|
||||
changes.append({"field": key, "before": old, "after": None})
|
||||
elif key in fields and (action == "add" or old != new):
|
||||
if key in secret:
|
||||
changes.append({"field": key, "before": None, "after": "(gesetzt)" if new else None})
|
||||
else:
|
||||
changes.append({"field": key, "before": old, "after": new})
|
||||
token = _token(host, kind, action, uuid, fields, before, net.management_device)
|
||||
return {
|
||||
"token": token, "kind": kind, "action": action, "uuid": uuid, "host_id": str(host.id), "host_name": host.name,
|
||||
"changes": changes, "risks": risks, "warnings": warnings, "blocks": blocks,
|
||||
"requires_acknowledgement": bool(risks), "management_device": net.management_device,
|
||||
"can_execute": not blocks and (action == "del" or bool(changes)),
|
||||
"_fields": fields, "_before": before,
|
||||
}
|
||||
|
||||
|
||||
def _matches(expected: dict[str, str], actual: dict[str, str], secret: set[str]) -> list[str]:
|
||||
return [k for k, v in expected.items() if k not in secret and actual.get(k, "") != v]
|
||||
|
||||
|
||||
async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> tuple[bool, list[str]]:
|
||||
try:
|
||||
if action == "del":
|
||||
present = await opnsense_client.list_network_item_uuids(host, kind)
|
||||
return (uuid not in present), ([] if uuid not in present else ["still_present"])
|
||||
actual = flatten(kind, await opnsense_client.get_network_item(host, kind, uuid))
|
||||
mismatches = _matches(fields, actual, SECRET_FIELDS.get(kind, set()))
|
||||
return (not mismatches), mismatches
|
||||
except OpnsenseAPIError:
|
||||
return False, ["unreadable"]
|
||||
|
||||
|
||||
async def _carp_vip_active(host: OpnsenseHost, vhid: str, attempts: int = 5) -> bool:
|
||||
"""True once the firewall reports the VHID as MASTER or BACKUP, i.e. the VIP really is on the interface and
|
||||
taking part in CARP (not DISABLED/INIT). The config can look right while the live VIP is gone."""
|
||||
for attempt in range(attempts):
|
||||
if attempt:
|
||||
await asyncio.sleep(2)
|
||||
data = await opnsense_client.get_vip_status(host)
|
||||
rows = data.get("rows") if isinstance(data, dict) else None
|
||||
states = {str(r.get("vhid")): str(r.get("status", "")).upper() for r in rows or [] if isinstance(r, dict)}
|
||||
if states.get(vhid) in ("MASTER", "BACKUP"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
|
||||
token: str, acknowledged: bool) -> dict:
|
||||
await ensure_no_maintenance(db, [host])
|
||||
try:
|
||||
plan = await preview(db, host, kind, action, uuid, fields)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
if plan["token"] != token:
|
||||
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
|
||||
if plan["blocks"]:
|
||||
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons=", ".join(b["code"] for b in plan["blocks"]))
|
||||
if not plan["can_execute"]:
|
||||
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="nothing_to_change")
|
||||
if plan["requires_acknowledgement"] and not acknowledged:
|
||||
raise api_error(409, ErrorCode.NETWORK_ACK_REQUIRED, reasons=", ".join(r["code"] for r in plan["risks"]))
|
||||
|
||||
fields = plan["_fields"]
|
||||
send = dict(fields)
|
||||
if kind == "vip" and action == "set" and plan["_before"].get("network"):
|
||||
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
|
||||
# the request, so every VIP update must repeat it (found live: a partial set was rejected / could wipe it).
|
||||
send.setdefault("network", plan["_before"]["network"])
|
||||
# VipSettingsController::setItemAction queues "delete the old address" whenever the posted interface differs
|
||||
# from the stored one, and a partial update that leaves "interface" out counts as different. The following
|
||||
# reconfigure then removes the live VIP and, working from a stale address list, never puts it back (found
|
||||
# live 2026-10-20: CARP VIP stuck on DISABLED after a description edit). So repeat the interface as well.
|
||||
if plan["_before"].get("interface"):
|
||||
send.setdefault("interface", plan["_before"]["interface"])
|
||||
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
|
||||
expected = {**plan["_before"], **send} if action == "set" else dict(send)
|
||||
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
|
||||
audit = {"kind": "network", "action": f"{kind}.{action}"[:40], "entity_id": uuid, "entity_name": entity,
|
||||
"host_id": str(host.id), "host_name": host.name}
|
||||
details = {"before": plan["_before"], "after": _public(kind, fields) if action != "del" else None,
|
||||
"risks": [r["code"] for r in plan["risks"]], "acknowledged": acknowledged}
|
||||
|
||||
# Way back: a config backup taken right before the change. No backup, no change.
|
||||
try:
|
||||
backup_id = backup_store.save_snapshot(host.id, await opnsense_client.download_backup(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
await audit_service.persist(**audit, status="aborted", message="Backup vor der Änderung fehlgeschlagen", details=details)
|
||||
raise api_error(502, ErrorCode.NETWORK_BACKUP_FAILED, host=host.name) from exc
|
||||
details["backup_id"] = backup_id
|
||||
await audit_service.persist(**audit, status="started", message=None, details=details)
|
||||
|
||||
try:
|
||||
result = await opnsense_client.write_network_item(host, kind, action, uuid, send)
|
||||
except OpnsenseAPIError as exc:
|
||||
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
|
||||
# A description is not wired into anything, so there is nothing to re-apply for it.
|
||||
cosmetic = kind == "vip" and action == "set" and {c["field"] for c in plan["changes"]} <= {"descr"}
|
||||
carp_live = kind == "vip" and action != "del" and expected.get("mode") == "carp" and not cosmetic
|
||||
try:
|
||||
if not cosmetic:
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
live_ok = True
|
||||
if carp_live:
|
||||
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||
if not live_ok: # one more apply usually brings it back up
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||
except OpnsenseAPIError as exc:
|
||||
# Written but not (provably) applied: say so instead of reporting success.
|
||||
await audit_service.persist(**audit, status="apply_uncertain", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id=backup_id) from exc
|
||||
|
||||
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
|
||||
if not live_ok:
|
||||
verified = False
|
||||
mismatches = [*mismatches, "carp_vip_inactive"]
|
||||
await audit_service.persist(**audit, status="applied" if verified else "unverified",
|
||||
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
||||
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
|
||||
|
||||
|
||||
def _options(value: dict) -> list[dict]:
|
||||
return [{"value": k, "label": str(o.get("value", k)) if isinstance(o, dict) else str(k), "selected": bool(isinstance(o, dict) and o.get("selected"))}
|
||||
for k, o in value.items()]
|
||||
|
||||
|
||||
async def template(host: OpnsenseHost, kind: str) -> dict:
|
||||
"""The firewall's own blank form for this kind: field defaults and, for select fields, the choices it
|
||||
accepts right now (e.g. only the gateways it considers valid for a route)."""
|
||||
node = _explode(await opnsense_client.get_network_item(host, kind, None))
|
||||
out: dict[str, dict] = {}
|
||||
for key in sorted(FIELDS[kind]):
|
||||
if key not in node:
|
||||
continue
|
||||
value = node[key]
|
||||
if isinstance(value, dict) and value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
|
||||
options = _options(value)
|
||||
# Free-text list fields (IPs, MACs, DNS servers) arrive in this shape too; the form decides how to show them.
|
||||
out[key] = {"value": ",".join(o["value"] for o in options if o["selected"] and o["value"] != ""), "options": options}
|
||||
elif key in SECRET_FIELDS.get(kind, set()):
|
||||
out[key] = {"value": "", "options": None}
|
||||
else:
|
||||
out[key] = {"value": "" if value is None or isinstance(value, dict) else str(value), "options": None}
|
||||
return out
|
||||
|
||||
|
||||
async def item(host: OpnsenseHost, kind: str, uuid: str) -> dict[str, str]:
|
||||
"""One object as the firewall holds it right now, reduced to the fields that may be sent (no secrets)."""
|
||||
node = await opnsense_client.get_network_item(host, kind, uuid)
|
||||
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
return {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Compares the network configuration of the nodes of an HA cluster.
|
||||
|
||||
Interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP objects should
|
||||
be the same on both nodes of a pair; a VLAN or gateway that exists on one node
|
||||
only is exactly the kind of difference that breaks a failover months later.
|
||||
|
||||
Per-node values are deliberately NOT compared: interface addresses, CARP
|
||||
advskew/password and the active default gateway legitimately differ between
|
||||
the nodes. What is compared is the identity of each object (its key) and the
|
||||
settings that must agree (its signature). A category is skipped when any
|
||||
reachable node cannot deliver it (older OPNsense), instead of reporting that
|
||||
as a difference.
|
||||
"""
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.schemas.network import HostNetwork
|
||||
from app.services import network_service
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
CATEGORIES = ["interfaces", "vlans", "vips", "gateways", "routes", "dns_hosts", "dhcp_hosts", "kea_subnets", "kea_reservations"]
|
||||
|
||||
|
||||
def signatures(net: HostNetwork) -> dict[str, dict[str, str]]:
|
||||
out: dict[str, dict[str, str]] = {c: {} for c in CATEGORIES}
|
||||
for i in net.interfaces:
|
||||
if i.assigned and i.identifier:
|
||||
out["interfaces"][i.identifier] = f"{i.description}|{i.vlan_tag or ''}|{i.ipv4_type or ''}"
|
||||
for v in net.vlans:
|
||||
out["vlans"][f"{v.tag}@{v.parent}"] = v.description
|
||||
for v in net.vips:
|
||||
if v.mode == "carp":
|
||||
out["vips"][f"carp:{v.vhid}"] = f"{v.address}|{v.advbase or ''}|{v.description}"
|
||||
else:
|
||||
out["vips"][f"{v.mode}:{v.address}"] = v.description
|
||||
for g in net.gateways:
|
||||
out["gateways"][g.name] = f"{g.interface}|{g.ip_protocol}|{g.address}|{g.disabled}"
|
||||
for r in net.routes:
|
||||
out["routes"][r.network] = f"{r.gateway}|{r.disabled}"
|
||||
for d in net.dns_hosts:
|
||||
out["dns_hosts"][f"{d.hostname}.{d.domain}|{d.record_type}"] = f"{d.server}|{d.enabled}"
|
||||
for h in net.dhcp_hosts:
|
||||
out["dhcp_hosts"][f"{h.host}.{h.domain}"] = f"{h.ip}|{h.hwaddr}"
|
||||
for s in net.kea_subnets:
|
||||
out["kea_subnets"][s.subnet] = f"{s.pools}|{s.routers}|{s.dns_servers}"
|
||||
for k in net.kea_reservations:
|
||||
out["kea_reservations"][f"{k.ip_address}|{k.hw_address}"] = k.hostname
|
||||
return out
|
||||
|
||||
|
||||
# Which HostNetwork.unavailable entry makes a category incomparable.
|
||||
_UNAVAILABLE_KEY = {"interfaces": "assignments"}
|
||||
|
||||
|
||||
def compare(per_host: dict[str, HostNetwork]) -> list[dict]:
|
||||
"""per_host: host name -> its network. Returns one row per object that is missing on a node or differs."""
|
||||
names = sorted(per_host)
|
||||
sigs = {n: signatures(per_host[n]) for n in names}
|
||||
rows: list[dict] = []
|
||||
for category in CATEGORIES:
|
||||
skip_key = _UNAVAILABLE_KEY.get(category, category)
|
||||
if any(skip_key in per_host[n].unavailable for n in names):
|
||||
continue
|
||||
keys = sorted({k for n in names for k in sigs[n][category]})
|
||||
for key in keys:
|
||||
present = {n: sigs[n][category][key] for n in names if key in sigs[n][category]}
|
||||
if len(present) < len(names):
|
||||
rows.append({"category": category, "key": key, "status": "missing",
|
||||
"present_on": sorted(present), "missing_from": sorted(set(names) - set(present)),
|
||||
"signatures": present})
|
||||
elif len(set(present.values())) > 1:
|
||||
rows.append({"category": category, "key": key, "status": "different",
|
||||
"present_on": names, "missing_from": [], "signatures": present})
|
||||
return rows
|
||||
|
||||
|
||||
async def compute_cluster(hosts: list[OpnsenseHost]) -> tuple[list[dict], list[str], list[str]]:
|
||||
"""Returns (differences, compared host names, unreachable host names)."""
|
||||
per_host: dict[str, HostNetwork] = {}
|
||||
unreachable: list[str] = []
|
||||
for host in hosts:
|
||||
try:
|
||||
per_host[host.name] = await network_service.get_host_network(host)
|
||||
except OpnsenseAPIError:
|
||||
unreachable.append(host.name)
|
||||
if len(per_host) < 2:
|
||||
return [], sorted(per_host), sorted(unreachable)
|
||||
return compare(per_host), sorted(per_host), sorted(unreachable)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Read-only network inventory of one firewall: interfaces, VLANs, virtual
|
||||
IPs (CARP), gateways and static routes, straight from the firewall.
|
||||
|
||||
Also works out which interface the firewall answers OPNexus on. Every later
|
||||
write feature in this area (VLAN/IP/gateway changes) must refuse or escalate
|
||||
changes to that interface — an apply there can cut the very connection OPNexus
|
||||
would need to undo it.
|
||||
|
||||
Shapes verified live on OPNsense 26.7.1 and 26.7.6 (see opnsense_client).
|
||||
"""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.schemas.network import (
|
||||
HostNetwork,
|
||||
NetAddress,
|
||||
NetGateway,
|
||||
NetDhcpHost,
|
||||
NetDnsHost,
|
||||
NetInterface,
|
||||
NetKeaReservation,
|
||||
NetKeaSubnet,
|
||||
NetRoute,
|
||||
NetVip,
|
||||
NetVlan,
|
||||
)
|
||||
from app.services import opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
|
||||
def _rows(data) -> list[dict]:
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
def _flag(value) -> bool:
|
||||
return value in ("1", 1, True)
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _opt(value) -> str | None:
|
||||
"""OPNsense is inconsistent about number vs. string (a gateway priority came back as 254, not "254")."""
|
||||
return None if value in (None, "") else str(value)
|
||||
|
||||
|
||||
async def management_addresses(host: OpnsenseHost) -> set:
|
||||
"""IP addresses OPNexus uses to reach this firewall (literal or resolved)."""
|
||||
hostname = urlparse(host.base_url).hostname
|
||||
if not hostname:
|
||||
return set()
|
||||
try:
|
||||
return {ipaddress.ip_address(hostname)}
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
infos = await asyncio.get_running_loop().getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return set()
|
||||
found = set()
|
||||
for info in infos:
|
||||
try:
|
||||
found.add(ipaddress.ip_address(info[4][0].split("%")[0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return found
|
||||
|
||||
|
||||
def _addresses(row: dict) -> list[NetAddress]:
|
||||
result = []
|
||||
for key in ("ipv4", "ipv6"):
|
||||
for entry in row.get(key) or []:
|
||||
if isinstance(entry, dict) and entry.get("ipaddr"):
|
||||
result.append(NetAddress(
|
||||
address=str(entry["ipaddr"]),
|
||||
vhid=_opt(entry.get("vhid")),
|
||||
carp_status=_opt(entry.get("status")),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _address_ip(address: str):
|
||||
try:
|
||||
return ipaddress.ip_interface(address).ip
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _interface(row: dict, assignments: dict[str, dict], management: set) -> NetInterface:
|
||||
identifier = row.get("identifier") or None
|
||||
assigned = assignments.get(identifier or "") or {}
|
||||
addresses = _addresses(row)
|
||||
vlan = row.get("vlan") or {}
|
||||
return NetInterface(
|
||||
device=_text(row.get("device")),
|
||||
identifier=identifier,
|
||||
description=_text(row.get("description")),
|
||||
status=_text(row.get("status")),
|
||||
enabled=_flag(row.get("enabled", True)) if "enabled" in row else True,
|
||||
is_physical=bool(row.get("is_physical")),
|
||||
mtu=_opt(row.get("mtu")),
|
||||
mac=_opt(row.get("macaddr")),
|
||||
media=_opt(row.get("media")),
|
||||
vlan_tag=_opt((vlan.get("tag") if isinstance(vlan, dict) else None) or row.get("vlan_tag")),
|
||||
vlan_parent=_opt(vlan.get("parent")) if isinstance(vlan, dict) else None,
|
||||
ipv4_type=assigned.get("%type4") or None,
|
||||
ipv6_type=assigned.get("%type6") or None,
|
||||
addresses=addresses,
|
||||
pending_action=assigned.get("pending_action") or None,
|
||||
assigned=bool(identifier and identifier in assignments),
|
||||
is_management=any(_address_ip(a.address) in management for a in addresses if not a.vhid),
|
||||
)
|
||||
|
||||
|
||||
def _vlan(row: dict) -> NetVlan:
|
||||
return NetVlan(
|
||||
uuid=_text(row.get("uuid")),
|
||||
parent=_text(row.get("if")),
|
||||
tag=_text(row.get("tag")),
|
||||
pcp=_opt(row.get("pcp")),
|
||||
description=_text(row.get("descr")),
|
||||
device=(row.get("vlanif") or "").split(" ")[0] or None,
|
||||
)
|
||||
|
||||
|
||||
def _vip(row: dict) -> NetVip:
|
||||
# Deliberately picks fields by name: the row also carries the CARP password.
|
||||
return NetVip(
|
||||
uuid=_text(row.get("uuid")),
|
||||
interface=_text(row.get("interface")),
|
||||
interface_label=_text(row.get("%interface") or row.get("interface")),
|
||||
mode=_text(row.get("mode")),
|
||||
address=_text(row.get("address") or row.get("subnet")),
|
||||
vhid=_opt(row.get("vhid")),
|
||||
advbase=_opt(row.get("advbase")),
|
||||
advskew=_opt(row.get("advskew")),
|
||||
description=_text(row.get("descr")),
|
||||
)
|
||||
|
||||
|
||||
def _gateway(row: dict) -> NetGateway:
|
||||
return NetGateway(
|
||||
uuid=_text(row.get("uuid")),
|
||||
name=_text(row.get("name")),
|
||||
description=_text(row.get("descr")),
|
||||
interface=_text(row.get("interface")),
|
||||
interface_label=_text(row.get("interface_descr") or row.get("interface")),
|
||||
ip_protocol=_text(row.get("ipprotocol")),
|
||||
address=_text(row.get("gateway")),
|
||||
is_default=bool(row.get("defaultgw")),
|
||||
disabled=bool(row.get("disabled")),
|
||||
priority=_opt(row.get("priority")),
|
||||
status=_text(row.get("status")),
|
||||
)
|
||||
|
||||
|
||||
def _route(row: dict) -> NetRoute:
|
||||
return NetRoute(
|
||||
uuid=_text(row.get("uuid")),
|
||||
network=_text(row.get("network")),
|
||||
gateway=_text(row.get("gateway")),
|
||||
description=_text(row.get("descr")),
|
||||
# list rows carry "enabled" ("1"/"0"); older shapes a "disabled" flag
|
||||
disabled=_flag(row.get("disabled")) or row.get("enabled") in ("0", 0, False),
|
||||
)
|
||||
|
||||
|
||||
def _dns_host(row: dict) -> NetDnsHost:
|
||||
return NetDnsHost(
|
||||
uuid=_text(row.get("uuid")), hostname=_text(row.get("hostname")), domain=_text(row.get("domain")),
|
||||
record_type=_text(row.get("rr")), server=_text(row.get("server")), enabled=_flag(row.get("enabled")),
|
||||
description=_text(row.get("description")),
|
||||
)
|
||||
|
||||
|
||||
def _dhcp_host(row: dict) -> NetDhcpHost:
|
||||
return NetDhcpHost(
|
||||
uuid=_text(row.get("uuid")), host=_text(row.get("host")), domain=_text(row.get("domain")),
|
||||
ip=_text(row.get("ip")), hwaddr=_text(row.get("hwaddr")), lease_time=_text(row.get("lease_time")),
|
||||
description=_text(row.get("descr")),
|
||||
)
|
||||
|
||||
|
||||
def _kea_subnet(row: dict) -> NetKeaSubnet:
|
||||
# Picked by name on purpose: the row also carries the DDNS key secret.
|
||||
return NetKeaSubnet(
|
||||
uuid=_text(row.get("uuid")), subnet=_text(row.get("subnet")), pools=_text(row.get("pools")),
|
||||
routers=_text(row.get("option_data.routers")), dns_servers=_text(row.get("option_data.domain_name_servers")),
|
||||
description=_text(row.get("description")),
|
||||
)
|
||||
|
||||
|
||||
def _kea_reservation(row: dict) -> NetKeaReservation:
|
||||
return NetKeaReservation(
|
||||
uuid=_text(row.get("uuid")), subnet=_text(row.get("%subnet") or row.get("subnet")),
|
||||
ip_address=_text(row.get("ip_address")), hw_address=_text(row.get("hw_address")),
|
||||
hostname=_text(row.get("hostname")), description=_text(row.get("description")),
|
||||
)
|
||||
|
||||
|
||||
async def _optional(name: str, call, unavailable: list[str]) -> list[dict]:
|
||||
try:
|
||||
return _rows(await call)
|
||||
except OpnsenseAPIError:
|
||||
unavailable.append(name)
|
||||
return []
|
||||
|
||||
|
||||
async def get_host_network(host: OpnsenseHost) -> HostNetwork:
|
||||
"""Raises OpnsenseAPIError when the interface list itself is unavailable
|
||||
(host down); every other section degrades to `unavailable` instead."""
|
||||
info_rows = _rows(await opnsense_client.get_interfaces_info(host))
|
||||
unavailable: list[str] = []
|
||||
(assignment_rows, vlan_rows, vip_rows, gateway_rows, route_rows,
|
||||
dns_rows, dhcp_rows, kea_subnet_rows, kea_reservation_rows) = await asyncio.gather(
|
||||
_optional("assignments", opnsense_client.search_interface_assignments(host), unavailable),
|
||||
_optional("vlans", opnsense_client.search_vlans(host), unavailable),
|
||||
_optional("vips", opnsense_client.search_vips(host), unavailable),
|
||||
_optional("gateways", opnsense_client.search_gateways(host), unavailable),
|
||||
_optional("routes", opnsense_client.search_static_routes(host), unavailable),
|
||||
_optional("dns_hosts", opnsense_client.search_dns_hosts(host), unavailable),
|
||||
_optional("dhcp_hosts", opnsense_client.search_dhcp_hosts(host), unavailable),
|
||||
_optional("kea_subnets", opnsense_client.search_kea_subnets(host), unavailable),
|
||||
_optional("kea_reservations", opnsense_client.search_kea_reservations(host), unavailable),
|
||||
)
|
||||
management = await management_addresses(host)
|
||||
assignments = {_text(r.get("identifier")): r for r in assignment_rows if r.get("identifier")}
|
||||
interfaces = [_interface(r, assignments, management) for r in info_rows]
|
||||
management_device = next((i.device for i in interfaces if i.is_management), None)
|
||||
return HostNetwork(
|
||||
host_id=str(host.id),
|
||||
host_name=host.name,
|
||||
interfaces=interfaces,
|
||||
vlans=[_vlan(r) for r in vlan_rows],
|
||||
vips=[_vip(r) for r in vip_rows],
|
||||
gateways=[_gateway(r) for r in gateway_rows],
|
||||
routes=[_route(r) for r in route_rows],
|
||||
dns_hosts=[_dns_host(r) for r in dns_rows if not r.get("isAlias")],
|
||||
dhcp_hosts=[_dhcp_host(r) for r in dhcp_rows],
|
||||
kea_subnets=[_kea_subnet(r) for r in kea_subnet_rows],
|
||||
kea_reservations=[_kea_reservation(r) for r in kea_reservation_rows],
|
||||
management_device=management_device,
|
||||
can_configure_interfaces=any("type4" in r for r in assignment_rows),
|
||||
unavailable=sorted(unavailable),
|
||||
)
|
||||
@@ -3,18 +3,23 @@ whatever's currently bad. Pure detection — no delivery, no state tracking
|
||||
here (see notification_dedup.py for the open/resolved diffing that turns
|
||||
these snapshots into actual notifications)."""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
from app.models.backup_settings import BackupSettings
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.models.one_to_one_deployment import OneToOneDeployment
|
||||
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||
from app.models.port_forward_deployment import PortForwardDeployment
|
||||
from app.models.rule_deployment import RuleDeployment
|
||||
from app.services import nat_drift_service, rule_drift_service
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.services import backup_store, nat_drift_service, network_drift_service, rule_drift_service
|
||||
from app.services.notification_dedup import Problems
|
||||
from app.services.host_status_service import get_statuses
|
||||
from app.services.live_certificates_service import get_live_certificates
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
@@ -53,13 +58,15 @@ async def check_host_offline(db: AsyncSession) -> dict[str, dict]:
|
||||
|
||||
async def check_certificates(db: AsyncSession) -> dict[str, dict]:
|
||||
hosts = await _enabled_hosts(db)
|
||||
problems: dict[str, dict] = {}
|
||||
problems = Problems()
|
||||
for host in hosts:
|
||||
try:
|
||||
certs = await get_live_certificates(host)
|
||||
except OpnsenseAPIError:
|
||||
# Unreachable — already reported by check_host_offline, no need
|
||||
# to double-report it here as a certificate problem.
|
||||
# to double-report it here as a certificate problem. Its open
|
||||
# certificate problems stay as they are (not evaluable now).
|
||||
problems.preserve_prefixes.add(f"{host.id}:")
|
||||
continue
|
||||
for cert in certs:
|
||||
if cert.status not in ("expiring_soon", "expired"):
|
||||
@@ -83,12 +90,18 @@ async def check_rule_drift(db: AsyncSession) -> dict[str, dict]:
|
||||
if host.cluster_name:
|
||||
by_cluster[host.cluster_name].append(host)
|
||||
|
||||
problems: dict[str, dict] = {}
|
||||
problems = Problems()
|
||||
for cluster_name, cluster_hosts in by_cluster.items():
|
||||
if len(cluster_hosts) < 2:
|
||||
continue
|
||||
names_by_id = {h.id: h.name for h in cluster_hosts}
|
||||
for row in await rule_drift_service.compute_cluster_drift(cluster_hosts):
|
||||
rows, reachable = await rule_drift_service.compute_cluster_drift_with_reachable(cluster_hosts)
|
||||
if len(reachable) < 2:
|
||||
# A member is down (reboot/update): drift cannot be judged, so keep
|
||||
# what is open instead of resolving it and re-opening it later.
|
||||
problems.preserve_prefixes.add(f"{cluster_name}:")
|
||||
continue
|
||||
for row in rows:
|
||||
key = f"{cluster_name}:{row['fingerprint']}"
|
||||
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
|
||||
missing_names = sorted(
|
||||
@@ -113,13 +126,17 @@ async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
|
||||
if host.cluster_name:
|
||||
by_cluster[host.cluster_name].append(host)
|
||||
|
||||
problems: dict[str, dict] = {}
|
||||
problems = Problems()
|
||||
for cluster_name, cluster_hosts in by_cluster.items():
|
||||
if len(cluster_hosts) < 2:
|
||||
continue
|
||||
names_by_id = {h.id: h.name for h in cluster_hosts}
|
||||
for nat_type in _NAT_TYPE_LABELS:
|
||||
for row in await nat_drift_service.compute_cluster_drift(cluster_hosts, nat_type):
|
||||
rows, reachable = await nat_drift_service.compute_cluster_drift_with_reachable(cluster_hosts, nat_type)
|
||||
if len(reachable) < 2:
|
||||
problems.preserve_prefixes.add(f"{cluster_name}:{nat_type}:")
|
||||
continue
|
||||
for row in rows:
|
||||
key = f"{cluster_name}:{nat_type}:{row['fingerprint']}"
|
||||
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
|
||||
missing_names = sorted(
|
||||
@@ -135,6 +152,40 @@ async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
|
||||
return problems
|
||||
|
||||
|
||||
_NETWORK_LABELS = {
|
||||
"interfaces": "Schnittstelle", "vlans": "VLAN", "vips": "Virtuelle IP", "gateways": "Gateway", "routes": "Route",
|
||||
"dns_hosts": "DNS-Eintrag", "dhcp_hosts": "DHCP-Host", "kea_subnets": "DHCP-Subnetz", "kea_reservations": "DHCP-Reservierung",
|
||||
}
|
||||
|
||||
|
||||
async def check_network_drift(db: AsyncSession) -> dict[str, dict]:
|
||||
hosts = await _enabled_hosts(db)
|
||||
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
|
||||
for host in hosts:
|
||||
if host.cluster_name:
|
||||
by_cluster[host.cluster_name].append(host)
|
||||
|
||||
problems = Problems()
|
||||
for cluster_name, cluster_hosts in by_cluster.items():
|
||||
if len(cluster_hosts) < 2:
|
||||
continue
|
||||
differences, compared, _unreachable = await network_drift_service.compute_cluster(cluster_hosts)
|
||||
if len(compared) < 2:
|
||||
# Not evaluable right now (a node is down, which check_host_offline reports): keep what is open.
|
||||
problems.preserve_prefixes.add(f"{cluster_name}:")
|
||||
continue
|
||||
for row in differences:
|
||||
key = f"{cluster_name}:{row['category']}:{row['key']}"
|
||||
problems[key] = {
|
||||
"cluster_name": cluster_name,
|
||||
"object": f"{_NETWORK_LABELS.get(row['category'], row['category'])} {row['key']}",
|
||||
"status": "fehlt auf einem Knoten" if row["status"] == "missing" else "Einstellungen weichen ab",
|
||||
"present_on": row["present_on"],
|
||||
"missing_from": row["missing_from"],
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
async def check_revert_failed(db: AsyncSession) -> dict[str, dict]:
|
||||
problems: dict[str, dict] = {}
|
||||
for deployment_type, model, id_attr in _DEPLOYMENT_MODELS:
|
||||
@@ -185,6 +236,28 @@ async def check_carp_health(db: AsyncSession) -> dict[str, dict]:
|
||||
"condition": "no_master",
|
||||
"host_names": sorted(h.name for h in online),
|
||||
}
|
||||
problems.update(_carp_vip_problems(cluster_name, online, statuses, split_brain=len(masters) == 2))
|
||||
return problems
|
||||
|
||||
|
||||
def _carp_vip_problems(cluster_name: str, online: list[OpnsenseHost], statuses: dict, split_brain: bool) -> dict[str, dict]:
|
||||
"""Per-VHID checks. The host-level role is "MASTER" as soon as one VIP is, so a VIP that is down on a
|
||||
node (a node without the VIP never advertises it, the peer takes it over) looks healthy in the totals."""
|
||||
problems: dict[str, dict] = {}
|
||||
for host in online:
|
||||
vips = statuses.get(host.id, {}).get("carp_vips") or {}
|
||||
down = sorted(v for v, state in vips.items() if state not in ("MASTER", "BACKUP"))
|
||||
if down:
|
||||
problems[f"{cluster_name}:vip_down:{host.name}"] = {
|
||||
"cluster_name": cluster_name, "condition": "vip_down", "host_names": [host.name], "vhids": down,
|
||||
}
|
||||
# One node MASTER on some VHIDs and BACKUP on others: a partial failover. Already covered by the
|
||||
# split-brain alert when both nodes hold a MASTER.
|
||||
if not split_brain and {"MASTER", "BACKUP"} <= set(vips.values()):
|
||||
problems[f"{cluster_name}:role_mismatch:{host.name}"] = {
|
||||
"cluster_name": cluster_name, "condition": "role_mismatch", "host_names": [host.name],
|
||||
"vhids": sorted(v for v, state in vips.items() if state == "MASTER"),
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
@@ -215,3 +288,78 @@ async def check_carp_auto_revert_failed(db: AsyncSession) -> dict[str, dict]:
|
||||
"message": window.message,
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
# A failed scheduled run never turns "fixed" by itself, so it is reported as an event:
|
||||
# open for this long after its planned time, then closed without a "resolved" message
|
||||
# (see notification_service._NO_RESOLVED_NOTICE).
|
||||
SCHEDULED_FAILURE_WINDOW = timedelta(days=3)
|
||||
|
||||
|
||||
async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict]:
|
||||
since = datetime.now(timezone.utc) - SCHEDULED_FAILURE_WINDOW
|
||||
result = await db.execute(
|
||||
select(ScheduledMaintenance).where(
|
||||
ScheduledMaintenance.status == "failed", ScheduledMaintenance.run_at >= since
|
||||
)
|
||||
)
|
||||
problems: dict[str, dict] = {}
|
||||
for sched in result.scalars().all():
|
||||
problems[str(sched.id)] = {
|
||||
"schedule_id": str(sched.id),
|
||||
"host_id": str(sched.host_id),
|
||||
"host_name": sched.host_name,
|
||||
"action": sched.action,
|
||||
"run_at": sched.run_at.isoformat(),
|
||||
"message": (sched.result or {}).get("error"),
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
async def check_backup_stale(db: AsyncSession) -> dict[str, dict]:
|
||||
"""Scheduled backups are on but a host has no recent one: three missed intervals
|
||||
(at least 6 hours) cover a reboot or a short outage without crying wolf."""
|
||||
cfg = await db.get(BackupSettings, 1)
|
||||
if cfg is not None and not cfg.scheduled_enabled:
|
||||
return {}
|
||||
interval = cfg.interval_hours if cfg is not None else 24
|
||||
limit = timedelta(hours=max(3 * interval, 6))
|
||||
now = datetime.now(timezone.utc)
|
||||
problems: dict[str, dict] = {}
|
||||
for host in await _enabled_hosts(db):
|
||||
snapshots = backup_store.list_snapshots(host.id)
|
||||
newest = snapshots[0]["created_at"] if snapshots else None
|
||||
if (newest is None and now - host.created_at > limit) or (newest is not None and now - newest > limit):
|
||||
problems[str(host.id)] = {
|
||||
"host_id": str(host.id), "host_name": host.name,
|
||||
"last_backup": newest.isoformat() if newest else "noch nie",
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
async def check_backup_offsite(db: AsyncSession) -> dict[str, dict]:
|
||||
cfg = await db.get(BackupSettings, 1)
|
||||
if cfg is None or not cfg.offsite_enabled or not cfg.last_offsite_error:
|
||||
return {}
|
||||
return {"offsite": {"message": cfg.last_offsite_error}}
|
||||
|
||||
|
||||
async def check_compliance(db: AsyncSession) -> dict[str, dict]:
|
||||
"""Critical and high findings of the last security check, one problem per (host, check); resolved when it passes."""
|
||||
from app.services import compliance_service as cs
|
||||
hosts = {h.id: h for h in await _enabled_hosts(db)}
|
||||
problems: dict[str, dict] = {}
|
||||
for row in (await db.scalars(select(ComplianceResult))).all():
|
||||
host = hosts.get(row.host_id)
|
||||
if host is None or row.error:
|
||||
continue
|
||||
for finding in row.findings:
|
||||
if finding["passed"] is False and finding["severity"] in ("critical", "high"):
|
||||
params = finding.get("params") or {}
|
||||
problems[f"{host.id}:{finding['id']}"] = {
|
||||
"host_id": str(host.id), "host_name": host.name, "check": finding["id"],
|
||||
"befund": cs.TITLES_DE.get(finding["id"], finding["id"]),
|
||||
"schweregrad": cs.SEVERITIES_DE[finding["severity"]],
|
||||
"beispiele": ", ".join(params.get("examples", [])) or None,
|
||||
}
|
||||
return problems
|
||||
@@ -13,6 +13,17 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Problems(dict):
|
||||
"""The result of a check ({dedup_key: context}) plus the key prefixes the
|
||||
check could NOT evaluate this round (e.g. a cluster member is offline, so
|
||||
drift is not computable). Open problems under such a prefix are left alone
|
||||
instead of being resolved and re-opened on the next round."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.preserve_prefixes: set[str] = set()
|
||||
|
||||
|
||||
async def diff_and_update(
|
||||
db: AsyncSession, problem_type: str, current: dict[str, dict]
|
||||
) -> tuple[list[NotificationProblem], list[NotificationProblem]]:
|
||||
@@ -74,8 +85,9 @@ async def diff_and_update(
|
||||
existing.last_seen_at = now
|
||||
existing.context = context
|
||||
|
||||
preserve = tuple(getattr(current, "preserve_prefixes", ()))
|
||||
for key, row in open_rows.items():
|
||||
if key not in current:
|
||||
if key not in current and not (preserve and key.startswith(preserve)):
|
||||
row.status = "resolved"
|
||||
row.resolved_at = now
|
||||
resolved.append(row)
|
||||
|
||||
@@ -31,7 +31,12 @@ _LABELS = {
|
||||
"maintenance_unknown": "Wartungsergebnis unklar",
|
||||
"carp_split_brain": "CARP-Zustand kritisch",
|
||||
"nat_drift": "NAT-Drift im Cluster",
|
||||
"network_drift": "Netzwerk-Abweichung im Cluster",
|
||||
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
||||
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
|
||||
"backup_stale": "Kein aktuelles Backup",
|
||||
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
|
||||
"compliance_finding": "Sicherheitsbefund",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,14 +28,26 @@ _CHECKS = [
|
||||
("maintenance_unknown", "maintenance_unknown_enabled", notification_checks.check_maintenance_unknown),
|
||||
("carp_split_brain", "carp_health_enabled", notification_checks.check_carp_health),
|
||||
("nat_drift", "nat_drift_enabled", notification_checks.check_nat_drift),
|
||||
("network_drift", "network_drift_enabled", notification_checks.check_network_drift),
|
||||
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
|
||||
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
|
||||
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
|
||||
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
|
||||
("compliance_finding", "compliance_alerts_enabled", notification_checks.check_compliance),
|
||||
]
|
||||
|
||||
# Event-like problems: when they drop out of the window nothing was "fixed", so no
|
||||
# "behoben" message goes out for them.
|
||||
_NO_RESOLVED_NOTICE = {"scheduled_maintenance_failed"}
|
||||
|
||||
|
||||
async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None:
|
||||
"""Best-effort, not an audit trail: attempted once per transition and
|
||||
never retried, even on failure — avoids retry-storming a broken webhook
|
||||
URL or SMTP relay every poll cycle."""
|
||||
if transition == "resolved" and problem.problem_type in _NO_RESOLVED_NOTICE:
|
||||
problem.resolved_notified_at = _now()
|
||||
return
|
||||
if settings_row.webhook_enabled:
|
||||
try:
|
||||
await notification_delivery.send_webhook(settings_row, problem, transition)
|
||||
|
||||
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
|
||||
"smtp_from_address", "smtp_to_addresses",
|
||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
||||
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
||||
"carp_auto_revert_failed_enabled",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
|
||||
"poll_interval_seconds",
|
||||
]
|
||||
for field in simple_fields:
|
||||
|
||||
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user