Compare commits
14
Commits
v1.10.1-rc2
...
v1.18.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218a1f14ac | ||
|
|
79783d6d62 | ||
|
|
c15417869b | ||
|
|
cf326d2d17 | ||
|
|
8fccb1a547 | ||
|
|
cf59bdb38e | ||
|
|
c662a58426 | ||
|
|
3b76f9f5fa | ||
|
|
b341ccd49f | ||
|
|
20fd575420 | ||
|
|
1fc2d2c87c | ||
|
|
9889be64ef | ||
|
|
5c092fabac | ||
|
|
e51df4ba04 |
No files matched your search
+25
-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,6 +71,25 @@ 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.
|
||||
# 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. Benötigt außerdem 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
|
||||
# Anfrage). Nur für isolierte Labor-Setups auf "true" setzen.
|
||||
# ALLOW_INSECURE_FIREWALL_URLS=false
|
||||
# SMTP über SSL/STARTTLS prüft Zertifikat UND Hostname. Mailserver mit
|
||||
# privater CA: CA-PEM in den Backend-Container einbinden und hier angeben.
|
||||
# SMTP_CA_FILE=/data/smtp-ca.pem
|
||||
# SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt.
|
||||
# ALLOW_INSECURE_SMTP_AUTH=false
|
||||
@@ -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
|
||||
|
||||
|
||||
+308
-1
@@ -29,7 +29,314 @@ in der App auf der Login-Seite, im Footer jeder Seite, auf `/about` und in
|
||||
den Einstellungen.
|
||||
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
ansteht): Lanner, Merlin …
|
||||
|
||||
## [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.
|
||||
**Verhaltensänderungen**, die beim Update auffallen können:
|
||||
|
||||
- **Backup-Rohdownload nur für Admins**. Nur-Lese-Konten sehen weiter die
|
||||
Liste der Snapshots, bekommen die `config.xml` (enthält Passwort-Hashes, VPN-Schlüssel)
|
||||
aber nicht mehr; jeder Versuch steht im Audit-Verlauf (neuer Status „abgelehnt“).
|
||||
- **Passwortwechsel/-reset beendet alle Sitzungen** des Kontos; ebenso
|
||||
2FA-Reset durch einen Admin. 2FA aktivieren/deaktivieren beendet die *anderen* Sitzungen.
|
||||
- **SMTP über SSL/STARTTLS prüft Zertifikat und Hostname**. Mailserver mit
|
||||
privater CA: `SMTP_CA_FILE` setzen. SMTP-Anmeldung ohne Verschlüsselung ist gesperrt
|
||||
(`ALLOW_INSECURE_SMTP_AUTH=true` hebt das bewusst auf).
|
||||
- **Firewalls nur noch per `https://`** — beim Anlegen und bei jeder Anfrage;
|
||||
ein vorhandener `http://`-Host zeigt „offline“, statt Zugangsdaten im Klartext zu senden
|
||||
(`ALLOW_INSECURE_FIREWALL_URLS=true` nur für Labor-Setups).
|
||||
- **VPN-Push (IPsec/OpenVPN/WireGuard) verlangt das Vorschau-Token** und das
|
||||
Token ändert sich, sobald nur das Geheimnis (PSK/Schlüssel) rotiert wurde. Die Vorschau
|
||||
zeigt „unverändert“ jetzt korrekt, statt jeden Tunnel dauerhaft als geändert.
|
||||
- **Host löschen** ist auch bei VPN-, Zertifikats- und ACME-Objekten auf der Firewall
|
||||
gesperrt, bis diese entfernt sind; Schlüsseldateien nie bereitgestellter
|
||||
Entwürfe werden mit entfernt.
|
||||
- Benachrichtigungs-Geheimnisse liegen jetzt im Volume `notification_secrets`
|
||||
. Ohne lesbares Signatur-/SMTP-Geheimnis wird **nichts** mehr gesendet
|
||||
(vorher: unsigniert/ohne Login). Wer Geheimnisse gesetzt hat, muss sie nach dem
|
||||
Update einmal neu eingeben, falls der Container zwischenzeitlich ersetzt wurde.
|
||||
|
||||
Weitere Korrekturen:
|
||||
|
||||
- Oberfläche: Der „Bestätigen“-Button steht jetzt am Ende der Deploy-Ergebnis-Zeile statt
|
||||
in der Aktionszeile (Regeln, Aliase, Port-Forwards, 1:1-NAT, Outbound-NAT,
|
||||
Hierarchie-Regeln); die Aktionszeile springt nicht mehr, wenn ein Deployment offen wird.
|
||||
|
||||
- **Deployments hinterlassen vor dem ersten Firewall-Aufruf eine dauerhafte Spur**
|
||||
(gemeinsamer Ablauf `deploy_flow.py` für Regeln, Aliase, Port-Forwards, 1:1-NAT,
|
||||
Outbound-NAT und Hierarchie-Regeln): Bei Absturz, verlorener Antwort oder Timeout bleibt
|
||||
die Rollback-Frist erhalten und der Sweeper macht die Änderung rückgängig. Wo die Firewall
|
||||
eine neue Regel möglicherweise angelegt hat, ohne dass eine UUID bekannt ist, steht der
|
||||
Eintrag auf „Rollback fehlgeschlagen“ mit dem Hinweis „Ausgang unklar“ (blockiert
|
||||
erneutes Deployen, löst die Benachrichtigung aus); „Rollback wiederholen“ entfernt ihn.
|
||||
Von der Firewall beantwortete Ablehnungen und Verbindungsfehler bleiben einfache Fehler.
|
||||
- TOTP- und Recovery-Codes sind auch bei gleichzeitigen Anfragen genau einmal
|
||||
verwendbar (Zeilensperre).
|
||||
- Login-Sperre: begrenzter Speicher, Argon2 läuft außerhalb des Event-Loops mit
|
||||
Parallelitätsgrenze, 2FA-Einrichtung/-Aktivierung/-Deaktivierung und Passwortwechsel
|
||||
haben ein Versuchslimit; offene 2FA-Einrichtung läuft nach 15 Minuten ab; Proxy
|
||||
begrenzt API-Anfragen auf 2 MB.
|
||||
- Fehlermeldungen der Firewall werden vor Speicherung/Anzeige von Geheimnissen
|
||||
bereinigt und gekürzt; Validierungsfehler (422) geben Eingaben nicht mehr
|
||||
zurück; Webhook-URLs (oft mit Token) sieht ein Nur-Lese-Konto nur noch als
|
||||
Host.
|
||||
- Audit: MFA/Passwort, VPN, Zertifikate/ACME, Benachrichtigungen und Backup-Exporte
|
||||
werden aufgezeichnet, ebenso abgelehnte Schreibversuche von Nur-Lese-Konten.
|
||||
- Same-Origin-Prüfung berücksichtigt das Schema bei `COOKIE_SECURE=true`;
|
||||
Warnung beim Start, wenn `FRONTEND_ORIGIN` https ist, das Cookie aber nicht `Secure`
|
||||
. Geheimnisdateien werden atomar mit Modus 0600 angelegt.
|
||||
- Frontend-Abhängigkeiten (`sharp`, `source-map-js`, `brace-expansion`) aktualisiert.
|
||||
Offen: `braces`/`postcss-selector-parser` nur in Build-Werkzeugen (Tailwind 3 /
|
||||
ESLint), nicht im Laufzeit-Image; für `braces` gibt es keine gepatchte Version.
|
||||
|
||||
## [1.10.1] "Lanner" — 2026-10-06
|
||||
|
||||
|
||||
+20
-6
@@ -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.10.1-rc2` ist der aktuelle öffentliche Release-Candidate (er löst `1.10.0-rc1`/`-rc2` ab). Entwickelt wurde gegen echte OPNsense-Instanzen, eine frische Installation aus den veröffentlichten Images ist aber noch nicht im Feld erprobt — bitte Ecken und Kanten melden.
|
||||
> **Stand:** `1.18.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.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -16,13 +16,25 @@ 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.
|
||||
- **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.
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / 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 +46,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.10.1-rc2
|
||||
OPNEXUS_VERSION=1.18.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.10.1-rc2` is the current public release candidate (it replaces `1.10.0-rc1`/`-rc2`). It has been developed against real OPNsense instances, but a fresh install from the published images has not been field-tested yet — please expect rough edges and report them.
|
||||
> **Status:** `1.18.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.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -16,15 +16,27 @@ 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.
|
||||
- **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.
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / 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 +48,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.10.1-rc2
|
||||
OPNEXUS_VERSION=1.18.0
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -57,6 +71,8 @@ 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.
|
||||
|
||||
@@ -8,7 +8,8 @@ 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.auth import client_ip
|
||||
from app.services import api_token_service, auth_service
|
||||
|
||||
_PUBLIC_PATHS = {
|
||||
"/api/auth/login",
|
||||
@@ -35,6 +36,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",
|
||||
@@ -42,35 +45,108 @@ _READ_ONLY_ALLOWED_WRITES = {
|
||||
}
|
||||
|
||||
|
||||
# Resource families whose URLs are /api/<family>/<collection>/<id>/<action>
|
||||
# (or /api/<family>/<action> for the account/notification endpoints). The
|
||||
# older kinds below keep their original /api/<kind>/<id>/<action> parsing.
|
||||
_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"}
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _security_event(parts: list[str], method: str) -> dict:
|
||||
"""kind/action/entity/host for VPN, certificate, notification and account-security
|
||||
requests. Only path segments are used — never query strings or bodies."""
|
||||
ids = [p for p in parts[2:] if _is_uuid(p)]
|
||||
tail = parts[-1]
|
||||
if parts[1] == "auth":
|
||||
action = "/".join(parts[2:])
|
||||
elif method == "DELETE":
|
||||
action = "delete"
|
||||
elif ids and not _is_uuid(tail):
|
||||
action = tail # deploy / push / remove / register / sign / poll
|
||||
elif method in ("PUT", "PATCH"):
|
||||
action = "edit"
|
||||
elif not ids and tail not in _COLLECTIONS and len(parts) > 2:
|
||||
action = tail # e.g. notifications/test
|
||||
else:
|
||||
action = "create"
|
||||
return {"kind": _SECURITY_KINDS[parts[1]], "action": action[:40], "entity_id": ids[0] if ids else None,
|
||||
"host_id": ids[1] if len(ids) > 1 else None}
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
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", "system": "system"}
|
||||
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
|
||||
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,
|
||||
"details": {"method": request.method, "path": request.url.path}}
|
||||
tracked = kind and writing and not request.url.path.endswith(("/preview", "/carp-preview", "/revisions"))
|
||||
|
||||
if (
|
||||
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 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:
|
||||
audit_service.context.reset(ctx)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
|
||||
)
|
||||
request.state.user = user
|
||||
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"}
|
||||
kind = kinds.get(parts[1]) if len(parts) > 1 else None
|
||||
tracked = kind and request.method in ("POST", "PUT", "PATCH", "DELETE") and not request.url.path.endswith(("/preview", "/carp-preview", "/revisions"))
|
||||
action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create")
|
||||
token = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": action})
|
||||
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,
|
||||
"details": {"method": request.method, "path": request.url.path}}
|
||||
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")
|
||||
|
||||
@@ -28,6 +28,14 @@ class Settings(BaseSettings):
|
||||
update_check_url: str = ""
|
||||
update_check_interval_hours: int = 12
|
||||
|
||||
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
|
||||
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
|
||||
# nie Zugriff auf den Docker-Socket; es reicht nur "aktualisieren" /
|
||||
# "zurückrollen" an diesen Dienst weiter, authentifiziert mit dem Token,
|
||||
# den der Updater in sein Zustands-Volume schreibt.
|
||||
updater_url: str = "http://updater:8080"
|
||||
updater_token_file: str = "/updater/token"
|
||||
|
||||
# Cookies now carry the session, so this can no longer be a bare "*"
|
||||
# (the Fetch spec forbids credentialed requests against a wildcard
|
||||
# response) — see main.py's allow_origin_regex. Default only matches
|
||||
@@ -47,6 +55,10 @@ class Settings(BaseSettings):
|
||||
opnsense_secret_dir: str = "/data/opnsense-secrets"
|
||||
opnsense_backup_dir: str = "/data/opnsense-backups"
|
||||
opnsense_timeout: float = 10.0
|
||||
# Firewall API credentials are sent as HTTP Basic auth. Plain http://
|
||||
# addresses are therefore refused (on create AND on every request) unless
|
||||
# this is switched on — only meant for isolated lab setups.
|
||||
allow_insecure_firewall_urls: bool = False
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────
|
||||
admin_username: str = "admin"
|
||||
@@ -86,6 +98,13 @@ class Settings(BaseSettings):
|
||||
# calls every cycle.
|
||||
notification_poll_min_interval_seconds: int = 60
|
||||
notification_smtp_timeout_seconds: float = 10.0
|
||||
# SMTP over SSL/STARTTLS always verifies the server certificate and host
|
||||
# name against the container's trust store. A mail server with a private
|
||||
# CA needs that CA's PEM file mounted and referenced here.
|
||||
smtp_ca_file: str = ""
|
||||
# Sending SMTP credentials over an unencrypted connection (security "none")
|
||||
# is refused unless this is switched on explicitly.
|
||||
allow_insecure_smtp_auth: bool = False
|
||||
|
||||
# ── Log ingest ────────────────────────────────────────────────────
|
||||
# Floor against an admin setting an absurdly low poll interval — same
|
||||
|
||||
@@ -6,6 +6,12 @@ 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"
|
||||
ADMIN_REQUIRED = "ADMIN_REQUIRED"
|
||||
BACKUP_EMPTY = "BACKUP_EMPTY"
|
||||
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
||||
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
|
||||
@@ -43,6 +49,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"
|
||||
@@ -59,6 +67,7 @@ class ErrorCode(str, Enum):
|
||||
HOST_HAT_NOCH_AKTIVE_POLICY_HIERARCHIE_DEPLOYMENTS_DIESE_ADD78845 = "HOST_HAT_NOCH_AKTIVE_POLICY_HIERARCHIE_DEPLOYMENTS_DIESE_ADD78845"
|
||||
HOST_HAT_NOCH_AKTIVE_REGEL_DEPLOYMENTS_DIESE_ZUERST_0BF41721 = "HOST_HAT_NOCH_AKTIVE_REGEL_DEPLOYMENTS_DIESE_ZUERST_0BF41721"
|
||||
HOST_HAT_NOCH_NAT_DEPLOYMENTS_DIESE_ZUERST_GEZIELT_0C2A9E85 = "HOST_HAT_NOCH_NAT_DEPLOYMENTS_DIESE_ZUERST_GEZIELT_0C2A9E85"
|
||||
HOST_HAT_NOCH_VPN_ODER_ZERTIFIKATS_OBJEKTE_DIESE_ZUERST_ENTFERNEN = "HOST_HAT_NOCH_VPN_ODER_ZERTIFIKATS_OBJEKTE_DIESE_ZUERST_ENTFERNEN"
|
||||
HOST_IST_DEAKTIVIERT = "HOST_IST_DEAKTIVIERT"
|
||||
HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS = "HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS"
|
||||
HOST_MUSS_ERREICHBAR_SEIN_UND_FIRMWARE_AUFTRAG_BEENDET_BCE1390B = "HOST_MUSS_ERREICHBAR_SEIN_UND_FIRMWARE_AUFTRAG_BEENDET_BCE1390B"
|
||||
@@ -98,6 +107,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"
|
||||
@@ -133,7 +149,15 @@ 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"
|
||||
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:
|
||||
|
||||
+110
-5
@@ -3,8 +3,14 @@ import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
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
|
||||
@@ -12,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
|
||||
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
|
||||
@@ -31,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
|
||||
@@ -40,6 +50,7 @@ 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 carp_event_service, carp_maintenance_window_service
|
||||
|
||||
# Without this, Python's root logger defaults to WARNING with no handler,
|
||||
@@ -220,6 +231,7 @@ async def _resource_poll_loop():
|
||||
|
||||
|
||||
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
|
||||
_RUNNING_MAINTENANCE_POLL_SECONDS = 15
|
||||
|
||||
|
||||
async def _scheduled_maintenance_poll_loop():
|
||||
@@ -235,9 +247,31 @@ 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)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -245,12 +279,20 @@ async def lifespan(app: FastAPI):
|
||||
await migrate.run()
|
||||
async with SessionLocal() as db:
|
||||
await auth_service.bootstrap_admin(db)
|
||||
if any(o.startswith("https://") for o in settings.allowed_origins) and not settings.cookie_secure:
|
||||
logger.warning(
|
||||
"FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie "
|
||||
"would also be sent over plain HTTP. Set COOKIE_SECURE=true."
|
||||
)
|
||||
if settings.allow_insecure_firewall_urls:
|
||||
logger.warning("ALLOW_INSECURE_FIREWALL_URLS is on: firewall API credentials may travel over plain HTTP.")
|
||||
|
||||
sweep_task = asyncio.create_task(_deploy_revert_sweep_loop())
|
||||
notification_task = asyncio.create_task(_notification_poll_loop())
|
||||
log_ingest_task = asyncio.create_task(_log_ingest_poll_loop())
|
||||
resource_task = asyncio.create_task(_resource_poll_loop())
|
||||
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
|
||||
compliance_task = asyncio.create_task(_compliance_poll_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -259,14 +301,69 @@ 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()
|
||||
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_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):
|
||||
"""FastAPI's default 422 body echoes the offending `input` (and validator
|
||||
`ctx`) back — for password/key/PSK fields that puts the secret into
|
||||
responses that browsers, proxies and error telemetry may record. Only
|
||||
where the problem is and what it is are returned."""
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": [{"loc": list(err.get("loc", ())), "msg": err.get("msg", ""), "type": err.get("type", "")} for err in exc.errors()]},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(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
|
||||
@@ -301,6 +398,10 @@ 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(logs.router, prefix="/api")
|
||||
app.include_router(groups.router, prefix="/api")
|
||||
app.include_router(aliases.router, prefix="/api")
|
||||
@@ -353,5 +454,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,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)
|
||||
@@ -48,7 +48,9 @@ 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")
|
||||
|
||||
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 +72,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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
+89
-18
@@ -2,13 +2,14 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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__)
|
||||
|
||||
@@ -45,7 +46,31 @@ def _clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
def _check_account_budget(user: AdminUser, action: str) -> None:
|
||||
"""429 once too many failed password/code attempts piled up for this account
|
||||
and action (see auth_service.account_throttle)."""
|
||||
wait = auth_service.account_throttle.retry_after(str(user.id), action)
|
||||
if wait > 0:
|
||||
raise api_error(status.HTTP_429_TOO_MANY_REQUESTS, ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, wait=wait)
|
||||
|
||||
|
||||
def _account_failure(user: AdminUser, action: str) -> None:
|
||||
auth_service.account_throttle.record_failure(str(user.id), action)
|
||||
|
||||
|
||||
def _account_success(user: AdminUser, action: str) -> None:
|
||||
auth_service.account_throttle.reset(str(user.id), action)
|
||||
|
||||
|
||||
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:
|
||||
@@ -84,6 +109,7 @@ async def login(payload: LoginRequest, request: Request, response: Response, db:
|
||||
logger.info("Failed login for '%s' from %s", payload.username, ip)
|
||||
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
||||
|
||||
verified_hash = user.password_hash
|
||||
if user.totp_enabled:
|
||||
# Only revealed after the password was correct, so it cannot be used
|
||||
# to find out which usernames exist or have 2FA.
|
||||
@@ -98,7 +124,7 @@ async def login(payload: LoginRequest, request: Request, response: Response, db:
|
||||
auth_service.throttle.reset(ip, payload.username)
|
||||
failed_since_last_login = auth_service.throttle.pop_failures_since_success(payload.username)
|
||||
token, expires_at = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, verified_hash=verified_hash
|
||||
)
|
||||
_set_session_cookie(response, token)
|
||||
logger.info("Successful login for '%s' from %s", user.username, ip)
|
||||
@@ -114,20 +140,33 @@ async def login(payload: LoginRequest, request: Request, response: Response, db:
|
||||
|
||||
|
||||
async def _accept_second_factor(db: AsyncSession, user: AdminUser, code: str) -> bool:
|
||||
"""Authenticator code (replay-protected) or, failing that, a one-time recovery code."""
|
||||
secret = totp_service.read_secret(user.id)
|
||||
"""Authenticator code (replay-protected) or, failing that, a one-time recovery code.
|
||||
|
||||
A code is single-use only if "check, then mark as used" cannot interleave
|
||||
with a second request carrying the same code. The user row is therefore
|
||||
re-read under a row lock (FOR UPDATE, fresh values) and the used step /
|
||||
consumed recovery hash is committed while holding it; a concurrent attempt
|
||||
blocks here and then sees the updated state.
|
||||
"""
|
||||
locked = await db.scalar(
|
||||
select(AdminUser).where(AdminUser.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
)
|
||||
if locked is None:
|
||||
return False
|
||||
secret = totp_service.read_secret(locked.id)
|
||||
if secret:
|
||||
step = totp_service.verify_code(secret, code, user.totp_last_step)
|
||||
step = totp_service.verify_code(secret, code, locked.totp_last_step)
|
||||
if step is not None:
|
||||
user.totp_last_step = step
|
||||
locked.totp_last_step = step
|
||||
await db.commit()
|
||||
return True
|
||||
remaining = totp_service.consume_recovery(user.totp_recovery_hashes, code)
|
||||
remaining = totp_service.consume_recovery(locked.totp_recovery_hashes, code)
|
||||
if remaining is not None:
|
||||
user.totp_recovery_hashes = remaining
|
||||
locked.totp_recovery_hashes = remaining
|
||||
await db.commit()
|
||||
logger.warning("Recovery code used by '%s' (%d left)", user.username, len(remaining))
|
||||
logger.warning("Recovery code used by '%s' (%d left)", locked.username, len(remaining))
|
||||
return True
|
||||
await db.commit() # nothing changed — just release the row lock
|
||||
return False
|
||||
|
||||
|
||||
@@ -156,45 +195,66 @@ async def totp_setup(payload: TotpSetupRequest, user: AdminUser = Depends(curren
|
||||
active until /2fa/enable proves the authenticator works."""
|
||||
if user.totp_enabled:
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_ALREADY_ENABLED)
|
||||
if not auth_service.verify_password(user.password_hash, payload.password):
|
||||
_check_account_budget(user, "2fa-setup")
|
||||
if not await auth_service.verify_password_async(user.password_hash, payload.password):
|
||||
_account_failure(user, "2fa-setup")
|
||||
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
||||
_account_success(user, "2fa-setup")
|
||||
secret = totp_service.generate_secret()
|
||||
totp_service.save_secret(user.id, secret, pending=True)
|
||||
return {"secret": secret, "otpauth_uri": totp_service.otpauth_uri(user.username, secret)}
|
||||
|
||||
|
||||
@router.post("/2fa/enable")
|
||||
async def totp_enable(payload: TotpEnableRequest, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
async def totp_enable(
|
||||
payload: TotpEnableRequest, request: Request,
|
||||
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
|
||||
):
|
||||
if user.totp_enabled:
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_ALREADY_ENABLED)
|
||||
_check_account_budget(user, "2fa-enable")
|
||||
pending = totp_service.read_secret(user.id, pending=True)
|
||||
if not pending:
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_SETUP_MISSING)
|
||||
step = totp_service.verify_code(pending, payload.code)
|
||||
if step is None:
|
||||
# Not reset by a fresh /2fa/setup: recreating the pending enrolment
|
||||
# must not hand out a new guessing budget.
|
||||
_account_failure(user, "2fa-enable")
|
||||
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID)
|
||||
_account_success(user, "2fa-enable")
|
||||
plain, hashes = totp_service.new_recovery_codes()
|
||||
totp_service.promote_pending(user.id)
|
||||
user.totp_enabled = True
|
||||
user.totp_last_step = step
|
||||
user.totp_recovery_hashes = hashes
|
||||
# Other sessions were established without the second factor — end them.
|
||||
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
|
||||
await db.commit()
|
||||
logger.info("Two-factor enabled for '%s'", user.username)
|
||||
return {"enabled": True, "recovery_codes": plain}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def totp_disable(payload: TotpDisableRequest, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
async def totp_disable(
|
||||
payload: TotpDisableRequest, request: Request,
|
||||
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
|
||||
):
|
||||
if not user.totp_enabled:
|
||||
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_NOT_ENABLED)
|
||||
if not auth_service.verify_password(user.password_hash, payload.password):
|
||||
_check_account_budget(user, "2fa-disable")
|
||||
if not await auth_service.verify_password_async(user.password_hash, payload.password):
|
||||
_account_failure(user, "2fa-disable")
|
||||
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
||||
if not await _accept_second_factor(db, user, payload.code):
|
||||
_account_failure(user, "2fa-disable")
|
||||
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID)
|
||||
_account_success(user, "2fa-disable")
|
||||
totp_service.delete_secrets(user.id)
|
||||
user.totp_enabled = False
|
||||
user.totp_recovery_hashes = None
|
||||
user.totp_last_step = 0
|
||||
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
|
||||
await db.commit()
|
||||
logger.info("Two-factor disabled for '%s'", user.username)
|
||||
return {"enabled": False}
|
||||
@@ -210,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:
|
||||
@@ -225,15 +293,18 @@ async def change_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user),
|
||||
):
|
||||
if not auth_service.verify_password(user.password_hash, payload.current_password):
|
||||
_check_account_budget(user, "change-password")
|
||||
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
|
||||
_account_failure(user, "change-password")
|
||||
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
||||
_account_success(user, "change-password")
|
||||
if payload.new_password == payload.current_password:
|
||||
raise api_error(status.HTTP_400_BAD_REQUEST, ErrorCode.DAS_NEUE_PASSWORT_MUSS_SICH_VOM_AKTUELLEN_UNTERSCHEIDEN)
|
||||
|
||||
user.password_hash = auth_service.hash_password(payload.new_password)
|
||||
await db.commit()
|
||||
|
||||
await auth_service.revoke_session(db, request.cookies.get(COOKIE_NAME, ""))
|
||||
# New hash, revocation of EVERY existing session (including other devices
|
||||
# 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)
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
||||
)
|
||||
|
||||
@@ -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,67 @@
|
||||
"""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 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.services import compliance_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)
|
||||
@@ -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)
|
||||
@@ -10,11 +10,20 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.services import audit_service
|
||||
from app.services.policy_service import policy_lock
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.policy_hierarchy_group import PolicyHierarchyGroup
|
||||
from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment
|
||||
from app.models.rule_deployment import RuleDeployment
|
||||
from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment
|
||||
from app.models.openvpn_instance_deployment import OpenVpnInstanceDeployment
|
||||
from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment
|
||||
from app.models.managed_certificate import ManagedCertificate
|
||||
from app.models.acme_account import AcmeAccount
|
||||
from app.models.acme_certificate_request import AcmeCertificateRequest
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
from app.schemas.host import AliasOption, FirmwareInfo, HostBackup, HostCa, HostCertificate, HostCreate, HostRead, HostUpdate, TlsProbeRequest, TlsProbeResult, InterfaceOption, LiveRule
|
||||
from app.services import backup_store, opnsense_client, opnsense_secret_store, tls_pin_service
|
||||
@@ -146,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:
|
||||
@@ -192,13 +203,35 @@ async def delete_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
raise api_error(409, ErrorCode.HOST_HAT_NOCH_NAT_DEPLOYMENTS_DIESE_ZUERST_GEZIELT_0C2A9E85)
|
||||
if await db.scalar(select(PolicyHierarchyRuleDeployment.id).where(PolicyHierarchyRuleDeployment.host_id == host_id).limit(1)):
|
||||
raise api_error(409, ErrorCode.HOST_HAT_NOCH_AKTIVE_POLICY_HIERARCHIE_DEPLOYMENTS_DIESE_ADD78845)
|
||||
# VPN tunnels, certificates and ACME objects likewise cascade away with the host
|
||||
# while staying live on the firewall: block until they are removed there.
|
||||
for deployment_model in (IpsecTunnelDeployment, OpenVpnInstanceDeployment, WireguardTunnelDeployment):
|
||||
if await db.scalar(select(deployment_model.id).where(deployment_model.host_id == host_id).limit(1)):
|
||||
raise api_error(409, ErrorCode.HOST_HAT_NOCH_VPN_ODER_ZERTIFIKATS_OBJEKTE_DIESE_ZUERST_ENTFERNEN)
|
||||
for cert_model in (ManagedCertificate, AcmeAccount, AcmeCertificateRequest):
|
||||
# A never-deployed draft only exists locally and may go with the host.
|
||||
if await db.scalar(
|
||||
select(cert_model.id).where(
|
||||
cert_model.host_id == host_id,
|
||||
(cert_model.opnsense_uuid.is_not(None)) | (cert_model.status == "success"),
|
||||
).limit(1)
|
||||
):
|
||||
raise api_error(409, ErrorCode.HOST_HAT_NOCH_VPN_ODER_ZERTIFIKATS_OBJEKTE_DIESE_ZUERST_ENTFERNEN)
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.services.host_maintenance_service import ACTIVE
|
||||
if await db.scalar(select(HostOperation.id).where(HostOperation.host_id == host_id, HostOperation.status.in_(ACTIVE)).limit(1)):
|
||||
raise api_error(409, ErrorCode.HOST_HAT_EINE_OFFENE_WARTUNGSAKTION_ZUERST_PRUEFEN_UND_8CC5FBE0)
|
||||
# Local drafts cascade away with the host; their file-backed secrets must not outlive them.
|
||||
from app.services import acme_secret_store, cert_key_store
|
||||
draft_certs = (await db.scalars(select(ManagedCertificate.id).where(ManagedCertificate.host_id == host_id))).all()
|
||||
draft_accounts = (await db.scalars(select(AcmeAccount.id).where(AcmeAccount.host_id == host_id))).all()
|
||||
await db.delete(host)
|
||||
await db.commit()
|
||||
opnsense_secret_store.delete_secret(host_id)
|
||||
for cert_id in draft_certs:
|
||||
cert_key_store.delete_secret(cert_id)
|
||||
for account_id in draft_accounts:
|
||||
acme_secret_store.delete_secret(account_id)
|
||||
|
||||
|
||||
@router.get("/{host_id}/live-rules", response_model=list[LiveRule])
|
||||
@@ -246,11 +279,23 @@ async def list_host_backups(host_id: uuid.UUID, db: AsyncSession = Depends(get_d
|
||||
|
||||
|
||||
@router.get("/{host_id}/backups/{backup_id}")
|
||||
async def download_host_backup(host_id: uuid.UUID, backup_id: str, db: AsyncSession = Depends(get_db)):
|
||||
await _get_host_or_404(db, host_id)
|
||||
async def download_host_backup(
|
||||
host_id: uuid.UUID, backup_id: str,
|
||||
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
|
||||
):
|
||||
"""Raw config.xml snapshot — contains password hashes, VPN keys and other
|
||||
firewall secrets, so unlike the (metadata-only) listing it is not part of
|
||||
the "read_only may GET everything" allowance. Every attempt is audited
|
||||
(who/which snapshot, never the content)."""
|
||||
host = await _get_host_or_404(db, host_id)
|
||||
event = {"kind": "backup", "action": "download", "entity_id": backup_id[:36], "host_id": str(host_id), "host_name": host.name}
|
||||
if user.role != "admin":
|
||||
await audit_service.persist(**event, status="denied", message="Rohdownload nur für Administratoren")
|
||||
raise api_error(403, ErrorCode.READ_ONLY_ACCESS)
|
||||
content = backup_store.read_snapshot(host_id, backup_id)
|
||||
if content is None:
|
||||
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
||||
await audit_service.persist(**event, status="accepted")
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/xml",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -151,10 +151,10 @@ async def list_revisions(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db
|
||||
|
||||
|
||||
@router.post("/{tunnel_id}/deploy", response_model=IpsecTunnelRead)
|
||||
async def deploy(tunnel_id: uuid.UUID, approval: IpsecDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)):
|
||||
async def deploy(tunnel_id: uuid.UUID, approval: IpsecDeployApproval, db: AsyncSession = Depends(get_db)):
|
||||
tunnel = await _load_tunnel(db, tunnel_id)
|
||||
plan = preview(tunnel)
|
||||
if approval and approval.preview_token != plan["token"]:
|
||||
if approval.preview_token != plan["token"]:
|
||||
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
|
||||
if not tunnel.pre_shared_key_set:
|
||||
raise api_error(400, ErrorCode.FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_PRE_SHARED_4AE1FC63)
|
||||
@@ -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}
|
||||
@@ -1,9 +1,13 @@
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.database import get_db
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.models.notification import NotificationProblem
|
||||
from app.schemas.notification import (
|
||||
NotificationProblemRead,
|
||||
@@ -16,9 +20,25 @@ from app.services import notification_delivery, notification_settings_service
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
def _redact_url(url: str | None) -> str | None:
|
||||
"""Webhook URLs very often carry the credential itself (a token in the path or
|
||||
query), so anyone who can read the URL can post to the receiver. Readers see
|
||||
only where it points."""
|
||||
if not url:
|
||||
return url
|
||||
parts = urlsplit(url)
|
||||
if not parts.hostname:
|
||||
return "…"
|
||||
return f"{parts.scheme}://{parts.hostname}{f':{parts.port}' if parts.port else ''}/…"
|
||||
|
||||
|
||||
@router.get("/settings", response_model=NotificationSettingsRead)
|
||||
async def read_settings(db: AsyncSession = Depends(get_db)):
|
||||
return await notification_settings_service.get_settings(db)
|
||||
async def read_settings(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
row = await notification_settings_service.get_settings(db)
|
||||
result = NotificationSettingsRead.model_validate(row)
|
||||
if user.role != "admin":
|
||||
result = result.model_copy(update={"webhook_url": _redact_url(result.webhook_url)})
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/settings", response_model=NotificationSettingsRead)
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -153,10 +153,10 @@ async def list_revisions(instance_id: uuid.UUID, db: AsyncSession = Depends(get_
|
||||
|
||||
|
||||
@router.post("/{instance_id}/deploy", response_model=OpenVpnInstanceRead)
|
||||
async def deploy(instance_id: uuid.UUID, approval: OpenVpnDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)):
|
||||
async def deploy(instance_id: uuid.UUID, approval: OpenVpnDeployApproval, db: AsyncSession = Depends(get_db)):
|
||||
instance = await _load_instance(db, instance_id)
|
||||
plan = preview(instance)
|
||||
if approval and approval.preview_token != plan["token"]:
|
||||
if approval.preview_token != plan["token"]:
|
||||
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
|
||||
if instance.tls_key_enabled and not instance.tls_key_material_set:
|
||||
raise api_error(400, ErrorCode.TLS_HARDENING_IST_AKTIVIERT_ABER_ES_WURDE_NOCH_F53C81DD)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,6 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services import update_check_service
|
||||
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"])
|
||||
|
||||
@@ -10,3 +14,93 @@ async def version_check() -> 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()
|
||||
|
||||
|
||||
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 UpdateRequest(BaseModel):
|
||||
version: str = Field(min_length=5, max_length=20)
|
||||
|
||||
|
||||
@router.get("/updater")
|
||||
async def updater_status(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()
|
||||
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)
|
||||
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, 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()
|
||||
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,67 @@
|
||||
"""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.
|
||||
"""
|
||||
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)
|
||||
):
|
||||
if user.role != "admin" and payload.scope != 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"])
|
||||
|
||||
@@ -34,7 +34,7 @@ async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
raise api_error(409, ErrorCode.BENUTZERNAME_BEREITS_VERGEBEN)
|
||||
user = AdminUser(
|
||||
username=payload.username,
|
||||
password_hash=auth_service.hash_password(payload.password),
|
||||
password_hash=await auth_service.hash_password_async(payload.password),
|
||||
role=payload.role.value,
|
||||
)
|
||||
db.add(user)
|
||||
@@ -64,6 +64,8 @@ async def reset_two_factor(user_id: uuid.UUID, db: AsyncSession = Depends(get_db
|
||||
target.totp_enabled = False
|
||||
target.totp_recovery_hashes = None
|
||||
target.totp_last_step = 0
|
||||
# The account lost its second factor (maybe to someone else): force a fresh login.
|
||||
await auth_service.revoke_sessions(db, target.id, commit=False)
|
||||
await db.commit()
|
||||
return target
|
||||
|
||||
@@ -73,7 +75,11 @@ 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)
|
||||
target.password_hash = auth_service.hash_password(payload.new_password)
|
||||
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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -162,10 +162,10 @@ async def list_revisions(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db
|
||||
|
||||
|
||||
@router.post("/{tunnel_id}/deploy", response_model=WireguardTunnelRead)
|
||||
async def deploy(tunnel_id: uuid.UUID, approval: WireguardDeployApproval | None = Body(default=None), db: AsyncSession = Depends(get_db)):
|
||||
async def deploy(tunnel_id: uuid.UUID, approval: WireguardDeployApproval, db: AsyncSession = Depends(get_db)):
|
||||
tunnel = await _load_tunnel(db, tunnel_id)
|
||||
plan = preview(tunnel)
|
||||
if approval and approval.preview_token != plan["token"]:
|
||||
if approval.preview_token != plan["token"]:
|
||||
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
|
||||
if not tunnel.server_private_key_set:
|
||||
raise api_error(400, ErrorCode.FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998)
|
||||
@@ -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
|
||||
@@ -2,7 +2,9 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.services.firewall_url import validate_base_url
|
||||
|
||||
|
||||
class HostCreate(BaseModel):
|
||||
@@ -17,6 +19,11 @@ class HostCreate(BaseModel):
|
||||
cluster_name: str | None = Field(default=None, max_length=120)
|
||||
policy_hierarchy_group_id: uuid.UUID | None = None
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def _base_url_is_safe(cls, value: str) -> str:
|
||||
return validate_base_url(value)
|
||||
|
||||
|
||||
class TlsProbeRequest(BaseModel):
|
||||
base_url: str = Field(min_length=1, max_length=255)
|
||||
@@ -40,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
|
||||
@@ -49,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)
|
||||
@@ -116,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
|
||||
@@ -128,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,8 @@ 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",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,7 +37,9 @@ 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
|
||||
|
||||
poll_interval_seconds: int
|
||||
updated_at: datetime
|
||||
@@ -71,7 +74,9 @@ 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
|
||||
|
||||
poll_interval_seconds: int | None = Field(
|
||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||
|
||||
@@ -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]
|
||||
@@ -27,7 +27,11 @@ def _origin_allowed(origin: str, host_header: str) -> bool:
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return False
|
||||
if parsed.netloc.lower() == host_header.lower():
|
||||
return True
|
||||
# Same host is not enough on an HTTPS deployment: an http:// page on that
|
||||
# very host (plain-HTTP port, downgraded first visit) is a different
|
||||
# origin. The backend only sees the proxy's plain-HTTP hop, so the
|
||||
# deployment's own COOKIE_SECURE switch says which scheme is legitimate.
|
||||
return parsed.scheme == "https" or not settings.cookie_secure
|
||||
return f"{parsed.scheme}://{parsed.netloc}".lower() in {o.lower() for o in settings.allowed_origins}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_SUFFIX = ".eabhmac"
|
||||
|
||||
@@ -20,9 +21,7 @@ def _path(account_id: uuid.UUID) -> Path:
|
||||
|
||||
def save_secret(account_id: uuid.UUID, secret: str) -> None:
|
||||
path = _path(account_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_secret(account_id: uuid.UUID) -> str | None:
|
||||
|
||||
@@ -19,7 +19,8 @@ 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.opnsense_client import OpnsenseAPIError
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -54,48 +55,23 @@ async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: Opnsense
|
||||
|
||||
prior_uuid = existing.opnsense_alias_uuid if existing else None
|
||||
prior_live_payload = existing.last_payload if existing else None
|
||||
new_uuid = prior_uuid
|
||||
try:
|
||||
if prior_uuid:
|
||||
await opnsense_client.set_alias_item(host, prior_uuid, payload)
|
||||
else:
|
||||
result = await opnsense_client.add_alias_item(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAPIError("Firewall hat keine Alias-UUID zurückgegeben; Stand manuell prüfen")
|
||||
await opnsense_client.reconfigure_aliases(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
if existing:
|
||||
existing.status = "error"
|
||||
existing.opnsense_alias_uuid = new_uuid
|
||||
existing.message = str(exc)
|
||||
existing.revert_deadline = None
|
||||
existing.deployed_at = _now()
|
||||
return existing
|
||||
deployment = AliasDeployment(
|
||||
alias_id=alias.id, host_id=host.id, status="error", message=str(exc),
|
||||
opnsense_alias_uuid=new_uuid, deployed_at=_now(),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds)
|
||||
if existing:
|
||||
existing.status = "pending"
|
||||
existing.message = None
|
||||
existing.opnsense_alias_uuid = new_uuid
|
||||
existing.deployed_at = _now()
|
||||
existing.revert_deadline = deadline
|
||||
existing.previous_payload = prior_live_payload
|
||||
existing.last_payload = payload
|
||||
return existing
|
||||
async def write(uuid_):
|
||||
if uuid_:
|
||||
await opnsense_client.set_alias_item(host, uuid_, payload)
|
||||
return uuid_
|
||||
result = await opnsense_client.add_alias_item(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAmbiguousError("Firewall hat keine Alias-UUID zurückgegeben; Stand manuell prüfen")
|
||||
return new_uuid
|
||||
|
||||
deployment = AliasDeployment(
|
||||
alias_id=alias.id, host_id=host.id, status="pending", message=None,
|
||||
opnsense_alias_uuid=new_uuid, deployed_at=_now(), revert_deadline=deadline,
|
||||
previous_payload=None, last_payload=payload,
|
||||
deployment = await push_to_host(
|
||||
db, existing=existing, uuid_attr="opnsense_alias_uuid",
|
||||
make=lambda **fields: AliasDeployment(alias_id=alias.id, host_id=host.id, **fields),
|
||||
prior_uuid=prior_uuid, prior_live_payload=prior_live_payload, payload=payload,
|
||||
write=write, apply=lambda: opnsense_client.reconfigure_aliases(host),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
|
||||
@@ -113,7 +89,7 @@ async def deploy_alias(db: AsyncSession, alias: FirewallAlias, hosts: list[Opnse
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = f"Alias wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}"
|
||||
deployment.revert_deadline = None
|
||||
# The change is live: keep the revert deadline so an unconfirmed result is still undone.
|
||||
await db.commit()
|
||||
return deployments
|
||||
|
||||
@@ -134,7 +110,7 @@ async def sweep_expired_alias_deployments(db: AsyncSession) -> list[AliasDeploym
|
||||
from app.services.policy_service import policy_lock
|
||||
await policy_lock(db)
|
||||
result = await db.execute(
|
||||
select(AliasDeployment).where(AliasDeployment.status == "pending", AliasDeployment.revert_deadline <= _now())
|
||||
select(AliasDeployment).where(AliasDeployment.status.in_(("pending", "error")), AliasDeployment.revert_deadline <= _now())
|
||||
)
|
||||
expired = list(result.scalars().all())
|
||||
touched: list[AliasDeployment] = []
|
||||
@@ -159,7 +135,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()
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""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(/|$)")
|
||||
# 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)(/|$)"
|
||||
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 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()
|
||||
@@ -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):
|
||||
@@ -42,7 +59,7 @@ def deployment_history(session, flush_context, instances):
|
||||
PolicyHierarchyRuleDeployment: ("policy_hierarchy", PolicyHierarchyRule, "policy_hierarchy_id", "opnsense_policy_hierarchy_uuid"),
|
||||
}
|
||||
for dep in list(session.new) + list(session.dirty) + list(session.deleted):
|
||||
if type(dep) not in mapping:
|
||||
if type(dep) not in mapping or getattr(dep, "_skip_audit", False):
|
||||
continue
|
||||
is_new, deleted = dep in session.new, dep in session.deleted
|
||||
if not is_new and not deleted and not session.is_modified(dep, include_collections=False):
|
||||
|
||||
@@ -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.
|
||||
@@ -20,6 +20,7 @@ Design notes
|
||||
not reveal whether the account exists.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
@@ -33,6 +34,7 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser, Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,6 +62,36 @@ def verify_password(stored_hash: str, password: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Argon2 is deliberately expensive (CPU and ~64 MiB each). Run on the default
|
||||
# executor so the single event loop (health checks, deploy/rollback sweeps)
|
||||
# keeps running, and cap how many run — and how many may queue — at once so a
|
||||
# flood of login attempts cannot exhaust CPU/memory or the thread pool.
|
||||
_HASH_CONCURRENCY = 4
|
||||
_HASH_MAX_WAITING = 32
|
||||
_hash_slots = asyncio.Semaphore(_HASH_CONCURRENCY)
|
||||
_hash_waiting = 0
|
||||
|
||||
|
||||
async def _bounded(function, *args):
|
||||
global _hash_waiting
|
||||
if _hash_waiting >= _HASH_MAX_WAITING:
|
||||
raise api_error(503, ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, wait=5)
|
||||
_hash_waiting += 1
|
||||
try:
|
||||
async with _hash_slots:
|
||||
return await asyncio.to_thread(function, *args)
|
||||
finally:
|
||||
_hash_waiting -= 1
|
||||
|
||||
|
||||
async def hash_password_async(password: str) -> str:
|
||||
return await _bounded(hash_password, password)
|
||||
|
||||
|
||||
async def verify_password_async(stored_hash: str, password: str) -> bool:
|
||||
return await _bounded(verify_password, stored_hash, password)
|
||||
|
||||
|
||||
def needs_rehash(stored_hash: str) -> bool:
|
||||
try:
|
||||
return _ph.check_needs_rehash(stored_hash)
|
||||
@@ -76,21 +108,32 @@ class _AttemptRecord:
|
||||
|
||||
|
||||
class _LoginThrottle:
|
||||
"""Failed-login limiter with two independent buckets per window:
|
||||
"""Failed-attempt limiter with two independent buckets per window:
|
||||
|
||||
* ``ip::username`` — targeted guessing against the one account
|
||||
* ``ip`` — spraying across many usernames from one source
|
||||
|
||||
A login is blocked while *either* bucket is locked out.
|
||||
|
||||
All state is bounded: records whose window and lockout have both passed
|
||||
are dropped, and a hard cap evicts the least recently active entries, so
|
||||
a stream of made-up usernames/sources cannot grow memory without limit.
|
||||
"""
|
||||
|
||||
MAX_RECORDS = 20_000
|
||||
MAX_COUNTERS = 5_000
|
||||
COUNTER_TTL_SECONDS = 24 * 3600
|
||||
_PURGE_EVERY_SECONDS = 30.0
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: dict[str, _AttemptRecord] = {}
|
||||
# Total failures per username since their last successful login —
|
||||
# informational only (shown to the user after login), separate from
|
||||
# the rolling lockout windows above. In-memory/per-process like the
|
||||
# rest of this throttle, so it resets on a backend restart.
|
||||
self._failures_since_success: dict[str, int] = {}
|
||||
# the rolling lockout windows above: username -> (count, last_failure).
|
||||
# In-memory/per-process like the rest of this throttle, so it resets
|
||||
# on a backend restart.
|
||||
self._failures_since_success: dict[str, tuple[int, float]] = {}
|
||||
self._last_purge = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _user_key(ip: str, username: str) -> str:
|
||||
@@ -107,6 +150,32 @@ class _LoginThrottle:
|
||||
remaining = rec.locked_until - time.monotonic()
|
||||
return int(remaining) + 1 if remaining > 0 else 0
|
||||
|
||||
@staticmethod
|
||||
def _last_activity(rec: _AttemptRecord) -> float:
|
||||
return max([rec.locked_until, *rec.failures]) if rec.failures or rec.locked_until else 0.0
|
||||
|
||||
def _purge(self, now: float, *, force: bool = False) -> None:
|
||||
if not force and now - self._last_purge < self._PURGE_EVERY_SECONDS and len(self._records) < self.MAX_RECORDS:
|
||||
return
|
||||
self._last_purge = now
|
||||
window = settings.login_window_seconds
|
||||
for key in [
|
||||
k for k, r in self._records.items()
|
||||
if r.locked_until <= now and all(now - t >= window for t in r.failures)
|
||||
]:
|
||||
del self._records[key]
|
||||
if len(self._records) > self.MAX_RECORDS:
|
||||
# Still over the cap with live entries: drop the stalest unlocked ones first.
|
||||
stale = sorted(self._records, key=lambda k: (self._records[k].locked_until > now, self._last_activity(self._records[k])))
|
||||
for key in stale[: len(self._records) - self.MAX_RECORDS]:
|
||||
del self._records[key]
|
||||
for name in [n for n, (_, ts) in self._failures_since_success.items() if now - ts > self.COUNTER_TTL_SECONDS]:
|
||||
del self._failures_since_success[name]
|
||||
if len(self._failures_since_success) > self.MAX_COUNTERS:
|
||||
oldest = sorted(self._failures_since_success, key=lambda n: self._failures_since_success[n][1])
|
||||
for name in oldest[: len(self._failures_since_success) - self.MAX_COUNTERS]:
|
||||
del self._failures_since_success[name]
|
||||
|
||||
def _record_key(self, key: str, max_attempts: int) -> None:
|
||||
now = time.monotonic()
|
||||
rec = self._records.setdefault(key, _AttemptRecord())
|
||||
@@ -119,16 +188,19 @@ class _LoginThrottle:
|
||||
|
||||
def retry_after(self, ip: str, username: str) -> int:
|
||||
"""Seconds the caller must wait, or 0 if not currently locked out."""
|
||||
self._purge(time.monotonic())
|
||||
return max(
|
||||
self._retry_after_key(self._user_key(ip, username)),
|
||||
self._retry_after_key(self._ip_key(ip)),
|
||||
)
|
||||
|
||||
def record_failure(self, ip: str, username: str) -> None:
|
||||
self._purge(time.monotonic())
|
||||
self._record_key(self._user_key(ip, username), settings.login_max_attempts)
|
||||
self._record_key(self._ip_key(ip), settings.login_ip_max_attempts)
|
||||
key = username.lower()
|
||||
self._failures_since_success[key] = self._failures_since_success.get(key, 0) + 1
|
||||
count, _ = self._failures_since_success.get(key, (0, 0.0))
|
||||
self._failures_since_success[key] = (count + 1, time.monotonic())
|
||||
|
||||
def reset(self, ip: str, username: str) -> None:
|
||||
self._records.pop(self._user_key(ip, username), None)
|
||||
@@ -136,10 +208,15 @@ class _LoginThrottle:
|
||||
def pop_failures_since_success(self, username: str) -> int:
|
||||
"""Read and clear the failure count for a username, called on a
|
||||
successful login so the count reflects only attempts since then."""
|
||||
return self._failures_since_success.pop(username.lower(), 0)
|
||||
return self._failures_since_success.pop(username.lower(), (0, 0.0))[0]
|
||||
|
||||
|
||||
throttle = _LoginThrottle()
|
||||
# Separate budget for the self-service security endpoints (2FA setup/enable/
|
||||
# disable, password change): keyed by (user id, action) instead of (ip,
|
||||
# username), so repeating the request from a stolen session cannot be used to
|
||||
# guess the password or a second-factor code without limit.
|
||||
account_throttle = _LoginThrottle()
|
||||
|
||||
|
||||
# ── Sessions ───────────────────────────────────────────────────────────
|
||||
@@ -158,12 +235,27 @@ async def create_session(
|
||||
*,
|
||||
user_agent: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
verified_hash: str | None = None,
|
||||
) -> tuple[str, datetime]:
|
||||
"""Create a session, returning the raw token (to be set as a cookie) and its expiry."""
|
||||
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
|
||||
|
||||
``verified_hash`` is the password hash the caller just authenticated
|
||||
against. The user row is re-read under a row lock first: a password
|
||||
change/reset that committed while the (slow) password check was running
|
||||
changed the hash, and the login must not outlive that revocation.
|
||||
"""
|
||||
user_id = user.id
|
||||
if verified_hash is not None:
|
||||
locked = await db.scalar(
|
||||
select(AdminUser.password_hash).where(AdminUser.id == user_id).with_for_update()
|
||||
)
|
||||
if locked != verified_hash:
|
||||
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)
|
||||
session = Session(
|
||||
user_id=user.id,
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(token),
|
||||
expires_at=expires_at,
|
||||
last_used_at=_now(),
|
||||
@@ -171,7 +263,7 @@ async def create_session(
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.add(session)
|
||||
await db.execute(delete(Session).where(Session.user_id == user.id, Session.expires_at < _now()))
|
||||
await db.execute(delete(Session).where(Session.user_id == user_id, Session.expires_at < _now()))
|
||||
await db.commit()
|
||||
return token, expires_at
|
||||
|
||||
@@ -193,6 +285,18 @@ async def revoke_session(db: AsyncSession, token: str) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def revoke_sessions(db: AsyncSession, user_id, *, keep_token: str | None = None, commit: bool = True) -> None:
|
||||
"""Delete every session of an account (optionally sparing the caller's own).
|
||||
With ``commit=False`` the deletion joins the caller's transaction, so a
|
||||
credential change and the revocation succeed or fail together."""
|
||||
query = delete(Session).where(Session.user_id == user_id)
|
||||
if keep_token:
|
||||
query = query.where(Session.token_hash != _hash_token(keep_token))
|
||||
await db.execute(query)
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────
|
||||
|
||||
async def authenticate(
|
||||
@@ -211,14 +315,14 @@ async def authenticate(
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
verify_password(_DUMMY_HASH, password)
|
||||
await verify_password_async(_DUMMY_HASH, password)
|
||||
return None
|
||||
|
||||
if not verify_password(user.password_hash, password):
|
||||
if not await verify_password_async(user.password_hash, password):
|
||||
return None
|
||||
|
||||
if needs_rehash(user.password_hash):
|
||||
user.password_hash = hash_password(password)
|
||||
user.password_hash = await hash_password_async(password)
|
||||
if finalize:
|
||||
await finalize_login(db, user, ip)
|
||||
else:
|
||||
@@ -263,7 +367,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:
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_SUFFIX = ".xml"
|
||||
|
||||
@@ -21,11 +22,10 @@ def save_snapshot(host_id: uuid.UUID, content: bytes) -> str:
|
||||
microsecond precision to avoid collisions on rapid consecutive saves.
|
||||
Returns the filename, used as the backup's id."""
|
||||
host_dir = _host_dir(host_id)
|
||||
host_dir.mkdir(parents=True, exist_ok=True)
|
||||
host_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
filename = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + _SUFFIX
|
||||
path = host_dir / filename
|
||||
path.write_bytes(content)
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, content)
|
||||
return filename
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -22,6 +22,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_SUFFIX = ".certkey"
|
||||
|
||||
@@ -32,9 +33,7 @@ def _path(cert_id: uuid.UUID) -> Path:
|
||||
|
||||
def save_secret(cert_id: uuid.UUID, secret: str) -> None:
|
||||
path = _path(cert_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_secret(cert_id: uuid.UUID) -> str | None:
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""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 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
|
||||
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
|
||||
|
||||
|
||||
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) -> 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)))
|
||||
|
||||
# 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
|
||||
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age)
|
||||
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 ValueError as exc:
|
||||
row.findings, row.score, row.error = [], None, "invalid_config"
|
||||
row.error_detail = str(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,125 @@
|
||||
"""The shared write path of every "push to a firewall, then confirm or auto-revert"
|
||||
feature (rules, aliases, port forwards, 1:1 NAT, outbound NAT, hierarchy rules).
|
||||
|
||||
Safety property: from the moment OPNexus may have changed something on a
|
||||
firewall, a durable record exists that lets the revert sweeper (or an operator)
|
||||
find and undo it — even if the reply is lost, the apply fails or the process
|
||||
dies in between.
|
||||
|
||||
1. *Intent* is committed BEFORE the first remote call:
|
||||
- existing deployment: status "pending" with the revert deadline armed and
|
||||
the previous payload as rollback target (a crash leaves a normal,
|
||||
sweepable pending change);
|
||||
- first deployment (no remote UUID known yet): status "revert_failed" =
|
||||
"outcome unknown, check the firewall" — the state that already blocks
|
||||
retries/edits and raises a notification; "Rollback wiederholen" just
|
||||
forgets the row when no UUID was ever recorded.
|
||||
2. The write. A refusal the firewall answered restores the previous state; a
|
||||
lost/ambiguous reply keeps the intent record (existing: deadline stays armed,
|
||||
new: stays "unknown").
|
||||
3. After a confirmed write the UUID is committed at once and the row becomes a
|
||||
normal armed "pending" change. If applying it fails the row is "error" but
|
||||
keeps its deadline, so the sweeper still reverts a possibly live change.
|
||||
4. Only after a successful apply the confirmation window starts.
|
||||
|
||||
policy_lock (a transaction-scoped advisory lock) is released by the intermediate
|
||||
commits and taken again right away; the intent row itself keeps other deploys
|
||||
and the sweeper away in the gap (pending/unknown rows are never re-deployed).
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
UNKNOWN_OUTCOME = (
|
||||
"Ausgang unklar: Die Firewall hat die Anfrage möglicherweise ausgeführt. Stand dort prüfen; "
|
||||
"„Rollback wiederholen“ entfernt diesen Eintrag, ohne die Firewall anzusprechen."
|
||||
)
|
||||
|
||||
_FIELDS = ("status", "message", "last_payload", "previous_payload", "revert_deadline", "deployed_at")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def _checkpoint(db: AsyncSession) -> None:
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
await db.commit()
|
||||
await policy_lock(db)
|
||||
|
||||
|
||||
async def push_to_host(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
existing: Any | None,
|
||||
make: Callable[..., Any],
|
||||
uuid_attr: str,
|
||||
prior_uuid: str | None,
|
||||
prior_live_payload: dict | None,
|
||||
payload: dict,
|
||||
write: Callable[[str | None], Awaitable[str]],
|
||||
apply: Callable[[], Awaitable[Any]],
|
||||
) -> Any:
|
||||
window = timedelta(seconds=settings.deploy_confirm_window_seconds)
|
||||
before = {name: getattr(existing, name) for name in _FIELDS} if existing else None
|
||||
|
||||
# 1. durable intent
|
||||
if existing is not None:
|
||||
row = existing
|
||||
row.status, row.message = "pending", None
|
||||
row.previous_payload, row.last_payload = prior_live_payload, payload
|
||||
row.revert_deadline, row.deployed_at = _now() + window, _now()
|
||||
else:
|
||||
row = make(
|
||||
status="revert_failed", message=UNKNOWN_OUTCOME, last_payload=payload, previous_payload=None,
|
||||
revert_deadline=None, deployed_at=_now(), **{uuid_attr: None},
|
||||
)
|
||||
db.add(row)
|
||||
# The intent is a technical safety record, not a state change worth a history
|
||||
# entry of its own (the request itself is already audited as "requested").
|
||||
row._skip_audit = True
|
||||
await _checkpoint(db)
|
||||
row._skip_audit = False
|
||||
|
||||
wrote = False
|
||||
try:
|
||||
new_uuid = await write(prior_uuid)
|
||||
wrote = True
|
||||
# 3. recoverable by UUID from here on
|
||||
setattr(row, uuid_attr, new_uuid)
|
||||
row.status, row.message = "pending", None
|
||||
row.previous_payload = prior_live_payload if existing is not None else None
|
||||
row.last_payload, row.revert_deadline, row.deployed_at = payload, _now() + window, _now()
|
||||
await _checkpoint(db)
|
||||
await apply()
|
||||
except OpnsenseAPIError as exc:
|
||||
ambiguous = isinstance(exc, OpnsenseAmbiguousError)
|
||||
if wrote:
|
||||
# written, apply unconfirmed: possibly live — status "error" but the deadline stays armed
|
||||
row.status, row.message = "error", str(exc)
|
||||
row.revert_deadline, row.deployed_at = _now() + window, _now()
|
||||
elif existing is not None and ambiguous:
|
||||
# the update may or may not have been applied: keep the armed rollback
|
||||
row.status, row.message = "error", f"{exc} (Ausgang unklar, wird zurückgesetzt)"
|
||||
row.deployed_at = _now()
|
||||
elif existing is not None:
|
||||
# a refusal the firewall answered: nothing changed, restore what was there
|
||||
for name, value in before.items():
|
||||
setattr(row, name, value)
|
||||
row.status, row.message, row.deployed_at = "error", str(exc), _now()
|
||||
elif ambiguous:
|
||||
row.status, row.message = "revert_failed", f"{exc} — {UNKNOWN_OUTCOME}"
|
||||
else:
|
||||
row.status, row.message = "error", str(exc)
|
||||
row.last_payload, row.revert_deadline = None, None
|
||||
return row
|
||||
|
||||
# 4. confirmed: the confirmation window starts now
|
||||
row.status, row.message = "pending", None
|
||||
row.revert_deadline, row.deployed_at = _now() + window, _now()
|
||||
return row
|
||||
@@ -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)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Policy for the address under which a firewall's API is reached.
|
||||
|
||||
The API key/secret travel as HTTP Basic credentials, so a plain ``http://``
|
||||
address would hand them to anyone on the path. ``verify_tls`` is a different
|
||||
control (does the certificate get checked) and does not upgrade or reject an
|
||||
``http://`` URL, hence the scheme is enforced here — when a host is created
|
||||
and again, for rows that already exist, right before a request is built.
|
||||
``ALLOW_INSECURE_FIREWALL_URLS=true`` is the explicit, deployment-level
|
||||
escape hatch for lab setups.
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def check_base_url(value: str) -> str | None:
|
||||
"""Return a reason (German, user-facing) if the URL must not be used, else None."""
|
||||
try:
|
||||
parts = urlsplit(value.strip())
|
||||
parts.port # noqa: B018 - raises ValueError on an invalid port
|
||||
except ValueError:
|
||||
return "Ungültige Adresse"
|
||||
if parts.scheme not in ("https", "http") or not parts.hostname:
|
||||
return "Die Adresse muss mit https:// beginnen und einen Host enthalten"
|
||||
if parts.scheme == "http" and not settings.allow_insecure_firewall_urls:
|
||||
return (
|
||||
"Unverschlüsseltes http:// ist nicht erlaubt (API-Zugangsdaten würden im Klartext "
|
||||
"übertragen) – bitte https:// verwenden"
|
||||
)
|
||||
if parts.username is not None or parts.password is not None:
|
||||
return "Zugangsdaten gehören nicht in die Adresse"
|
||||
if parts.query or parts.fragment:
|
||||
return "Die Adresse darf keine Parameter oder Fragmente enthalten"
|
||||
return None
|
||||
|
||||
|
||||
def validate_base_url(value: str) -> str:
|
||||
reason = check_base_url(value)
|
||||
if reason:
|
||||
raise ValueError(reason)
|
||||
return value
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_SUFFIX = ".psk"
|
||||
|
||||
@@ -21,9 +22,7 @@ def _path(tunnel_id: uuid.UUID) -> Path:
|
||||
|
||||
def save_secret(tunnel_id: uuid.UUID, secret: str) -> None:
|
||||
path = _path(tunnel_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_secret(tunnel_id: uuid.UUID) -> str | None:
|
||||
@@ -35,3 +34,12 @@ def read_secret(tunnel_id: uuid.UUID) -> str | None:
|
||||
|
||||
def delete_secret(tunnel_id: uuid.UUID) -> None:
|
||||
_path(tunnel_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def version(tunnel_id: uuid.UUID) -> str | None:
|
||||
"""Opaque change marker (last write time) — lets an approval token notice a
|
||||
rotated secret without containing, or being derivable from, the secret."""
|
||||
try:
|
||||
return str(_path(tunnel_id).stat().st_mtime_ns)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
@@ -8,7 +8,8 @@ import hashlib
|
||||
import json
|
||||
|
||||
from app.models.policy import IpsecTunnelRevision
|
||||
from app.services.ipsec_deploy_service import build_payloads
|
||||
from app.services import ipsec_psk_store
|
||||
from app.services.ipsec_deploy_service import _redact_for_storage, build_payloads
|
||||
from app.services.policy_service import effective_hosts # noqa: F401 - re-exported for routers/ipsec.py
|
||||
|
||||
IPSEC_FIELDS = (
|
||||
@@ -33,6 +34,9 @@ def revision(db, tunnel, note):
|
||||
|
||||
def preview(tunnel):
|
||||
payloads = build_payloads(tunnel)
|
||||
# What deployments persist/compare is the redacted form (secret -> hash);
|
||||
# comparing the raw payload with it would flag an untouched secret as changed.
|
||||
comparable = _redact_for_storage(payloads)
|
||||
deployments = {d.host_id: d for d in tunnel.deployments}
|
||||
hosts = effective_hosts(tunnel)
|
||||
rows = []
|
||||
@@ -40,7 +44,7 @@ def preview(tunnel):
|
||||
dep = deployments.get(host.id)
|
||||
before = dep.last_payload if dep else None
|
||||
action = "create" if not dep or not dep.opnsense_connection_uuid else "update"
|
||||
if before == payloads and dep and dep.status == "success":
|
||||
if before == comparable and dep and dep.status == "success":
|
||||
action = "unchanged"
|
||||
if not host.enabled:
|
||||
action = "disabled"
|
||||
@@ -48,7 +52,7 @@ def preview(tunnel):
|
||||
if action not in ("disabled",):
|
||||
for section in ("connection", "local", "remote", "child", "psk"):
|
||||
before_section = (before or {}).get(section, {})
|
||||
after_section = payloads[section]
|
||||
after_section = comparable[section]
|
||||
for key, value in after_section.items():
|
||||
if key == "connection":
|
||||
continue # UUID filled in at deploy time, not a user-visible field
|
||||
@@ -57,7 +61,7 @@ def preview(tunnel):
|
||||
label = "Pre-Shared-Key" if section == "psk" and key == "Key" else f"{section}.{key}"
|
||||
# Never surface the PSK's own value in a diff view.
|
||||
shown_before = "•••" if section == "psk" and key == "Key" and prior else prior
|
||||
shown_after = "•••" if section == "psk" and key == "Key" else value
|
||||
shown_after = "•••" if section == "psk" and key == "Key" and value else value
|
||||
changes.append({"field": label, "before": shown_before, "after": shown_after})
|
||||
rows.append({
|
||||
"host_id": str(host.id), "host_name": host.name, "action": action,
|
||||
@@ -89,6 +93,9 @@ def preview(tunnel):
|
||||
warnings.append("Für diesen Tunnel ist noch kein Pre-Shared-Key hinterlegt.")
|
||||
state = {
|
||||
"snapshot": snapshot(tunnel), "rows": rows,
|
||||
# Rotating only the secret changes nothing else in the snapshot — bind the
|
||||
# approval to the stored secret's version so an old approval cannot cover it.
|
||||
"secret_version": ipsec_psk_store.version(tunnel.id),
|
||||
"hosts": [(str(h.id), h.base_url, h.enabled, h.verify_tls) for h in hosts],
|
||||
"deployments": sorted(
|
||||
[(str(d.host_id), d.opnsense_connection_uuid, d.opnsense_local_uuid, d.opnsense_remote_uuid,
|
||||
@@ -109,12 +116,12 @@ def workflow(tunnel, deployments):
|
||||
hosts = effective_hosts(tunnel)
|
||||
active = [h for h in hosts if h.enabled]
|
||||
by_host = {d.host_id: d for d in deployments}
|
||||
payloads = build_payloads(tunnel)
|
||||
comparable = _redact_for_storage(build_payloads(tunnel))
|
||||
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 != payloads),
|
||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != comparable),
|
||||
"errors": sum(d.status == "error" for d in deployments),
|
||||
"retained": sum(d.host_id not in target_ids for d in deployments),
|
||||
}
|
||||
|
||||
@@ -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,453 @@
|
||||
"""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 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 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"])
|
||||
# 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)
|
||||
try:
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
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)
|
||||
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,6 +3,7 @@ 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
|
||||
@@ -14,7 +15,9 @@ 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 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 +56,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 +88,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 +124,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 +150,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:
|
||||
@@ -215,3 +264,29 @@ 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
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
@@ -30,7 +31,9 @@ _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",
|
||||
}
|
||||
|
||||
|
||||
@@ -67,20 +70,43 @@ async def send_webhook(settings_row: NotificationSettings, problem: Notification
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings_row.webhook_signing_secret_set:
|
||||
secret = notification_secret_store.read_secret("webhook_signing_secret")
|
||||
if secret:
|
||||
signature = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
||||
headers["X-OPNexus-Signature"] = f"sha256={signature}"
|
||||
if not secret:
|
||||
# Fail closed: silently sending an UNSIGNED message to a receiver that
|
||||
# was told to expect signatures would change the authentication
|
||||
# behaviour without anyone noticing.
|
||||
raise NotificationDeliveryError(
|
||||
"Signaturschlüssel für den Webhook ist konfiguriert, aber nicht lesbar – bitte neu setzen"
|
||||
)
|
||||
signature = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
||||
headers["X-OPNexus-Signature"] = f"sha256={signature}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.opnsense_timeout) as client:
|
||||
resp = await client.post(settings_row.webhook_url, content=raw, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise NotificationDeliveryError(f"Webhook-Zustellung fehlgeschlagen: {exc}") from exc
|
||||
# str(exc) names the full URL, which often is the credential itself
|
||||
# (token in path/query) — keep it out of messages and logs.
|
||||
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
|
||||
raise NotificationDeliveryError(f"Webhook-Zustellung fehlgeschlagen ({reason})") from None
|
||||
|
||||
|
||||
def _tls_context() -> ssl.SSLContext:
|
||||
"""Verifying context (certificate chain AND hostname). smtplib's own default
|
||||
when no context is passed is an *unverifying* one — the connection would be
|
||||
encrypted but could be impersonated."""
|
||||
return ssl.create_default_context(cafile=settings.smtp_ca_file or None)
|
||||
|
||||
|
||||
def _send_email_sync(settings_row: NotificationSettings, subject: str, body: str) -> None:
|
||||
password = notification_secret_store.read_secret("smtp_password")
|
||||
authenticated = bool(settings_row.smtp_username and settings_row.smtp_password_set)
|
||||
if authenticated and not password:
|
||||
raise NotificationDeliveryError("SMTP-Passwort ist konfiguriert, aber nicht lesbar – bitte neu setzen")
|
||||
if authenticated and settings_row.smtp_security == "none" and not settings.allow_insecure_smtp_auth:
|
||||
raise NotificationDeliveryError(
|
||||
"SMTP-Anmeldung ohne Verschlüsselung ist deaktiviert – STARTTLS oder SSL/TLS wählen"
|
||||
)
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = settings_row.smtp_from_address
|
||||
@@ -89,17 +115,22 @@ def _send_email_sync(settings_row: NotificationSettings, subject: str, body: str
|
||||
|
||||
timeout = settings.notification_smtp_timeout_seconds
|
||||
if settings_row.smtp_security == "ssl":
|
||||
client = smtplib.SMTP_SSL(settings_row.smtp_host, settings_row.smtp_port, timeout=timeout)
|
||||
client = smtplib.SMTP_SSL(
|
||||
settings_row.smtp_host, settings_row.smtp_port, timeout=timeout, context=_tls_context()
|
||||
)
|
||||
else:
|
||||
client = smtplib.SMTP(settings_row.smtp_host, settings_row.smtp_port, timeout=timeout)
|
||||
try:
|
||||
if settings_row.smtp_security == "starttls":
|
||||
client.starttls()
|
||||
if settings_row.smtp_username and password:
|
||||
client.starttls(context=_tls_context())
|
||||
if authenticated:
|
||||
client.login(settings_row.smtp_username, password)
|
||||
client.send_message(message)
|
||||
finally:
|
||||
client.quit()
|
||||
try:
|
||||
client.quit()
|
||||
except (smtplib.SMTPException, OSError):
|
||||
client.close()
|
||||
|
||||
|
||||
async def send_email(settings_row: NotificationSettings, subject: str, body: str) -> None:
|
||||
@@ -107,5 +138,7 @@ async def send_email(settings_row: NotificationSettings, subject: str, body: str
|
||||
raise NotificationDeliveryError("SMTP-Konfiguration unvollständig")
|
||||
try:
|
||||
await asyncio.to_thread(_send_email_sync, settings_row, subject, body)
|
||||
except NotificationDeliveryError:
|
||||
raise
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise NotificationDeliveryError(f"E-Mail-Zustellung fehlgeschlagen: {exc}") from exc
|
||||
@@ -8,6 +8,7 @@ webhook config for the whole install).
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_SUFFIX = ".secret"
|
||||
|
||||
@@ -18,9 +19,7 @@ def _path(name: str) -> Path:
|
||||
|
||||
def save_secret(name: str, secret: str) -> None:
|
||||
path = _path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_secret(name: str) -> str | None:
|
||||
|
||||
@@ -28,14 +28,23 @@ _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),
|
||||
]
|
||||
|
||||
# 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",
|
||||
"poll_interval_seconds",
|
||||
]
|
||||
for field in simple_fields:
|
||||
|
||||
@@ -10,7 +10,8 @@ from app.models.one_to_one import OneToOneRule
|
||||
from app.models.one_to_one_deployment import OneToOneDeployment
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -53,48 +54,23 @@ async def _deploy_to_host(db: AsyncSession, rule: OneToOneRule, host: OpnsenseHo
|
||||
|
||||
prior_uuid = existing.opnsense_one_to_one_uuid if existing else None
|
||||
prior_live_payload = existing.last_payload if existing else None
|
||||
new_uuid = prior_uuid
|
||||
try:
|
||||
if prior_uuid:
|
||||
await opnsense_client.set_one_to_one_rule(host, prior_uuid, payload)
|
||||
else:
|
||||
result = await opnsense_client.add_one_to_one_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAPIError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
await opnsense_client.apply_one_to_one(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
if existing:
|
||||
existing.status = "error"
|
||||
existing.opnsense_one_to_one_uuid = new_uuid
|
||||
existing.message = str(exc)
|
||||
existing.revert_deadline = None
|
||||
existing.deployed_at = _now()
|
||||
return existing
|
||||
deployment = OneToOneDeployment(
|
||||
one_to_one_id=rule.id, host_id=host.id, status="error", message=str(exc),
|
||||
opnsense_one_to_one_uuid=new_uuid, deployed_at=_now(),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds)
|
||||
if existing:
|
||||
existing.status = "pending"
|
||||
existing.message = None
|
||||
existing.opnsense_one_to_one_uuid = new_uuid
|
||||
existing.deployed_at = _now()
|
||||
existing.revert_deadline = deadline
|
||||
existing.previous_payload = prior_live_payload
|
||||
existing.last_payload = payload
|
||||
return existing
|
||||
async def write(uuid_):
|
||||
if uuid_:
|
||||
await opnsense_client.set_one_to_one_rule(host, uuid_, payload)
|
||||
return uuid_
|
||||
result = await opnsense_client.add_one_to_one_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAmbiguousError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
return new_uuid
|
||||
|
||||
deployment = OneToOneDeployment(
|
||||
one_to_one_id=rule.id, host_id=host.id, status="pending", message=None,
|
||||
opnsense_one_to_one_uuid=new_uuid, deployed_at=_now(), revert_deadline=deadline,
|
||||
previous_payload=None, last_payload=payload,
|
||||
deployment = await push_to_host(
|
||||
db, existing=existing, uuid_attr="opnsense_one_to_one_uuid",
|
||||
make=lambda **fields: OneToOneDeployment(one_to_one_id=rule.id, host_id=host.id, **fields),
|
||||
prior_uuid=prior_uuid, prior_live_payload=prior_live_payload, payload=payload,
|
||||
write=write, apply=lambda: opnsense_client.apply_one_to_one(host),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
|
||||
@@ -112,7 +88,7 @@ async def deploy_one_to_one(db: AsyncSession, rule: OneToOneRule, hosts: list[Op
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = f"1:1 NAT wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}"
|
||||
deployment.revert_deadline = None
|
||||
# The change is live: keep the revert deadline so an unconfirmed result is still undone.
|
||||
await db.commit()
|
||||
return deployments
|
||||
|
||||
@@ -138,7 +114,7 @@ async def sweep_expired_one_to_one_deployments(db: AsyncSession) -> list[OneToOn
|
||||
await policy_lock(db)
|
||||
result = await db.execute(
|
||||
select(OneToOneDeployment).where(
|
||||
OneToOneDeployment.status == "pending", OneToOneDeployment.revert_deadline <= _now()
|
||||
OneToOneDeployment.status.in_(("pending", "error")), OneToOneDeployment.revert_deadline <= _now()
|
||||
)
|
||||
)
|
||||
expired = list(result.scalars().all())
|
||||
@@ -164,7 +140,7 @@ async def undeploy_one_to_one(db: AsyncSession, rule: OneToOneRule) -> 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()
|
||||
|
||||
@@ -15,7 +15,8 @@ citations):
|
||||
Instances.Instance: vpnid (VPNIdField, Required, MinimumValue 1, NO
|
||||
<Default> — never sent by this app; OPNsense's own UI never prompts for
|
||||
one either, so its field-type presumably auto-assigns one the same way
|
||||
on addAction — NOT independently verified, flagged rather than guessed),
|
||||
on addAction — VERIFIED LIVE 2026-10-09 on 26.7.5: it is NOT auto-assigned, add() fails
|
||||
with "instance.vpnid: A value is required." — see _next_vpnid),
|
||||
enabled (BooleanField, Default 1), dev_type (OptionValues
|
||||
tun/tap/ovpn(DCO), Default "tun" — never sent, relies on the model
|
||||
default exactly like IPsec relies on Child.mode defaulting to "tunnel"),
|
||||
@@ -27,7 +28,8 @@ citations):
|
||||
section below, this is the key finding that reshaped v1), server
|
||||
(NetworkField, NetMaskRequired Y — server-role tunnel network), cert /
|
||||
ca (CertificateField, i.e. ModelRelationField into OPNsense's own
|
||||
Trust\\Cert store), tls_key (ModelRelationField -> this SAME model's
|
||||
Trust\\Cert store; values are the Trust-Store REFIDs, not row UUIDs —
|
||||
verified live 2026-10-09), tls_key (ModelRelationField -> this SAME model's
|
||||
StaticKeys.StaticKey, display "description,mode"), auth/data-ciphers
|
||||
(cipher suite OptionFields — v1 always omits these, relying on
|
||||
OPNsense's own defaults, exactly like IPsec's proposals default to
|
||||
@@ -238,6 +240,18 @@ def build_payloads(instance: OpenVpnInstance, host_id: uuid.UUID) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _next_vpnid(host: OpnsenseHost) -> str:
|
||||
data = await opnsense_client.search_openvpn_instances(host)
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
used = []
|
||||
for row in rows:
|
||||
try:
|
||||
used.append(int(row.get("vpnid")))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return str(max(used, default=0) + 1)
|
||||
|
||||
|
||||
async def _write_config(host: OpnsenseHost, payloads: dict, deployment: OpenVpnInstanceDeployment) -> None:
|
||||
"""Writes the StaticKey (if enabled) then the Instance, in that
|
||||
dependency order, persisting each UUID onto `deployment` as soon as
|
||||
@@ -270,6 +284,10 @@ async def _write_config(host: OpnsenseHost, payloads: dict, deployment: OpenVpnI
|
||||
if deployment.opnsense_instance_uuid:
|
||||
await opnsense_client.set_openvpn_instance(host, deployment.opnsense_instance_uuid, instance_wire)
|
||||
else:
|
||||
# vpnid is Required and the firewall does NOT assign it (verified live
|
||||
# on 26.7: "instance.vpnid: A value is required."), so pick the next
|
||||
# free one. It stays untouched on later set() calls.
|
||||
instance_wire["vpnid"] = await _next_vpnid(host)
|
||||
result = await opnsense_client.add_openvpn_instance(host, instance_wire)
|
||||
deployment.opnsense_instance_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not deployment.opnsense_instance_uuid:
|
||||
@@ -370,7 +388,7 @@ async def undeploy_instance(db: AsyncSession, instance: OpenVpnInstance) -> 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()
|
||||
|
||||
@@ -21,6 +21,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_SUFFIX = ".ovpnkey"
|
||||
|
||||
@@ -31,9 +32,7 @@ def _path(instance_id: uuid.UUID) -> Path:
|
||||
|
||||
def save_secret(instance_id: uuid.UUID, secret: str) -> None:
|
||||
path = _path(instance_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_secret(instance_id: uuid.UUID) -> str | None:
|
||||
@@ -45,3 +44,12 @@ def read_secret(instance_id: uuid.UUID) -> str | None:
|
||||
|
||||
def delete_secret(instance_id: uuid.UUID) -> None:
|
||||
_path(instance_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def version(instance_id: uuid.UUID) -> str | None:
|
||||
"""Opaque change marker (last write time) — lets an approval token notice a
|
||||
rotated secret without containing, or being derivable from, the secret."""
|
||||
try:
|
||||
return str(_path(instance_id).stat().st_mtime_ns)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
@@ -7,7 +7,8 @@ import hashlib
|
||||
import json
|
||||
|
||||
from app.models.policy import OpenVpnInstanceRevision
|
||||
from app.services.openvpn_deploy_service import MissingCertError, build_payloads
|
||||
from app.services import openvpn_key_store
|
||||
from app.services.openvpn_deploy_service import MissingCertError, _redact_for_storage, build_payloads
|
||||
from app.services.policy_service import effective_hosts # noqa: F401 - re-exported for routers/openvpn.py
|
||||
|
||||
OPENVPN_FIELDS = (
|
||||
@@ -56,6 +57,8 @@ def preview(instance):
|
||||
"changes": [],
|
||||
})
|
||||
continue
|
||||
# Compare in the stored (secret -> hash) form, see ipsec_service.preview().
|
||||
payloads = _redact_for_storage(payloads)
|
||||
action = "create" if not dep or not dep.opnsense_instance_uuid else "update"
|
||||
if before == payloads and dep and dep.status == "success":
|
||||
action = "unchanged"
|
||||
@@ -108,6 +111,7 @@ def preview(instance):
|
||||
blocked = instance.tls_key_enabled and not instance.tls_key_material_set
|
||||
state = {
|
||||
"snapshot": snapshot(instance), "rows": rows,
|
||||
"secret_version": openvpn_key_store.version(instance.id),
|
||||
"hosts": [(str(h.id), h.base_url, h.enabled, h.verify_tls) for h in hosts],
|
||||
"deployments": sorted(
|
||||
[(str(d.host_id), d.opnsense_statickey_uuid, d.opnsense_instance_uuid, d.last_payload,
|
||||
@@ -130,7 +134,7 @@ def _host_changed(instance, by_host, host):
|
||||
except MissingCertError:
|
||||
return True
|
||||
dep = by_host.get(host.id)
|
||||
return dep is None or dep.last_payload != payloads
|
||||
return dep is None or dep.last_payload != _redact_for_storage(payloads)
|
||||
|
||||
|
||||
def workflow(instance, deployments):
|
||||
|
||||
@@ -16,7 +16,8 @@ import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import opnsense_secret_store, tls_pin_service
|
||||
from app.services import firewall_url, opnsense_secret_store, tls_pin_service
|
||||
from app.services.redaction import redact_data, redact_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,7 +26,18 @@ class OpnsenseAPIError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OpnsenseAmbiguousError(OpnsenseAPIError):
|
||||
"""The request may have been executed although no usable answer arrived (lost
|
||||
reply, timeout while waiting, server error, no UUID returned). A refusal the
|
||||
firewall actually answered is a plain OpnsenseAPIError; this one means the
|
||||
outcome of a write is unknown and must be reconciled, not retried blindly."""
|
||||
|
||||
|
||||
def _client(host: OpnsenseHost) -> httpx.AsyncClient:
|
||||
# Before the secret is even read: an http:// host must never receive credentials.
|
||||
reason = firewall_url.check_base_url(host.base_url)
|
||||
if reason:
|
||||
raise OpnsenseAPIError(f"Host '{host.name}': {reason}")
|
||||
secret = opnsense_secret_store.read_secret(host.id)
|
||||
if not secret:
|
||||
raise OpnsenseAPIError(f"Kein API-Secret für Host '{host.name}' hinterlegt")
|
||||
@@ -44,7 +56,12 @@ async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> h
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise OpnsenseAPIError(f"{exc.response.status_code} {path}: {exc.response.text}") from exc
|
||||
# The body may reflect submitted secrets: keep only a scrubbed, bounded excerpt.
|
||||
secret = opnsense_secret_store.read_secret(host.id)
|
||||
error = OpnsenseAmbiguousError if exc.response.status_code >= 500 else OpnsenseAPIError
|
||||
raise error(
|
||||
f"{exc.response.status_code} {path}: {redact_text(exc.response.text, host.api_key, secret)}"
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
if host.tls_ca_pem and "CERTIFICATE_VERIFY_FAILED" in str(exc):
|
||||
raise OpnsenseAPIError(
|
||||
@@ -54,12 +71,17 @@ async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> h
|
||||
if isinstance(exc, httpx.RemoteProtocolError):
|
||||
# The firewall answered, but the HTTP framing is broken (seen with
|
||||
# OPNsense 26.7: kernel TLS corrupts responses above ~64 KiB).
|
||||
raise OpnsenseAPIError(
|
||||
raise OpnsenseAmbiguousError(
|
||||
f"{host.name} hat eine beschädigte HTTP-Antwort geliefert ({exc}). "
|
||||
"Bei OPNsense 26.x hilft meist `kern.ipc.tls.enable=0` (System ▸ Einstellungen ▸ "
|
||||
"Tunables), siehe Handbuch „Fehlerbehebung“."
|
||||
) from exc
|
||||
raise OpnsenseAPIError(f"Verbindung zu {host.name} fehlgeschlagen: {exc}") from exc
|
||||
# Once the request was (partly) sent, a failed read/write says nothing about
|
||||
# whether the firewall executed it; a failed connect means it never did.
|
||||
error = OpnsenseAmbiguousError if isinstance(
|
||||
exc, (httpx.ReadTimeout, httpx.ReadError, httpx.WriteTimeout, httpx.WriteError)
|
||||
) else OpnsenseAPIError
|
||||
raise error(f"Verbindung zu {host.name} fehlgeschlagen: {exc}") from exc
|
||||
|
||||
|
||||
async def _request(host: OpnsenseHost, method: str, path: str, **kwargs) -> Any:
|
||||
@@ -167,8 +189,7 @@ def _mutation_result(data: Any, operation: str, results: set[str]) -> dict:
|
||||
status = str(data.get("status", "")).strip().lower()
|
||||
accepted = result in results if "result" in data else status in {"ok", "done"}
|
||||
if data.get("validations") or not accepted or status in {"error", "failed"}:
|
||||
detail = json.dumps(data, ensure_ascii=False)[:350]
|
||||
raise OpnsenseAPIError(f"{operation} von Firewall nicht bestätigt: {detail}")
|
||||
raise OpnsenseAPIError(f"{operation} von Firewall nicht bestätigt: {redact_data(data)}")
|
||||
return data
|
||||
|
||||
|
||||
@@ -536,6 +557,19 @@ async def del_ipsec_psk(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
return _mutation_result(await _request(host, "POST", f"/ipsec/preSharedKeys/delItem/{uuid_}"), "IPsec Pre-Shared-Key entfernen", {"deleted", "not found"})
|
||||
|
||||
|
||||
async def ensure_ipsec_enabled(host: OpnsenseHost) -> bool:
|
||||
"""Switch the service-wide IPsec flag on (`ipsec/connections/isEnabled` /
|
||||
`toggle/1`, ConnectionsController — it is NOT part of the service
|
||||
controller). Without it the connections are stored but no daemon runs
|
||||
(live 26.7.6: is_enabled=false, service status "disabled", yet
|
||||
reconfigure answered ok). Returns True when it had to be switched on."""
|
||||
data = await _request(host, "GET", "/ipsec/connections/is_enabled")
|
||||
if isinstance(data, dict) and data.get("enabled") is True:
|
||||
return False
|
||||
_mutation_result(await _request(host, "POST", "/ipsec/connections/toggle/1"), "IPsec aktivieren", {"ok"})
|
||||
return True
|
||||
|
||||
|
||||
async def reconfigure_ipsec(host: OpnsenseHost) -> dict:
|
||||
"""`ipsec/service/reconfigure` (ServiceController::reconfigureAction,
|
||||
inherited from ApiMutableServiceControllerBase) — soft-reloads a
|
||||
@@ -735,19 +769,12 @@ async def reconfigure_openvpn(host: OpnsenseHost) -> dict:
|
||||
# ServiceController's own `$internalServiceEnabled = 'enabled'` /
|
||||
# `$internalServiceClass = General::class` tie together for the
|
||||
# framework's generic service-status plumbing. NOT INDEPENDENTLY
|
||||
# VERIFIED: whether `wireguard configure` (the configd action
|
||||
# reconfigureAction shells out to) actually brings up any wg-quick
|
||||
# interface end-to-end while General.enabled=0 — reconfigureAction's
|
||||
# OWN PHP body never reads $internalServiceEnabled at all (it always
|
||||
# runs unconditionally), so this depends on configd's own action
|
||||
# script content, which is out of the controller/model source layer
|
||||
# this app's verification standard covers (same boundary IPsec/
|
||||
# OpenVPN draw). Flagged as a real, source-confirmed structural
|
||||
# difference from IPsec/OpenVPN (neither of which have an equivalent
|
||||
# separate master switch) rather than silently ignored — see README:
|
||||
# operators should confirm "VPN > WireGuard > General" is enabled at
|
||||
# least once via the OPNsense UI before relying on OPNexus-pushed
|
||||
# tunnels.
|
||||
# VERIFIED LIVE (26.7.5, 2026-10-09): with General.enabled=0 a deploy
|
||||
# writes Server+Peer correctly but NO wg interface comes up and
|
||||
# `wireguard/service/show` stays empty, while OPNexus used to report
|
||||
# "success". Enabling it (wireguard/general/set + reconfigure) brings
|
||||
# wg0 up. wireguard_deploy_service therefore switches it on before the
|
||||
# reconfigure (ensure_wireguard_enabled below); undeploy never touches it.
|
||||
#
|
||||
# Field names verified against Server.xml/Client.xml (fetched directly,
|
||||
# not summarized) — see wireguard_deploy_service.py for the exact
|
||||
@@ -800,6 +827,21 @@ async def del_wireguard_peer(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
return _mutation_result(await _request(host, "POST", f"/wireguard/client/delClient/{uuid_}"), "WireGuard-Peer entfernen", {"deleted", "not found"})
|
||||
|
||||
|
||||
async def ensure_wireguard_enabled(host: OpnsenseHost) -> bool:
|
||||
"""Turn the service-wide WireGuard switch (General.enabled, default 0) on
|
||||
if it is off. Returns True when it had to be switched on."""
|
||||
data = await _request(host, "GET", "/wireguard/general/get")
|
||||
general = data.get("general", {}) if isinstance(data, dict) else {}
|
||||
if str(general.get("enabled", "0")) == "1":
|
||||
return False
|
||||
_mutation_result(
|
||||
await _request(host, "POST", "/wireguard/general/set", json={"general": {"enabled": "1"}}),
|
||||
"WireGuard-Dienst aktivieren",
|
||||
{"saved"},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def reconfigure_wireguard(host: OpnsenseHost) -> dict:
|
||||
"""`wireguard/service/reconfigure` (ServiceController::reconfigureAction)
|
||||
— runs interface registration + template reload + `wireguard configure`.
|
||||
@@ -817,6 +859,10 @@ async def reconfigure_wireguard(host: OpnsenseHost) -> dict:
|
||||
# CertController.php directly) — see managed_certificate_service.py's
|
||||
# module docstring for the full field-mapping writeup.
|
||||
#
|
||||
# URL SEGMENTS ARE NOT PHP METHOD NAMES: the router strips the `Action`
|
||||
# suffix, so addAction() is POST /trust/cert/add (the `...Action` URL is a
|
||||
# 404 — found live 2026-10-09 on 26.7.5, the respx mocks had hidden it).
|
||||
#
|
||||
# CertController extends ApiMutableModelControllerBase exactly like
|
||||
# FilterController/ConnectionsController/etc: searchAction (already used
|
||||
# via search_certificates() above) / getAction / addAction / setAction /
|
||||
@@ -845,11 +891,11 @@ def _trust_cert_result(data: Any, operation: str) -> dict:
|
||||
|
||||
|
||||
async def add_trust_cert(host: OpnsenseHost, payload: dict) -> dict:
|
||||
return _trust_cert_result(await _request(host, "POST", "/trust/cert/addAction", json={"cert": payload}), "Zertifikat anlegen")
|
||||
return _trust_cert_result(await _request(host, "POST", "/trust/cert/add", json={"cert": payload}), "Zertifikat anlegen")
|
||||
|
||||
|
||||
async def set_trust_cert(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
|
||||
return _trust_cert_result(await _request(host, "POST", f"/trust/cert/setAction/{uuid_}", json={"cert": payload}), "Zertifikat ändern")
|
||||
return _trust_cert_result(await _request(host, "POST", f"/trust/cert/set/{uuid_}", json={"cert": payload}), "Zertifikat ändern")
|
||||
|
||||
|
||||
async def del_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
@@ -858,7 +904,7 @@ async def del_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
# matching every other del* call in this module (add_alias_item's
|
||||
# del_alias_item sibling etc.), so this reuses the same
|
||||
# {"deleted", "not found"} results set they all share.
|
||||
return _mutation_result(await _request(host, "POST", f"/trust/cert/delAction/{uuid_}"), "Zertifikat entfernen", {"deleted", "not found"})
|
||||
return _mutation_result(await _request(host, "POST", f"/trust/cert/del/{uuid_}"), "Zertifikat entfernen", {"deleted", "not found"})
|
||||
|
||||
|
||||
async def get_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
@@ -919,15 +965,15 @@ def _acme_result(data: Any, operation: str) -> dict:
|
||||
|
||||
|
||||
async def add_acme_account(host: OpnsenseHost, payload: dict) -> dict:
|
||||
return _acme_result(await _request(host, "POST", "/acmeclient/accounts/addAction", json={"account": payload}), "ACME-Account anlegen")
|
||||
return _acme_result(await _request(host, "POST", "/acmeclient/accounts/add", json={"account": payload}), "ACME-Account anlegen")
|
||||
|
||||
|
||||
async def update_acme_account(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
|
||||
return _acme_result(await _request(host, "POST", f"/acmeclient/accounts/updateAction/{uuid_}", json={"account": payload}), "ACME-Account ändern")
|
||||
return _acme_result(await _request(host, "POST", f"/acmeclient/accounts/update/{uuid_}", json={"account": payload}), "ACME-Account ändern")
|
||||
|
||||
|
||||
async def del_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
return _mutation_result(await _request(host, "POST", f"/acmeclient/accounts/delAction/{uuid_}"), "ACME-Account entfernen", {"deleted", "not found"})
|
||||
return _mutation_result(await _request(host, "POST", f"/acmeclient/accounts/del/{uuid_}"), "ACME-Account entfernen", {"deleted", "not found"})
|
||||
|
||||
|
||||
async def get_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
@@ -935,15 +981,15 @@ async def get_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
|
||||
|
||||
async def add_acme_certificate(host: OpnsenseHost, payload: dict) -> dict:
|
||||
return _acme_result(await _request(host, "POST", "/acmeclient/certificates/addAction", json={"certificate": payload}), "ACME-Zertifikatsanfrage anlegen")
|
||||
return _acme_result(await _request(host, "POST", "/acmeclient/certificates/add", json={"certificate": payload}), "ACME-Zertifikatsanfrage anlegen")
|
||||
|
||||
|
||||
async def update_acme_certificate(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
|
||||
return _acme_result(await _request(host, "POST", f"/acmeclient/certificates/updateAction/{uuid_}", json={"certificate": payload}), "ACME-Zertifikatsanfrage ändern")
|
||||
return _acme_result(await _request(host, "POST", f"/acmeclient/certificates/update/{uuid_}", json={"certificate": payload}), "ACME-Zertifikatsanfrage ändern")
|
||||
|
||||
|
||||
async def del_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
return _mutation_result(await _request(host, "POST", f"/acmeclient/certificates/delAction/{uuid_}"), "ACME-Zertifikatsanfrage entfernen", {"deleted", "not found"})
|
||||
return _mutation_result(await _request(host, "POST", f"/acmeclient/certificates/del/{uuid_}"), "ACME-Zertifikatsanfrage entfernen", {"deleted", "not found"})
|
||||
|
||||
|
||||
async def get_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
@@ -962,7 +1008,7 @@ def _acme_action_result(data: Any, operation: str) -> dict:
|
||||
return data
|
||||
if str(data.get("result", "")).strip().lower() == "failed":
|
||||
raise OpnsenseAPIError(f"{operation}: von der Firewall abgelehnt (Objekt nicht gefunden oder keine POST-Anfrage)")
|
||||
raise OpnsenseAPIError(f"{operation}: unerwartete Antwortform der Firewall: {json.dumps(data, ensure_ascii=False)[:350]}")
|
||||
raise OpnsenseAPIError(f"{operation}: unerwartete Antwortform der Firewall: {redact_data(data)}")
|
||||
|
||||
|
||||
async def register_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
@@ -981,3 +1027,261 @@ async def sign_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
|
||||
against anything but a respx mock / fully-mocked write path in
|
||||
tests/verification."""
|
||||
return _acme_action_result(await _request(host, "POST", f"/acmeclient/certificates/sign/{uuid_}"), "ACME-Zertifikat ausstellen")
|
||||
|
||||
|
||||
# ── Network inventory (read-only) ────────────────────────────────────────
|
||||
# Interfaces\Api\{Overview,Assignment,VlanSettings,VipSettings}Controller,
|
||||
# Routing\Api\SettingsController and Routes\Api\RoutesController, verified
|
||||
# against core source (github.com/opnsense/core, master) AND live with real
|
||||
# read-only GETs on OPNsense 26.7.1/26.7.6 (2026-10-09). Shapes: the search*
|
||||
# calls return {"rows": [...]} (flat dicts with "%"-prefixed display
|
||||
# counterparts); interfacesInfo rows carry device/identifier/status/ipv4[].
|
||||
# Caution: vip_settings rows contain the CARP password in plain text — callers
|
||||
# must drop it (see network_service).
|
||||
async def get_interfaces_info(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/interfaces/overview/interfacesInfo", params={"rowCount": -1})
|
||||
|
||||
|
||||
async def search_interface_assignments(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/interfaces/assignment/searchItem", params={"rowCount": -1})
|
||||
|
||||
|
||||
async def search_vlans(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/interfaces/vlan_settings/searchItem", params={"rowCount": -1})
|
||||
|
||||
|
||||
async def search_vips(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/interfaces/vip_settings/searchItem", params={"rowCount": -1})
|
||||
|
||||
|
||||
async def search_gateways(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/routing/settings/searchGateway", params={"rowCount": -1})
|
||||
|
||||
|
||||
async def search_static_routes(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/routes/routes/searchroute", params={"rowCount": -1})
|
||||
|
||||
|
||||
# ── Network objects (write) ──────────────────────────────────────────────
|
||||
# One table for the four config objects handled by network_change_service.
|
||||
# Sources (core master, read 2026-10-09): Interfaces/Api/VlanSettingsController,
|
||||
# VipSettingsController, Routing/Api/SettingsController (gateways) and
|
||||
# Routes/Api/RoutesController. Each add/set takes {<body_key>: {fields}}; set/del
|
||||
# address the item by UUID; nothing takes effect before the object's reconfigure
|
||||
# call, which answers {"status": "ok"}. The VIP form posts address+prefix as ONE
|
||||
# field called "network".
|
||||
# The interface assignment (Interfaces/Api/AssignmentController, model NetworkInterface) is two-step:
|
||||
# add/set/del only stage the change (a todo file on the firewall); reconfigure ("interface apply") makes
|
||||
# it real. Its items are addressed by the interface identifier (wan, lan, opt1), not a UUID, and
|
||||
# deleting one makes OPNsense itself drop every rule/NAT entry that references it.
|
||||
NETWORK_ITEM_APIS = {
|
||||
"vlan": {"body_key": "vlan", "base": "/interfaces/vlan_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem",
|
||||
"reconfigure": "/interfaces/vlan_settings/reconfigure"},
|
||||
"vip": {"body_key": "vip", "base": "/interfaces/vip_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem",
|
||||
"reconfigure": "/interfaces/vip_settings/reconfigure"},
|
||||
"gateway": {"body_key": "gateway_item", "base": "/routing/settings", "add": "addGateway", "set": "setGateway",
|
||||
"del": "delGateway", "get": "getGateway", "search": "searchGateway",
|
||||
"reconfigure": "/routing/settings/reconfigure"},
|
||||
"route": {"body_key": "route", "base": "/routes/routes", "add": "addroute", "set": "setroute",
|
||||
"del": "delroute", "get": "getroute", "search": "searchroute",
|
||||
"reconfigure": "/routes/routes/reconfigure"},
|
||||
"interface": {"body_key": "interface", "base": "/interfaces/assignment", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem",
|
||||
"reconfigure": "/interfaces/assignment/reconfigure"},
|
||||
# DNS / DHCP (Unbound/Api/SettingsController, Dnsmasq/Api/SettingsController, Kea/Api/Dhcpv4Controller;
|
||||
# reconfigure is the service controller's inherited action). Read 2026-10-09.
|
||||
"dns_host": {"body_key": "host", "base": "/unbound/settings", "add": "addHostOverride", "set": "setHostOverride",
|
||||
"del": "delHostOverride", "get": "getHostOverride", "search": "searchHostOverride",
|
||||
"reconfigure": "/unbound/service/reconfigure"},
|
||||
"dhcp_host": {"body_key": "host", "base": "/dnsmasq/settings", "add": "addHost", "set": "setHost",
|
||||
"del": "delHost", "get": "getHost", "search": "searchHost",
|
||||
"reconfigure": "/dnsmasq/service/reconfigure"},
|
||||
"kea_subnet": {"body_key": "subnet4", "base": "/kea/dhcpv4", "add": "addSubnet", "set": "setSubnet",
|
||||
"del": "delSubnet", "get": "getSubnet", "search": "searchSubnet",
|
||||
"reconfigure": "/kea/service/reconfigure"},
|
||||
# System / interface devices / DNS+DHCP extras (same controllers pattern; read 2026-10-09).
|
||||
"tunable": {"body_key": "sysctl", "base": "/core/tunables", "add": "addItem", "set": "setItem", "del": "delItem",
|
||||
"get": "getItem", "search": "searchItem", "reconfigure": "/core/tunables/reconfigure"},
|
||||
"cron_job": {"body_key": "job", "base": "/cron/settings", "add": "addJob", "set": "setJob", "del": "delJob",
|
||||
"get": "getJob", "search": "searchJobs", "reconfigure": "/cron/service/reconfigure"},
|
||||
"syslog_destination": {"body_key": "destination", "base": "/syslog/settings", "add": "addDestination",
|
||||
"set": "setDestination", "del": "delDestination", "get": "getDestination",
|
||||
"search": "searchDestinations", "reconfigure": "/syslog/service/reconfigure"},
|
||||
"gateway_group": {"body_key": "gateway_group", "base": "/routing/group_settings", "add": "add", "set": "set",
|
||||
"del": "del", "get": "get", "search": "search", "reconfigure": "/routing/group_settings/reconfigure"},
|
||||
"lagg": {"body_key": "lagg", "base": "/interfaces/lagg_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/lagg_settings/reconfigure"},
|
||||
"bridge": {"body_key": "bridge", "base": "/interfaces/bridge_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/bridge_settings/reconfigure"},
|
||||
"loopback": {"body_key": "loopback", "base": "/interfaces/loopback_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/loopback_settings/reconfigure"},
|
||||
"gre": {"body_key": "gre", "base": "/interfaces/gre_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gre_settings/reconfigure"},
|
||||
"vxlan": {"body_key": "vxlan", "base": "/interfaces/vxlan_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/vxlan_settings/reconfigure"},
|
||||
"gif": {"body_key": "gif", "base": "/interfaces/gif_settings", "add": "addItem", "set": "setItem",
|
||||
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gif_settings/reconfigure"},
|
||||
"dns_forward": {"body_key": "dot", "base": "/unbound/settings", "add": "addForward", "set": "setForward",
|
||||
"del": "delForward", "get": "getForward", "search": "searchForward",
|
||||
"reconfigure": "/unbound/service/reconfigure"},
|
||||
"dhcp_range": {"body_key": "range", "base": "/dnsmasq/settings", "add": "addRange", "set": "setRange",
|
||||
"del": "delRange", "get": "getRange", "search": "searchRange",
|
||||
"reconfigure": "/dnsmasq/service/reconfigure"},
|
||||
"kea_reservation": {"body_key": "reservation", "base": "/kea/dhcpv4", "add": "addReservation",
|
||||
"set": "setReservation", "del": "delReservation", "get": "getReservation",
|
||||
"search": "searchReservation", "reconfigure": "/kea/service/reconfigure"},
|
||||
}
|
||||
|
||||
|
||||
def _nest(fields: dict) -> dict:
|
||||
"""{"option_data.routers": "x"} -> {"option_data": {"routers": "x"}} (the wire form of Kea's option block)."""
|
||||
out: dict = {}
|
||||
for key, value in fields.items():
|
||||
head, _, tail = key.partition(".")
|
||||
if tail:
|
||||
out.setdefault(head, {})[tail] = value
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
async def get_network_item(host: OpnsenseHost, kind: str, uuid: str | None) -> dict:
|
||||
api = NETWORK_ITEM_APIS[kind]
|
||||
path = f"{api['base']}/{api['get']}" + (f"/{uuid}" if uuid else "")
|
||||
data = await _request(host, "GET", path)
|
||||
item = data.get(api["body_key"]) if isinstance(data, dict) else None
|
||||
return item if isinstance(item, dict) else {}
|
||||
|
||||
|
||||
async def write_network_item(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> dict:
|
||||
api = NETWORK_ITEM_APIS[kind]
|
||||
if action == "add":
|
||||
return _mutation_result(
|
||||
await _request(host, "POST", f"{api['base']}/{api['add']}", json={api["body_key"]: _nest(fields)}),
|
||||
"Objekt anlegen", {"saved"})
|
||||
if action == "set":
|
||||
return _mutation_result(
|
||||
await _request(host, "POST", f"{api['base']}/{api['set']}/{uuid}", json={api["body_key"]: _nest(fields)}),
|
||||
"Objekt ändern", {"saved"})
|
||||
return _mutation_result(
|
||||
await _request(host, "POST", f"{api['base']}/{api['del']}/{uuid}"), "Objekt entfernen", {"deleted"})
|
||||
|
||||
|
||||
async def reconfigure_network(host: OpnsenseHost, kind: str) -> dict:
|
||||
return _mutation_result(
|
||||
await _request(host, "POST", NETWORK_ITEM_APIS[kind]["reconfigure"]), "Änderung anwenden", set())
|
||||
|
||||
|
||||
async def list_network_item_uuids(host: OpnsenseHost, kind: str) -> list[str]:
|
||||
api = NETWORK_ITEM_APIS[kind]
|
||||
data = await _request(host, "GET", f"{api['base']}/{api['search']}", params={"rowCount": -1})
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [str(r.get("uuid")) for r in rows if isinstance(r, dict) and r.get("uuid")]
|
||||
|
||||
|
||||
async def interface_changes_pending(host: OpnsenseHost) -> bool:
|
||||
"""True while staged interface changes wait for an apply (AssignmentController::pendingAction).
|
||||
Our own apply would apply those too, so a change must not start on top of them."""
|
||||
data = await _request(host, "GET", "/interfaces/assignment/pending")
|
||||
return isinstance(data, dict) and data.get("status") == "pending"
|
||||
|
||||
|
||||
async def _search_all(host: OpnsenseHost, path: str) -> dict:
|
||||
return await _request(host, "GET", path, params={"rowCount": -1})
|
||||
|
||||
|
||||
async def search_dns_hosts(host: OpnsenseHost) -> dict:
|
||||
return await _search_all(host, "/unbound/settings/searchHostOverride")
|
||||
|
||||
|
||||
async def search_dhcp_hosts(host: OpnsenseHost) -> dict:
|
||||
return await _search_all(host, "/dnsmasq/settings/searchHost")
|
||||
|
||||
|
||||
async def search_kea_subnets(host: OpnsenseHost) -> dict:
|
||||
return await _search_all(host, "/kea/dhcpv4/searchSubnet")
|
||||
|
||||
|
||||
async def search_kea_reservations(host: OpnsenseHost) -> dict:
|
||||
return await _search_all(host, "/kea/dhcpv4/searchReservation")
|
||||
|
||||
|
||||
async def search_network_objects(host: OpnsenseHost, kind: str) -> list[dict]:
|
||||
api = NETWORK_ITEM_APIS[kind]
|
||||
data = await _request(host, "GET", f"{api['base']}/{api['search']}", params={"rowCount": -1})
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
# ── ZFS snapshots (Core/Api/SnapshotsController, read 2026-10-09) ────────
|
||||
# Not a config object: flat form fields instead of {body_key: {...}}, no reconfigure, and the answers are
|
||||
# free-form ("OK" strings / {"status": ...}), so callers confirm by listing again.
|
||||
async def list_snapshots(host: OpnsenseHost) -> list[dict]:
|
||||
data = await _request(host, "GET", "/core/snapshots/search", params={"rowCount": -1})
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
async def create_snapshot(host: OpnsenseHost, name: str, note: str = "") -> None:
|
||||
data = await _request(host, "POST", "/core/snapshots/add", json={"name": name, "note": note})
|
||||
if isinstance(data, dict) and (data.get("validations") or str(data.get("status", "")).lower() == "failed"):
|
||||
raise OpnsenseAPIError(f"Snapshot anlegen von Firewall nicht bestätigt: {redact_data(data)}")
|
||||
|
||||
|
||||
async def delete_snapshot(host: OpnsenseHost, uuid: str) -> None:
|
||||
data = await _request(host, "POST", f"/core/snapshots/del/{uuid}")
|
||||
if isinstance(data, dict) and (data.get("validations") or str(data.get("status", "")).lower() == "failed"):
|
||||
raise OpnsenseAPIError(f"Snapshot löschen von Firewall nicht bestätigt: {redact_data(data)}")
|
||||
|
||||
|
||||
# ── Diagnostics (Diagnostics/Api/{Ping,Traceroute,Portprobe,DnsDiagnostics}Controller, read + live 2026-10-09) ──
|
||||
# Traceroute/portprobe/DNS answer synchronously to the same "set" call that validates the form:
|
||||
# {"result": "ok", "response": {...}} or {"result": "failed", "validations": {"<model>.settings.<field>": "..."}}.
|
||||
# Ping is a job: set (returns a uuid) -> start -> searchJobs shows live counters -> stop -> remove.
|
||||
# A probe to a closed/blackholed port takes the firewall 10 s, so these calls need a longer timeout.
|
||||
DIAGNOSTIC_TIMEOUT = 40.0
|
||||
|
||||
|
||||
async def diagnostic_set(host: OpnsenseHost, path: str, model: str, settings: dict) -> dict:
|
||||
data = await _request(host, "POST", path, json={model: {"settings": settings}}, timeout=DIAGNOSTIC_TIMEOUT)
|
||||
if not isinstance(data, dict):
|
||||
raise OpnsenseAPIError("Diagnose: keine auswertbare Antwort der Firewall")
|
||||
if data.get("result") != "ok":
|
||||
raise OpnsenseDiagnosticRejected(data.get("validations") or {})
|
||||
return data
|
||||
|
||||
|
||||
class OpnsenseDiagnosticRejected(OpnsenseAPIError):
|
||||
"""The firewall refused the input (its own validation); carries {field: message}."""
|
||||
|
||||
def __init__(self, validations: dict):
|
||||
super().__init__("Eingabe von der Firewall abgelehnt")
|
||||
self.validations = validations
|
||||
|
||||
|
||||
async def ping_job_start(host: OpnsenseHost, settings: dict) -> str:
|
||||
data = await diagnostic_set(host, "/diagnostics/ping/set", "ping", settings)
|
||||
job = data.get("uuid")
|
||||
if not job:
|
||||
raise OpnsenseAPIError("Ping: die Firewall hat keine Job-ID geliefert")
|
||||
started = await _request(host, "POST", f"/diagnostics/ping/start/{job}", timeout=DIAGNOSTIC_TIMEOUT)
|
||||
if not isinstance(started, dict) or started.get("status") != "ok":
|
||||
await ping_job_remove(host, job)
|
||||
raise OpnsenseAPIError("Ping: der Job konnte nicht gestartet werden")
|
||||
return job
|
||||
|
||||
|
||||
async def ping_job_state(host: OpnsenseHost, job: str) -> dict | None:
|
||||
data = await _request(host, "GET", "/diagnostics/ping/searchJobs", params={"rowCount": -1})
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return next((r for r in rows if isinstance(r, dict) and r.get("id") == job), None)
|
||||
|
||||
|
||||
async def ping_job_stop(host: OpnsenseHost, job: str) -> None:
|
||||
await _request(host, "POST", f"/diagnostics/ping/stop/{job}")
|
||||
|
||||
|
||||
async def ping_job_remove(host: OpnsenseHost, job: str) -> None:
|
||||
await _request(host, "POST", f"/diagnostics/ping/remove/{job}")
|
||||
@@ -8,6 +8,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_SUFFIX = ".secret"
|
||||
|
||||
@@ -18,9 +19,7 @@ def _path(host_id: uuid.UUID) -> Path:
|
||||
|
||||
def save_secret(host_id: uuid.UUID, secret: str) -> None:
|
||||
path = _path(host_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_secret(host_id: uuid.UUID) -> str | None:
|
||||
|
||||
@@ -11,7 +11,8 @@ from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||
from app.services.outbound_nat_mode import require_manual_mode
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -57,48 +58,23 @@ async def _deploy_to_host(db: AsyncSession, rule: OutboundNatRule, host: Opnsens
|
||||
|
||||
prior_uuid = existing.opnsense_outbound_nat_uuid if existing else None
|
||||
prior_live_payload = existing.last_payload if existing else None
|
||||
new_uuid = prior_uuid
|
||||
try:
|
||||
if prior_uuid:
|
||||
await opnsense_client.set_outbound_nat_rule(host, prior_uuid, payload)
|
||||
else:
|
||||
result = await opnsense_client.add_outbound_nat_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAPIError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
await opnsense_client.apply_outbound_nat(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
if existing:
|
||||
existing.status = "error"
|
||||
existing.opnsense_outbound_nat_uuid = new_uuid
|
||||
existing.message = str(exc)
|
||||
existing.revert_deadline = None
|
||||
existing.deployed_at = _now()
|
||||
return existing
|
||||
deployment = OutboundNatDeployment(
|
||||
outbound_nat_id=rule.id, host_id=host.id, status="error", message=str(exc),
|
||||
opnsense_outbound_nat_uuid=new_uuid, deployed_at=_now(),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds)
|
||||
if existing:
|
||||
existing.status = "pending"
|
||||
existing.message = None
|
||||
existing.opnsense_outbound_nat_uuid = new_uuid
|
||||
existing.deployed_at = _now()
|
||||
existing.revert_deadline = deadline
|
||||
existing.previous_payload = prior_live_payload
|
||||
existing.last_payload = payload
|
||||
return existing
|
||||
async def write(uuid_):
|
||||
if uuid_:
|
||||
await opnsense_client.set_outbound_nat_rule(host, uuid_, payload)
|
||||
return uuid_
|
||||
result = await opnsense_client.add_outbound_nat_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAmbiguousError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
return new_uuid
|
||||
|
||||
deployment = OutboundNatDeployment(
|
||||
outbound_nat_id=rule.id, host_id=host.id, status="pending", message=None,
|
||||
opnsense_outbound_nat_uuid=new_uuid, deployed_at=_now(), revert_deadline=deadline,
|
||||
previous_payload=None, last_payload=payload,
|
||||
deployment = await push_to_host(
|
||||
db, existing=existing, uuid_attr="opnsense_outbound_nat_uuid",
|
||||
make=lambda **fields: OutboundNatDeployment(outbound_nat_id=rule.id, host_id=host.id, **fields),
|
||||
prior_uuid=prior_uuid, prior_live_payload=prior_live_payload, payload=payload,
|
||||
write=write, apply=lambda: opnsense_client.apply_outbound_nat(host),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
|
||||
@@ -119,7 +95,7 @@ async def deploy_outbound_nat(db: AsyncSession, rule: OutboundNatRule, hosts: li
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = f"Outbound NAT wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}"
|
||||
deployment.revert_deadline = None
|
||||
# The change is live: keep the revert deadline so an unconfirmed result is still undone.
|
||||
except Exception:
|
||||
# A mode can change after fleet preflight. Preserve already applied
|
||||
# hosts and their rollback deadlines even if a later host now blocks.
|
||||
@@ -150,7 +126,7 @@ async def sweep_expired_outbound_nat_deployments(db: AsyncSession) -> list[Outbo
|
||||
await policy_lock(db)
|
||||
result = await db.execute(
|
||||
select(OutboundNatDeployment).where(
|
||||
OutboundNatDeployment.status == "pending", OutboundNatDeployment.revert_deadline <= _now()
|
||||
OutboundNatDeployment.status.in_(("pending", "error")), OutboundNatDeployment.revert_deadline <= _now()
|
||||
)
|
||||
)
|
||||
expired = list(result.scalars().all())
|
||||
@@ -176,7 +152,7 @@ async def undeploy_outbound_nat(db: AsyncSession, rule: OutboundNatRule) -> 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()
|
||||
|
||||
@@ -37,7 +37,8 @@ from app.models.policy_hierarchy_rule import PolicyHierarchyRule
|
||||
from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
from app.services.policy_hierarchy_service import ancestor_chain, descendants, ordered_hierarchy_rules
|
||||
|
||||
|
||||
@@ -219,62 +220,36 @@ async def _deploy_to_host(db: AsyncSession, rule: PolicyHierarchyRule, host: Opn
|
||||
|
||||
prior_uuid = existing.opnsense_policy_hierarchy_uuid if existing else None
|
||||
prior_live_payload = existing.last_payload if existing else None
|
||||
new_uuid = prior_uuid
|
||||
try:
|
||||
if prior_uuid:
|
||||
await opnsense_client.set_rule(host, prior_uuid, payload)
|
||||
else:
|
||||
result = await opnsense_client.add_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAPIError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
await opnsense_client.apply(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
if existing:
|
||||
existing.status = "error"
|
||||
existing.opnsense_policy_hierarchy_uuid = new_uuid
|
||||
existing.message = str(exc)
|
||||
existing.revert_deadline = None
|
||||
existing.deployed_at = _now()
|
||||
return existing
|
||||
deployment = PolicyHierarchyRuleDeployment(
|
||||
policy_hierarchy_id=rule.id, host_id=host.id, status="error", message=str(exc),
|
||||
opnsense_policy_hierarchy_uuid=new_uuid, deployed_at=_now(),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
# Rule content is live. Persist the uuid now (flushed) so the
|
||||
# positioning pass below sees this deployment as an anchor too.
|
||||
if existing:
|
||||
deployment = existing
|
||||
deployment.opnsense_policy_hierarchy_uuid = new_uuid
|
||||
else:
|
||||
deployment = PolicyHierarchyRuleDeployment(
|
||||
policy_hierarchy_id=rule.id, host_id=host.id, status="pending",
|
||||
opnsense_policy_hierarchy_uuid=new_uuid, deployed_at=_now(),
|
||||
)
|
||||
db.add(deployment)
|
||||
await db.flush()
|
||||
async def write(uuid_):
|
||||
if uuid_:
|
||||
await opnsense_client.set_rule(host, uuid_, payload)
|
||||
return uuid_
|
||||
result = await opnsense_client.add_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAmbiguousError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
return new_uuid
|
||||
|
||||
deployment = await push_to_host(
|
||||
db, existing=existing, uuid_attr="opnsense_policy_hierarchy_uuid",
|
||||
make=lambda **fields: PolicyHierarchyRuleDeployment(policy_hierarchy_id=rule.id, host_id=host.id, **fields),
|
||||
prior_uuid=prior_uuid, prior_live_payload=prior_live_payload, payload=payload,
|
||||
write=write, apply=lambda: opnsense_client.apply(host),
|
||||
)
|
||||
|
||||
if deployment.status != "pending":
|
||||
return deployment
|
||||
|
||||
try:
|
||||
await reassert_ordering(db, host, rule.interface)
|
||||
except OpnsenseAPIError as exc:
|
||||
# Live but not provably in the right position — this must not read
|
||||
# as a merely-unconfirmed pending change (see module docstring).
|
||||
# as a merely-unconfirmed pending change (see module docstring), yet
|
||||
# it stays recoverable: the deadline remains armed.
|
||||
deployment.status = "error"
|
||||
deployment.message = f"Regel wurde übernommen, Positionierung ('quick') aber nicht bestätigt: {exc}"
|
||||
deployment.revert_deadline = None
|
||||
deployment.deployed_at = _now()
|
||||
return deployment
|
||||
|
||||
deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds)
|
||||
deployment.status = "pending"
|
||||
deployment.message = None
|
||||
deployment.deployed_at = _now()
|
||||
deployment.revert_deadline = deadline
|
||||
deployment.previous_payload = prior_live_payload
|
||||
deployment.last_payload = payload
|
||||
return deployment
|
||||
|
||||
|
||||
@@ -309,7 +284,7 @@ async def sweep_expired_hierarchy_deployments(db: AsyncSession) -> list[PolicyHi
|
||||
await policy_lock(db)
|
||||
result = await db.execute(
|
||||
select(PolicyHierarchyRuleDeployment).where(
|
||||
PolicyHierarchyRuleDeployment.status == "pending",
|
||||
PolicyHierarchyRuleDeployment.status.in_(("pending", "error")),
|
||||
PolicyHierarchyRuleDeployment.revert_deadline <= _now(),
|
||||
)
|
||||
)
|
||||
@@ -336,7 +311,7 @@ async def undeploy_hierarchy_rule(db: AsyncSession, rule: PolicyHierarchyRule) -
|
||||
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()
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import text
|
||||
from app.models.policy import RuleRevision
|
||||
from app.services.rule_deploy_service import _build_payload
|
||||
|
||||
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled")
|
||||
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log")
|
||||
|
||||
|
||||
async def policy_lock(db):
|
||||
|
||||
@@ -22,7 +22,8 @@ from app.models.port_forward import PortForwardRule
|
||||
from app.models.port_forward_deployment import PortForwardDeployment
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -65,48 +66,23 @@ async def _deploy_to_host(db: AsyncSession, rule: PortForwardRule, host: Opnsens
|
||||
|
||||
prior_uuid = existing.opnsense_port_forward_uuid if existing else None
|
||||
prior_live_payload = existing.last_payload if existing else None
|
||||
new_uuid = prior_uuid
|
||||
try:
|
||||
if prior_uuid:
|
||||
await opnsense_client.set_port_forward_rule(host, prior_uuid, payload)
|
||||
else:
|
||||
result = await opnsense_client.add_port_forward_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAPIError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
await opnsense_client.apply_port_forward(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
if existing:
|
||||
existing.status = "error"
|
||||
existing.opnsense_port_forward_uuid = new_uuid
|
||||
existing.message = str(exc)
|
||||
existing.revert_deadline = None
|
||||
existing.deployed_at = _now()
|
||||
return existing
|
||||
deployment = PortForwardDeployment(
|
||||
port_forward_id=rule.id, host_id=host.id, status="error", message=str(exc),
|
||||
opnsense_port_forward_uuid=new_uuid, deployed_at=_now(),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds)
|
||||
if existing:
|
||||
existing.status = "pending"
|
||||
existing.message = None
|
||||
existing.opnsense_port_forward_uuid = new_uuid
|
||||
existing.deployed_at = _now()
|
||||
existing.revert_deadline = deadline
|
||||
existing.previous_payload = prior_live_payload
|
||||
existing.last_payload = payload
|
||||
return existing
|
||||
async def write(uuid_):
|
||||
if uuid_:
|
||||
await opnsense_client.set_port_forward_rule(host, uuid_, payload)
|
||||
return uuid_
|
||||
result = await opnsense_client.add_port_forward_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAmbiguousError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
return new_uuid
|
||||
|
||||
deployment = PortForwardDeployment(
|
||||
port_forward_id=rule.id, host_id=host.id, status="pending", message=None,
|
||||
opnsense_port_forward_uuid=new_uuid, deployed_at=_now(), revert_deadline=deadline,
|
||||
previous_payload=None, last_payload=payload,
|
||||
deployment = await push_to_host(
|
||||
db, existing=existing, uuid_attr="opnsense_port_forward_uuid",
|
||||
make=lambda **fields: PortForwardDeployment(port_forward_id=rule.id, host_id=host.id, **fields),
|
||||
prior_uuid=prior_uuid, prior_live_payload=prior_live_payload, payload=payload,
|
||||
write=write, apply=lambda: opnsense_client.apply_port_forward(host),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
|
||||
@@ -124,7 +100,7 @@ async def deploy_port_forward(db: AsyncSession, rule: PortForwardRule, hosts: li
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = f"Port Forward wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}"
|
||||
deployment.revert_deadline = None
|
||||
# The change is live: keep the revert deadline so an unconfirmed result is still undone.
|
||||
await db.commit()
|
||||
return deployments
|
||||
|
||||
@@ -150,7 +126,7 @@ async def sweep_expired_port_forward_deployments(db: AsyncSession) -> list[PortF
|
||||
await policy_lock(db)
|
||||
result = await db.execute(
|
||||
select(PortForwardDeployment).where(
|
||||
PortForwardDeployment.status == "pending", PortForwardDeployment.revert_deadline <= _now()
|
||||
PortForwardDeployment.status.in_(("pending", "error")), PortForwardDeployment.revert_deadline <= _now()
|
||||
)
|
||||
)
|
||||
expired = list(result.scalars().all())
|
||||
@@ -176,7 +152,7 @@ async def undeploy_port_forward(db: AsyncSession, rule: PortForwardRule) -> 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()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Scrub text that came back from a firewall before it is stored, logged or
|
||||
shown to a user.
|
||||
|
||||
An upstream error body can echo the request that caused it — including the
|
||||
password, pre-shared key or private key we just sent — or contain secret-bearing
|
||||
configuration. OPNexus keeps these messages (deployment status, maintenance
|
||||
results, host status detail) and read-only users can see them, so they must not
|
||||
carry such values. Redaction is applied where the exception is built
|
||||
(opnsense_client), so every consumer gets the safe version.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
MAX_LENGTH = 300
|
||||
_REDACTED = "[redacted]"
|
||||
|
||||
# Field names whose value is (or may be) secret material.
|
||||
_SECRET_KEY = re.compile(
|
||||
r"pass|secret|psk|pre_?shared|private|prv|token|api_?key|apikey|credential|auth|crt|cert|\bkey\b|_key|key_|tls_?key",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PEM = re.compile(r"-----BEGIN [A-Z0-9 ]+-----.*?(?:-----END [A-Z0-9 ]+-----|$)", re.DOTALL)
|
||||
# key=value / key: value pairs in non-JSON text
|
||||
_PAIR = re.compile(r"(?i)\b([\w.\-]*(?:pass|secret|psk|token|private|api_?key|key)[\w.\-]*)\b([\"']?\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&]+)")
|
||||
|
||||
|
||||
# Keys under which OPNsense lists per-field validation errors
|
||||
# ({"validations": {"instance.cert": "Please select a valid certificate"}}).
|
||||
# There the field name says WHICH field failed. "cert", "crt" and "auth" in it
|
||||
# do not make the message secret, so those three alone stop triggering the
|
||||
# redaction (otherwise every certificate/auth error from the firewall reads
|
||||
# "[redacted]"). Real secret names (psk, key, password, ...) still do.
|
||||
_VALIDATION_KEYS = {"validations", "validation"}
|
||||
_SECRET_KEY_IN_VALIDATIONS = re.compile(
|
||||
r"pass|secret|psk|pre_?shared|private|prv|token|api_?key|apikey|credential|\bkey\b|_key|key_|tls_?key",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _scrub_json(value: Any, key: str = "", in_validations: bool = False) -> Any:
|
||||
if isinstance(value, dict):
|
||||
nested = in_validations or key in _VALIDATION_KEYS
|
||||
return {k: _scrub_json(v, str(k), nested) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_scrub_json(v, key, in_validations) for v in value]
|
||||
pattern = _SECRET_KEY_IN_VALIDATIONS if in_validations else _SECRET_KEY
|
||||
if isinstance(value, str) and key and pattern.search(key):
|
||||
return _REDACTED
|
||||
if isinstance(value, str):
|
||||
return _scrub_text(value)
|
||||
return value
|
||||
|
||||
|
||||
def _scrub_text(text: str) -> str:
|
||||
return _PAIR.sub(lambda m: f"{m.group(1)}{m.group(2)}{_REDACTED}", _PEM.sub(_REDACTED, text))
|
||||
|
||||
|
||||
def redact_text(text: str, *known_secrets: str | None) -> str:
|
||||
"""Return a bounded, secret-free rendering of an upstream response body."""
|
||||
text = text or ""
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, (dict, list)):
|
||||
text = json.dumps(_scrub_json(parsed), ensure_ascii=False)
|
||||
else:
|
||||
text = _scrub_text(text)
|
||||
for secret in known_secrets:
|
||||
if secret and len(secret) >= 4:
|
||||
text = text.replace(secret, _REDACTED)
|
||||
text = " ".join(text.split())
|
||||
return text if len(text) <= MAX_LENGTH else text[:MAX_LENGTH] + "…"
|
||||
|
||||
|
||||
def redact_data(data: Any, *known_secrets: str | None) -> str:
|
||||
"""Same for an already-parsed JSON structure."""
|
||||
return redact_text(json.dumps(data, ensure_ascii=False, default=str), *known_secrets)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Takes over a firewall rule that was created by hand on a host, so it can be
|
||||
edited, versioned and deployed from OPNexus like any other rule.
|
||||
|
||||
Adoption itself writes NOTHING to the firewall: it records the rule as it is
|
||||
right now (read fresh from the host at adoption time, never from what the
|
||||
browser sent) together with its OPNsense UUID. The first change afterwards goes
|
||||
through the normal preview → push → confirm/auto-revert flow.
|
||||
|
||||
Only rules OPNexus can describe without distortion are adopted. A rule using
|
||||
something the model cannot express (several interfaces, an inverted source, a
|
||||
non-quick rule, ...) is refused with the reason instead of being imported in a
|
||||
way that would silently change its meaning on the next edit. Fields the model
|
||||
does not know about (gateway, state options, schedules, tags, ...) are left
|
||||
alone on the firewall: setRule only overwrites the fields it is sent.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.rule import FirewallRule
|
||||
from app.models.rule_deployment import RuleDeployment
|
||||
from app.services import opnsense_client
|
||||
from app.services.policy_service import revision
|
||||
from app.services.rule_deploy_service import _build_payload
|
||||
|
||||
|
||||
class RuleNotFoundOnHost(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotAdoptable(Exception):
|
||||
def __init__(self, reasons: list[str]):
|
||||
super().__init__(", ".join(reasons))
|
||||
self.reasons = reasons
|
||||
|
||||
|
||||
def _on(value) -> bool:
|
||||
return value in ("1", 1, True)
|
||||
|
||||
|
||||
def _clean(value) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def reasons_not_adoptable(row: dict, managed_uuids: set[str]) -> list[str]:
|
||||
"""Machine-readable reasons (the UI translates them); empty = adoptable."""
|
||||
reasons = []
|
||||
if row.get("is_automatic"):
|
||||
reasons.append("automatic")
|
||||
if _clean(row.get("uuid")) in managed_uuids:
|
||||
reasons.append("already_managed")
|
||||
interface = _clean(row.get("interface"))
|
||||
if not interface or "," in interface:
|
||||
reasons.append("interface_not_single")
|
||||
if _on(row.get("interfacenot")):
|
||||
reasons.append("negated_interface")
|
||||
if _on(row.get("source_not")):
|
||||
reasons.append("negated_source")
|
||||
if _on(row.get("destination_not")):
|
||||
reasons.append("negated_destination")
|
||||
if _clean(row.get("received-on")):
|
||||
reasons.append("received_on")
|
||||
if _clean(row.get("quick")) != "1":
|
||||
reasons.append("not_quick")
|
||||
if _clean(row.get("direction")) not in ("in", "out"):
|
||||
reasons.append("direction_unsupported")
|
||||
if _clean(row.get("action")) not in ("pass", "block", "reject"):
|
||||
reasons.append("action_unsupported")
|
||||
if _clean(row.get("ipprotocol")) not in ("inet", "inet6"):
|
||||
reasons.append("ipprotocol_unsupported")
|
||||
if not _clean(row.get("description")):
|
||||
reasons.append("no_description")
|
||||
return reasons
|
||||
|
||||
|
||||
async def _managed_uuids(db: AsyncSession, host: OpnsenseHost) -> set[str]:
|
||||
rows = await db.scalars(
|
||||
select(RuleDeployment.opnsense_rule_uuid).where(
|
||||
RuleDeployment.host_id == host.id, RuleDeployment.opnsense_rule_uuid.is_not(None)
|
||||
)
|
||||
)
|
||||
return set(rows.all())
|
||||
|
||||
|
||||
async def _live_rows(host: OpnsenseHost) -> list[dict]:
|
||||
data = await opnsense_client.search_rules(host, all_rows=True)
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [r for r in rows if isinstance(r, dict) and r.get("uuid")]
|
||||
|
||||
|
||||
async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
|
||||
managed = await _managed_uuids(db, host)
|
||||
result = []
|
||||
for row in await _live_rows(host):
|
||||
if row.get("is_automatic"):
|
||||
continue # built-in/system rules never show up here at all
|
||||
reasons = reasons_not_adoptable(row, managed)
|
||||
if "already_managed" in reasons:
|
||||
continue
|
||||
result.append({
|
||||
"uuid": _clean(row.get("uuid")),
|
||||
"description": _clean(row.get("description")),
|
||||
"interface": _clean(row.get("interface")),
|
||||
"interface_label": _clean(row.get("%interface")) or _clean(row.get("interface")),
|
||||
"direction": _clean(row.get("direction")),
|
||||
"action": _clean(row.get("action")),
|
||||
"protocol": _clean(row.get("protocol")) or "any",
|
||||
"source_net": _clean(row.get("source_net")) or "any",
|
||||
"source_port": _clean(row.get("source_port")),
|
||||
"destination_net": _clean(row.get("destination_net")) or "any",
|
||||
"destination_port": _clean(row.get("destination_port")),
|
||||
"enabled": _on(row.get("enabled")),
|
||||
"log": _on(row.get("log")),
|
||||
"adoptable": not reasons,
|
||||
"reasons": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str) -> FirewallRule:
|
||||
managed = await _managed_uuids(db, host)
|
||||
row = next((r for r in await _live_rows(host) if _clean(r.get("uuid")) == rule_uuid), None)
|
||||
if row is None:
|
||||
raise RuleNotFoundOnHost(rule_uuid)
|
||||
reasons = reasons_not_adoptable(row, managed)
|
||||
if reasons:
|
||||
raise NotAdoptable(reasons)
|
||||
|
||||
rule = FirewallRule(
|
||||
description=_clean(row.get("description")),
|
||||
interface=_clean(row.get("interface")),
|
||||
direction=_clean(row.get("direction")),
|
||||
ipprotocol=_clean(row.get("ipprotocol")),
|
||||
protocol=_clean(row.get("protocol")) or "any",
|
||||
action=_clean(row.get("action")),
|
||||
source_net=_clean(row.get("source_net")) or "any",
|
||||
source_port=_clean(row.get("source_port")) or None,
|
||||
destination_net=_clean(row.get("destination_net")) or "any",
|
||||
destination_port=_clean(row.get("destination_port")) or None,
|
||||
enabled=_on(row.get("enabled")),
|
||||
log=_on(row.get("log")),
|
||||
targets=[host],
|
||||
groups=[],
|
||||
)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
revision(db, rule, "Von Firewall übernommen")
|
||||
db.add(RuleDeployment(
|
||||
rule_id=rule.id,
|
||||
host_id=host.id,
|
||||
status="success",
|
||||
message="Von der Firewall übernommen",
|
||||
opnsense_rule_uuid=rule_uuid,
|
||||
last_payload=_build_payload(rule),
|
||||
previous_payload=None,
|
||||
))
|
||||
await db.flush()
|
||||
return rule
|
||||
@@ -28,7 +28,8 @@ from app.models.rule import FirewallRule
|
||||
from app.models.rule_deployment import RuleDeployment
|
||||
from app.services.audit_service import tracked_rollback
|
||||
from app.services import opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.deploy_flow import push_to_host
|
||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -49,7 +50,7 @@ def _build_payload(rule: FirewallRule) -> dict:
|
||||
"destination_net": rule.destination_net,
|
||||
"destination_port": rule.destination_port or "",
|
||||
"description": rule.description,
|
||||
"log": "0",
|
||||
"log": "1" if rule.log else "0",
|
||||
}
|
||||
|
||||
|
||||
@@ -73,48 +74,23 @@ async def _deploy_to_host(db: AsyncSession, rule: FirewallRule, host: OpnsenseHo
|
||||
|
||||
prior_uuid = existing.opnsense_rule_uuid if existing else None
|
||||
prior_live_payload = existing.last_payload if existing else None
|
||||
new_uuid = prior_uuid
|
||||
try:
|
||||
if prior_uuid:
|
||||
await opnsense_client.set_rule(host, prior_uuid, payload)
|
||||
else:
|
||||
result = await opnsense_client.add_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAPIError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
await opnsense_client.apply(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
if existing:
|
||||
existing.status = "error"
|
||||
existing.opnsense_rule_uuid = new_uuid
|
||||
existing.message = str(exc)
|
||||
existing.revert_deadline = None
|
||||
existing.deployed_at = _now()
|
||||
return existing
|
||||
deployment = RuleDeployment(
|
||||
rule_id=rule.id, host_id=host.id, status="error", message=str(exc),
|
||||
opnsense_rule_uuid=new_uuid, deployed_at=_now(),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds)
|
||||
if existing:
|
||||
existing.status = "pending"
|
||||
existing.message = None
|
||||
existing.opnsense_rule_uuid = new_uuid
|
||||
existing.deployed_at = _now()
|
||||
existing.revert_deadline = deadline
|
||||
existing.previous_payload = prior_live_payload
|
||||
existing.last_payload = payload
|
||||
return existing
|
||||
async def write(uuid_):
|
||||
if uuid_:
|
||||
await opnsense_client.set_rule(host, uuid_, payload)
|
||||
return uuid_
|
||||
result = await opnsense_client.add_rule(host, payload)
|
||||
new_uuid = result.get("uuid") if isinstance(result, dict) else None
|
||||
if not new_uuid:
|
||||
raise OpnsenseAmbiguousError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
|
||||
return new_uuid
|
||||
|
||||
deployment = RuleDeployment(
|
||||
rule_id=rule.id, host_id=host.id, status="pending", message=None,
|
||||
opnsense_rule_uuid=new_uuid, deployed_at=_now(), revert_deadline=deadline,
|
||||
previous_payload=None, last_payload=payload,
|
||||
deployment = await push_to_host(
|
||||
db, existing=existing, uuid_attr="opnsense_rule_uuid",
|
||||
make=lambda **fields: RuleDeployment(rule_id=rule.id, host_id=host.id, **fields),
|
||||
prior_uuid=prior_uuid, prior_live_payload=prior_live_payload, payload=payload,
|
||||
write=write, apply=lambda: opnsense_client.apply(host),
|
||||
)
|
||||
db.add(deployment)
|
||||
return deployment
|
||||
|
||||
|
||||
@@ -132,7 +108,7 @@ async def deploy_rule(db: AsyncSession, rule: FirewallRule, hosts: list[Opnsense
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = f"Regel wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}"
|
||||
deployment.revert_deadline = None
|
||||
# The change is live: keep the revert deadline so an unconfirmed result is still undone.
|
||||
await db.commit()
|
||||
return deployments
|
||||
|
||||
@@ -159,7 +135,7 @@ async def sweep_expired_deployments(db: AsyncSession) -> list[RuleDeployment]:
|
||||
from app.services.policy_service import policy_lock
|
||||
await policy_lock(db)
|
||||
result = await db.execute(
|
||||
select(RuleDeployment).where(RuleDeployment.status == "pending", RuleDeployment.revert_deadline <= _now())
|
||||
select(RuleDeployment).where(RuleDeployment.status.in_(("pending", "error")), RuleDeployment.revert_deadline <= _now())
|
||||
)
|
||||
expired = list(result.scalars().all())
|
||||
touched: list[RuleDeployment] = []
|
||||
@@ -184,7 +160,7 @@ async def undeploy_rule(db: AsyncSession, rule: FirewallRule) -> 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()
|
||||
|
||||
@@ -38,6 +38,10 @@ async def _live_rules_or_none(host: OpnsenseHost) -> list[LiveRule] | None:
|
||||
|
||||
|
||||
async def compute_cluster_drift(hosts: list[OpnsenseHost]) -> list[dict]:
|
||||
return (await compute_cluster_drift_with_reachable(hosts))[0]
|
||||
|
||||
|
||||
async def compute_cluster_drift_with_reachable(hosts: list[OpnsenseHost]) -> tuple[list[dict], set]:
|
||||
"""Returns one row per fingerprint that isn't present on every reachable
|
||||
host in the cluster: {"fingerprint": str, "sample": LiveRule,
|
||||
"present_on": set[host_id], "reachable_host_ids": set[host_id]}.
|
||||
@@ -58,4 +62,4 @@ async def compute_cluster_drift(hosts: list[OpnsenseHost]) -> list[dict]:
|
||||
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
|
||||
@@ -6,6 +6,9 @@ action dispatch table) rather than duplicating that logic differently,
|
||||
since this is the exact same remote action, just fired by a timer instead
|
||||
of a click.
|
||||
|
||||
Updates take a fresh config backup right before firing (a plan for the day after tomorrow
|
||||
would otherwise always trip over the 24h freshness rule).
|
||||
|
||||
v1 scope decision: a blocked or failed scheduled fire is logged and
|
||||
recorded on the ScheduledMaintenance row's own `result` field (visible in
|
||||
the UI) but is NOT wired into the NotificationSettings webhook/email
|
||||
@@ -15,7 +18,7 @@ for this is a bigger change than this feature's own scope. Flagged here,
|
||||
not silently dropped, in case it's wanted later.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
@@ -24,12 +27,30 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.services import audit_service, opnsense_client as client
|
||||
from app.services.host_maintenance_service import ensure_no_maintenance, inspect_host
|
||||
from app.services import audit_service, backup_store, opnsense_client as client
|
||||
from app.services.host_maintenance_service import ACTIVE, ensure_no_maintenance, inspect_carp_switch, inspect_host, peers
|
||||
from app.services.host_status_service import get_statuses
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ACTION_FN = {"reboot": client.reboot_host, "update": client.update_firmware, "major_update": client.upgrade_firmware}
|
||||
# Actions that need a config backup no older than 24h (see host_maintenance_service.inspect_host).
|
||||
# Unattended, nobody can take it by hand beforehand, so it is taken right before firing.
|
||||
_BACKUP_BEFORE = {"update", "major_update"}
|
||||
|
||||
# ── CARP handover run (opt-in per plan) ─────────────────────────────────────
|
||||
# MASTER -> maintenance mode -> partner must become MASTER -> action -> wait until the node is
|
||||
# back -> leave maintenance mode. One step per poll tick, state in the schedule row.
|
||||
# Rules that keep it safe:
|
||||
# * the CARP toggle is a toggle: it is only ever sent against freshly read state and never
|
||||
# replayed after an unclear answer (see routers/maintenance.py carp_execute);
|
||||
# * until the action was submitted, any problem restores normal operation (nothing happened yet);
|
||||
# * after that, any problem leaves the node demoted — the partner keeps carrying traffic, which is
|
||||
# the safe side — and the run ends as failed (=> notification) with an "unknown" operation.
|
||||
HANDOVER_TIMEOUT = timedelta(minutes=3)
|
||||
LEAVE_TIMEOUT = timedelta(minutes=5)
|
||||
JOB_GRACE = timedelta(minutes=5) # no sign of a job after this long: treat a package update as already done
|
||||
JOB_TIMEOUT = {"reboot": timedelta(minutes=20), "update": timedelta(minutes=60), "major_update": timedelta(minutes=150)}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -79,6 +100,16 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
await db.commit()
|
||||
logger.warning("Geplante Wartung %s blockiert: %s", sched.id, exc.detail)
|
||||
return
|
||||
if sched.carp_handover and host.cluster_name:
|
||||
try:
|
||||
statuses = await get_statuses(await peers(db, host))
|
||||
except client.OpnsenseAPIError as exc:
|
||||
statuses = {}
|
||||
logger.warning("Geplante Wartung %s: Rolle nicht lesbar: %s", sched.id, exc)
|
||||
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
|
||||
await _start_handover(db, sched, host)
|
||||
return
|
||||
# Already BACKUP (or role unclear): no handover needed, the normal path below decides.
|
||||
try:
|
||||
state = await inspect_host(db, host)
|
||||
except client.OpnsenseAPIError as exc:
|
||||
@@ -87,6 +118,18 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
await db.commit()
|
||||
logger.warning("Geplante Wartung %s: Host nicht erreichbar", sched.id)
|
||||
return
|
||||
if sched.action in _BACKUP_BEFORE and not state["blocked"]:
|
||||
# Read-only on the firewall. Fail closed: no backup, no update. Taken only after the
|
||||
# other blockers passed, so a plan that is going to fail anyway does not pile up backups.
|
||||
try:
|
||||
backup_store.save_snapshot(host.id, await client.download_backup(host))
|
||||
state = await inspect_host(db, host)
|
||||
except client.OpnsenseAPIError as exc:
|
||||
sched.status = "failed"
|
||||
sched.result = {"error": f"Automatisches Backup vor dem Update fehlgeschlagen: {str(exc)[:300]}"}
|
||||
await db.commit()
|
||||
logger.warning("Geplante Wartung %s: Backup vor dem Update fehlgeschlagen", sched.id)
|
||||
return
|
||||
if state["actions"][sched.action]:
|
||||
sched.status = "failed"
|
||||
sched.result = {"error": " ".join(state["actions"][sched.action])}
|
||||
@@ -125,3 +168,248 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
host_id=str(host.id), host_name=host.name, entity_id=str(op.id),
|
||||
message=op.message, details={"scheduled": True, "schedule_id": str(sched.id), "response": result},
|
||||
)
|
||||
|
||||
|
||||
# ── handover run ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _progress(sched: ScheduledMaintenance, **changes) -> dict:
|
||||
"""JSON columns only notice reassignment, so always hand back a new dict."""
|
||||
sched.progress = {**(sched.progress or {}), **changes}
|
||||
return sched.progress
|
||||
|
||||
|
||||
async def _audit(host, sched, op, action, status, message=None, **details):
|
||||
await audit_service.persist(
|
||||
kind="host", action=action, status=status, host_id=str(host.id), host_name=host.name,
|
||||
entity_id=str(op.id) if op else None, message=message,
|
||||
details={"scheduled": True, "schedule_id": str(sched.id), "handover": True, **details},
|
||||
)
|
||||
|
||||
|
||||
async def _end(db, sched, host, op, *, ok, message, op_status=None, **result):
|
||||
sched.status = "succeeded" if ok else "failed"
|
||||
sched.phase = None
|
||||
sched.result = {"handover": True, **result} if ok else {"error": message[:900], **result}
|
||||
if op is not None:
|
||||
op.status = op_status or ("acknowledged" if ok else "unknown")
|
||||
op.message = message[:1000]
|
||||
await db.commit()
|
||||
await _audit(host, sched, op, sched.action, "completed" if ok else (op_status or "unknown"), message)
|
||||
if not ok:
|
||||
logger.warning("Geplante Wartung %s (CARP-Übergabe) beendet: %s", sched.id, message)
|
||||
|
||||
|
||||
async def _toggle(host, expected: str) -> None:
|
||||
"""One CARP maintenance toggle; raises OpnsenseAPIError for an unclear or wrong-direction answer."""
|
||||
result = await client.toggle_carp_maintenance(host)
|
||||
if result.get("action") != expected:
|
||||
raise client.OpnsenseAPIError("CARP-Antwort passt nicht zur angeforderten Richtung")
|
||||
|
||||
|
||||
async def _start_handover(db: AsyncSession, sched: ScheduledMaintenance, host: OpnsenseHost) -> None:
|
||||
"""The node is MASTER now. All preconditions are checked while nothing has been changed yet."""
|
||||
def fail(message: str):
|
||||
sched.status = "failed"
|
||||
sched.result = {"error": message[:900]}
|
||||
return db.commit()
|
||||
|
||||
try:
|
||||
carp = await inspect_carp_switch(db, host)
|
||||
state = await inspect_host(db, host, skip_role_checks=True)
|
||||
blocked = list(dict.fromkeys(carp["blocked"] + state["blocked"]))
|
||||
if carp["action"] != "carp_enter": # MASTER but already in maintenance mode: unclear, do not toggle blindly
|
||||
blocked.append(f"{host.name} steht bereits im CARP-Wartungsmodus. Rollen prüfen.")
|
||||
if not blocked and sched.action in _BACKUP_BEFORE:
|
||||
backup_store.save_snapshot(host.id, await client.download_backup(host))
|
||||
state = await inspect_host(db, host, skip_role_checks=True)
|
||||
blocked = list(dict.fromkeys(blocked + state["actions"][sched.action]))
|
||||
except client.OpnsenseAPIError as exc:
|
||||
await fail(f"Vorbereitung der CARP-Übergabe fehlgeschlagen, nichts wurde verändert: {str(exc)[:300]}")
|
||||
return
|
||||
if blocked:
|
||||
await fail(" ".join(blocked))
|
||||
return
|
||||
|
||||
now = _now()
|
||||
op = HostOperation(
|
||||
host_id=host.id, host_name=host.name, actor=sched.actor, action=sched.action,
|
||||
expires_at=now, fingerprint="", snapshot=state, status="starting", started_at=now,
|
||||
)
|
||||
db.add(op)
|
||||
sched.status = "running"
|
||||
await db.commit()
|
||||
sched.host_operation_id = op.id
|
||||
await _audit(host, sched, op, "carp_enter", "requested", snapshot=state)
|
||||
try:
|
||||
await _toggle(host, "enter_maintenance")
|
||||
except client.OpnsenseAPIError as exc:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"CARP-Umschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
|
||||
f"Rollen von {host.name} und Partner prüfen.")
|
||||
return
|
||||
sched.phase, sched.phase_since = "handover_wait", _now()
|
||||
_progress(sched, version_before=state.get("version"))
|
||||
op.message = "CARP-Übergabe angefordert, warte auf Bestätigung."
|
||||
await db.commit()
|
||||
await _audit(host, sched, op, "carp_enter", "submitted")
|
||||
|
||||
|
||||
async def advance_running(db: AsyncSession) -> int:
|
||||
"""One step for every handover run in progress. Returns how many are still running."""
|
||||
rows = list((await db.scalars(
|
||||
select(ScheduledMaintenance).where(ScheduledMaintenance.status == "running", ScheduledMaintenance.phase.is_not(None))
|
||||
)).all())
|
||||
for sched in rows:
|
||||
host = await db.get(OpnsenseHost, sched.host_id)
|
||||
op = await db.get(HostOperation, sched.host_operation_id) if sched.host_operation_id else None
|
||||
try:
|
||||
if host is None:
|
||||
raise RuntimeError("Host nicht mehr vorhanden")
|
||||
if op is not None and op.status not in ACTIVE:
|
||||
# A human closed or cancelled the operation: stop here and touch nothing more.
|
||||
sched.status, sched.phase = "failed", None
|
||||
sched.result = {"error": f"Vorgang von Hand beendet, Ablauf gestoppt. CARP-Rolle von {host.name} prüfen."}
|
||||
await db.commit()
|
||||
continue
|
||||
await _STEPS[sched.phase](db, sched, host, op)
|
||||
except Exception as exc: # a stuck run must not loop forever or take the poll loop down
|
||||
logger.exception("Geplante Wartung %s: Schritt %s fehlgeschlagen", sched.id, sched.phase)
|
||||
if host is not None:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"Unerwarteter Fehler während der CARP-Übergabe ({type(exc).__name__}). "
|
||||
f"{host.name} kann im CARP-Wartungsmodus verblieben sein — Rollen prüfen.")
|
||||
return sum(1 for s in rows if s.status == "running")
|
||||
|
||||
|
||||
def _since(sched: ScheduledMaintenance) -> timedelta:
|
||||
return _now() - (sched.phase_since or _now())
|
||||
|
||||
|
||||
async def _roles(db, host) -> tuple[dict, bool]:
|
||||
"""(own status, a partner is online and MASTER)."""
|
||||
fleet = await peers(db, host)
|
||||
statuses = await get_statuses(fleet)
|
||||
partner_master = any(
|
||||
statuses.get(h.id, {}).get("status") == "online" and statuses.get(h.id, {}).get("carp_status") == "MASTER"
|
||||
for h in fleet if h.id != host.id
|
||||
)
|
||||
return statuses.get(host.id, {}), partner_master
|
||||
|
||||
|
||||
async def _step_handover_wait(db, sched, host, op):
|
||||
me, partner_master = await _roles(db, host)
|
||||
if me.get("status") == "online" and me.get("carp_maintenance_mode") and me.get("carp_status") == "BACKUP" and partner_master:
|
||||
try:
|
||||
await _ACTION_FN[sched.action](host)
|
||||
except client.OpnsenseAPIError as exc:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"Auftrag „{sched.action}“ mit unklarem Ergebnis: {str(exc)[:300]}. Nicht wiederholt. "
|
||||
f"{host.name} bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr.")
|
||||
return
|
||||
sched.phase, sched.phase_since = "job_wait", _now()
|
||||
_progress(sched, job_started_at=_now().isoformat(), seen_down=False, seen_running=False)
|
||||
op.status, op.message = "submitted", "Übergabe bestätigt, Auftrag gestartet."
|
||||
await db.commit()
|
||||
await _audit(host, sched, op, sched.action, "submitted")
|
||||
return
|
||||
if _since(sched) < HANDOVER_TIMEOUT:
|
||||
return
|
||||
# The partner never took over. Nothing has run yet, so put the node back to normal.
|
||||
if me.get("status") == "online" and me.get("carp_maintenance_mode"):
|
||||
try:
|
||||
await _toggle(host, "leave_maintenance")
|
||||
except client.OpnsenseAPIError as exc:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"CARP-Übergabe nicht bestätigt, und der Normalbetrieb konnte nicht wiederhergestellt werden "
|
||||
f"({str(exc)[:200]}). {host.name} prüfen.")
|
||||
return
|
||||
await _end(db, sched, host, op, ok=False, op_status="cancelled",
|
||||
message="CARP-Übergabe nicht bestätigt (der Partner wurde nicht MASTER). Normalbetrieb wiederhergestellt, nichts aktualisiert.")
|
||||
elif me.get("status") == "online":
|
||||
await _end(db, sched, host, op, ok=False, op_status="cancelled",
|
||||
message="CARP-Übergabe hat nicht stattgefunden (Wartungsmodus nicht aktiv). Nichts aktualisiert.")
|
||||
else:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"CARP-Übergabe nicht bestätigt und {host.name} nicht erreichbar. Rollen prüfen.")
|
||||
|
||||
|
||||
async def _step_job_wait(db, sched, host, op):
|
||||
prog = dict(sched.progress or {})
|
||||
started = datetime.fromisoformat(prog["job_started_at"])
|
||||
elapsed = _now() - started
|
||||
try:
|
||||
running = (await client.get_firmware_running(host)).get("status")
|
||||
except client.OpnsenseAPIError:
|
||||
_progress(sched, seen_down=True) # rebooting: exactly what a reboot or base update looks like
|
||||
running = None
|
||||
else:
|
||||
if running != "ready":
|
||||
_progress(sched, seen_running=True)
|
||||
prog = sched.progress
|
||||
if running == "ready":
|
||||
if sched.action == "reboot":
|
||||
done = bool(prog.get("seen_down"))
|
||||
else:
|
||||
done = bool(prog.get("seen_down") or prog.get("seen_running")) or elapsed >= JOB_GRACE
|
||||
if done:
|
||||
sched.phase, sched.phase_since = "leave", _now()
|
||||
await db.commit()
|
||||
return
|
||||
if elapsed >= JOB_TIMEOUT[sched.action]:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"Auftrag „{sched.action}“ auf {host.name} nicht rechtzeitig abgeschlossen. {host.name} bleibt im "
|
||||
f"CARP-Wartungsmodus, der Partner trägt den Verkehr. Bitte prüfen und von Hand zurückschalten.")
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _step_leave(db, sched, host, op):
|
||||
me, _partner = await _roles(db, host)
|
||||
if me.get("status") != "online":
|
||||
if _since(sched) >= LEAVE_TIMEOUT:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"{host.name} ist nach dem Auftrag nicht wieder erreichbar. Der Partner trägt den Verkehr.")
|
||||
return
|
||||
if me.get("carp_maintenance_mode"):
|
||||
try:
|
||||
await _toggle(host, "leave_maintenance")
|
||||
except client.OpnsenseAPIError as exc:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"Zurückschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
|
||||
f"{host.name} prüfen, ggf. „CARP-Normalbetrieb herstellen“.")
|
||||
return
|
||||
await _audit(host, sched, op, "carp_leave", "submitted")
|
||||
sched.phase, sched.phase_since = "leave_wait", _now()
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _step_leave_wait(db, sched, host, op):
|
||||
fleet = await peers(db, host)
|
||||
statuses = await get_statuses(fleet)
|
||||
me = statuses.get(host.id, {})
|
||||
online = [h for h in fleet if statuses.get(h.id, {}).get("status") == "online"]
|
||||
masters = [h for h in online if statuses.get(h.id, {}).get("carp_status") == "MASTER"]
|
||||
if me.get("status") == "online" and not me.get("carp_maintenance_mode") and len(masters) == 1 and len(online) == len(fleet):
|
||||
extra = {}
|
||||
try:
|
||||
extra["version_after"] = (await client.get_firmware_info(host)).get("product_version")
|
||||
extra["reboot_required"] = str((await client.get_firmware_status(host)).get("status_reboot", "0")) == "1"
|
||||
except client.OpnsenseAPIError:
|
||||
pass
|
||||
extra["version_before"] = (sched.progress or {}).get("version_before")
|
||||
text = "Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt."
|
||||
if extra.get("reboot_required"):
|
||||
text += " Ein Neustart ist noch erforderlich."
|
||||
await _end(db, sched, host, op, ok=True, message=text, **extra)
|
||||
return
|
||||
if _since(sched) >= LEAVE_TIMEOUT:
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"Normalbetrieb nach dem Zurückschalten nicht bestätigt. Rollen von {host.name} und Partner prüfen.")
|
||||
|
||||
|
||||
_STEPS = {
|
||||
"handover_wait": _step_handover_wait,
|
||||
"job_wait": _step_job_wait,
|
||||
"leave": _step_leave,
|
||||
"leave_wait": _step_leave_wait,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Writing secret material to disk without a window in which it is readable.
|
||||
|
||||
``Path.write_text()`` followed by ``chmod(0o600)`` creates the file with the
|
||||
process umask (typically 0644) first. Here the file is created with mode 0600
|
||||
from the start, under a fresh unpredictable name (``O_EXCL`` + ``O_NOFOLLOW``,
|
||||
so nothing planted there is followed), flushed to disk and then renamed over
|
||||
the destination — readers see either the complete old or the complete new
|
||||
content, never a partial write, and a symlink at the destination is replaced
|
||||
rather than written through.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def write_private(path: Path, data: str | bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
payload = data.encode() if isinstance(data, str) else data
|
||||
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -0,0 +1,73 @@
|
||||
"""ZFS snapshots (boot environments) of one firewall: list, create, delete.
|
||||
|
||||
Snapshots are a safety net before bigger changes, not a rollback button:
|
||||
activating one needs a reboot and discards everything since, so that action is
|
||||
deliberately NOT offered here. Deleting refuses the active/next-boot snapshot
|
||||
(the firewall refuses too; we say so up front). Every step is audited.
|
||||
"""
|
||||
import re
|
||||
|
||||
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
|
||||
|
||||
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
|
||||
|
||||
def _row(row: dict) -> dict:
|
||||
return {
|
||||
"uuid": str(row.get("uuid", "")), "name": str(row.get("name", "")), "active": str(row.get("active", "")),
|
||||
"size": str(row.get("size", "")), "created": str(row.get("created_str", "")),
|
||||
"mountpoint": str(row.get("mountpoint", "")),
|
||||
}
|
||||
|
||||
|
||||
async def list_snapshots(host: OpnsenseHost) -> dict:
|
||||
try:
|
||||
supported = (await opnsense_client._request(host, "GET", "/core/snapshots/isSupported")).get("supported")
|
||||
except OpnsenseAPIError as exc:
|
||||
if "404" in str(exc):
|
||||
return {"supported": False, "rows": []}
|
||||
raise
|
||||
if not supported:
|
||||
return {"supported": False, "rows": []}
|
||||
return {"supported": True, "rows": [_row(r) for r in await opnsense_client.list_snapshots(host)]}
|
||||
|
||||
|
||||
async def create(host: OpnsenseHost, name: str, note: str) -> dict:
|
||||
if not NAME.match(name):
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="name")
|
||||
audit = {"kind": "snapshot", "action": "snapshot.add", "entity_name": name, "host_id": str(host.id), "host_name": host.name}
|
||||
await audit_service.persist(**audit, status="started", message=None, details={"note": note[:200]})
|
||||
try:
|
||||
await opnsense_client.create_snapshot(host, name, note[:200])
|
||||
created = next((r for r in await opnsense_client.list_snapshots(host) if r.get("name") == name), None)
|
||||
except OpnsenseAPIError as exc:
|
||||
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||
await audit_service.persist(**audit, status="applied" if created else "unverified", message=None, details={})
|
||||
if not created:
|
||||
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
|
||||
return _row(created)
|
||||
|
||||
|
||||
async def delete(host: OpnsenseHost, uuid: str) -> None:
|
||||
rows = await opnsense_client.list_snapshots(host)
|
||||
target = next((r for r in rows if str(r.get("uuid")) == uuid), None)
|
||||
if target is None:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
if str(target.get("active", "")).strip() not in ("", "-"):
|
||||
# N = active now, R = active after reboot: removing either would break the boot environment.
|
||||
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="snapshot_active")
|
||||
audit = {"kind": "snapshot", "action": "snapshot.del", "entity_name": str(target.get("name")), "host_id": str(host.id), "host_name": host.name}
|
||||
await audit_service.persist(**audit, status="started", message=None, details={})
|
||||
try:
|
||||
await opnsense_client.delete_snapshot(host, uuid)
|
||||
still = any(str(r.get("uuid")) == uuid for r in await opnsense_client.list_snapshots(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||
await audit_service.persist(**audit, status="unverified" if still else "applied", message=None, details={})
|
||||
if still:
|
||||
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
|
||||
@@ -17,6 +17,7 @@ from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
STEP_SECONDS = 30
|
||||
DIGITS = 6
|
||||
@@ -94,14 +95,22 @@ def _path(user_id: uuid.UUID, pending: bool) -> Path:
|
||||
|
||||
def save_secret(user_id: uuid.UUID, secret: str, pending: bool = False) -> None:
|
||||
path = _path(user_id, pending)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret)
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret)
|
||||
|
||||
|
||||
# An enrolment that was started but never completed expires, so a forgotten
|
||||
# QR code (or a stolen session's half-finished attempt) does not stay valid.
|
||||
PENDING_TTL_SECONDS = 15 * 60
|
||||
|
||||
|
||||
def read_secret(user_id: uuid.UUID, pending: bool = False) -> str | None:
|
||||
path = _path(user_id, pending)
|
||||
return path.read_text().strip() if path.exists() else None
|
||||
if not path.exists():
|
||||
return None
|
||||
if pending and time.time() - path.stat().st_mtime > PENDING_TTL_SECONDS:
|
||||
path.unlink(missing_ok=True)
|
||||
return None
|
||||
return path.read_text().strip()
|
||||
|
||||
|
||||
def delete_secrets(user_id: uuid.UUID) -> None:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Client für den optionalen OPNexus-Updater-Container (updater/server.py).
|
||||
|
||||
Das Backend kann selbst nichts am Host ändern und soll das auch nicht können:
|
||||
Es fragt den Updater-Dienst nach dem Zustand und reicht eine bereits
|
||||
geprüfte Update-/Rollback-Anfrage weiter. Ist der Dienst nicht aktiviert oder
|
||||
nicht erreichbar, ist das ein normaler Zustand ("nicht verfügbar"), kein Fehler.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STATUS_TIMEOUT = 50.0 # der Updater fragt docker ab (bis zu 45 s)
|
||||
_ACTION_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class UpdaterUnavailable(Exception):
|
||||
"""Updater nicht aktiviert (kein Token) oder nicht erreichbar."""
|
||||
|
||||
|
||||
class UpdaterRejected(Exception):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
try:
|
||||
token = Path(settings.updater_token_file).read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
token = ""
|
||||
if not token:
|
||||
raise UpdaterUnavailable("not_enabled")
|
||||
return token
|
||||
|
||||
|
||||
async def _call(method: str, path: str, payload: dict | None, timeout: float) -> dict:
|
||||
headers = {"Authorization": f"Bearer {_token()}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as http:
|
||||
response = await http.request(method, settings.updater_url.rstrip("/") + path, headers=headers, json=payload)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.info("Updater nicht erreichbar: %s", exc.__class__.__name__)
|
||||
raise UpdaterUnavailable("unreachable") from exc
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = {}
|
||||
if response.status_code == 401:
|
||||
# Token passt nicht (Volume neu angelegt?) — wie "nicht verfügbar" behandeln.
|
||||
raise UpdaterUnavailable("unauthorized")
|
||||
if response.status_code >= 400:
|
||||
raise UpdaterRejected(str(body.get("error") or "rejected"))
|
||||
return body
|
||||
|
||||
|
||||
async def status() -> dict:
|
||||
return await _call("GET", "/status", None, _STATUS_TIMEOUT)
|
||||
|
||||
|
||||
async def start_update(version: str) -> dict:
|
||||
return await _call("POST", "/update", {"version": version}, _ACTION_TIMEOUT)
|
||||
|
||||
|
||||
async def start_rollback() -> dict:
|
||||
return await _call("POST", "/rollback", {}, _ACTION_TIMEOUT)
|
||||
@@ -324,6 +324,9 @@ async def _deploy_to_host(db: AsyncSession, tunnel: WireguardTunnel, host: Opnse
|
||||
|
||||
try:
|
||||
await _write_config(host, payloads, deployment)
|
||||
# Without the global switch the config is stored but no interface
|
||||
# ever comes up (live-verified), so a "success" would be a lie.
|
||||
await opnsense_client.ensure_wireguard_enabled(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
deployment.status = "error"
|
||||
deployment.message = str(exc)
|
||||
@@ -388,7 +391,7 @@ async def undeploy_tunnel(db: AsyncSession, tunnel: WireguardTunnel) -> 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()
|
||||
|
||||
@@ -28,6 +28,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.services import secure_files
|
||||
|
||||
_PRIVATE_KEY_SUFFIX = ".wgkey"
|
||||
_PSK_SUFFIX = ".wgpsk"
|
||||
@@ -39,9 +40,7 @@ def _path(tunnel_id: uuid.UUID, suffix: str) -> Path:
|
||||
|
||||
def save_private_key(tunnel_id: uuid.UUID, secret: str) -> None:
|
||||
path = _path(tunnel_id, _PRIVATE_KEY_SUFFIX)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_private_key(tunnel_id: uuid.UUID) -> str | None:
|
||||
@@ -57,9 +56,7 @@ def delete_private_key(tunnel_id: uuid.UUID) -> None:
|
||||
|
||||
def save_psk(tunnel_id: uuid.UUID, secret: str) -> None:
|
||||
path = _path(tunnel_id, _PSK_SUFFIX)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(secret.strip())
|
||||
path.chmod(0o600)
|
||||
secure_files.write_private(path, secret.strip())
|
||||
|
||||
|
||||
def read_psk(tunnel_id: uuid.UUID) -> str | None:
|
||||
@@ -71,3 +68,15 @@ def read_psk(tunnel_id: uuid.UUID) -> str | None:
|
||||
|
||||
def delete_psk(tunnel_id: uuid.UUID) -> None:
|
||||
_path(tunnel_id, _PSK_SUFFIX).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def version(tunnel_id: uuid.UUID) -> str:
|
||||
"""Opaque change marker for both secrets (last write times) — lets an approval
|
||||
token notice a rotated key/PSK without containing, or being derivable from, them."""
|
||||
marks = []
|
||||
for suffix in (_PRIVATE_KEY_SUFFIX, _PSK_SUFFIX):
|
||||
try:
|
||||
marks.append(str(_path(tunnel_id, suffix).stat().st_mtime_ns))
|
||||
except FileNotFoundError:
|
||||
marks.append("-")
|
||||
return ":".join(marks)
|
||||
@@ -8,7 +8,8 @@ import hashlib
|
||||
import json
|
||||
|
||||
from app.models.policy import WireguardTunnelRevision
|
||||
from app.services.wireguard_deploy_service import build_payloads
|
||||
from app.services import wireguard_key_store
|
||||
from app.services.wireguard_deploy_service import _redact_for_storage, build_payloads
|
||||
from app.services.policy_service import effective_hosts # noqa: F401 - re-exported for routers/wireguard.py
|
||||
|
||||
WIREGUARD_FIELDS = (
|
||||
@@ -43,7 +44,8 @@ def revision(db, tunnel, note):
|
||||
|
||||
|
||||
def preview(tunnel):
|
||||
payloads = build_payloads(tunnel)
|
||||
# Compare in the stored (secret -> hash) form, see ipsec_service.preview().
|
||||
payloads = _redact_for_storage(build_payloads(tunnel))
|
||||
deployments = {d.host_id: d for d in tunnel.deployments}
|
||||
hosts = effective_hosts(tunnel)
|
||||
rows = []
|
||||
@@ -103,6 +105,7 @@ def preview(tunnel):
|
||||
blocked = not tunnel.server_private_key_set or (tunnel.peer_psk_enabled and not tunnel.peer_psk_set)
|
||||
state = {
|
||||
"snapshot": snapshot(tunnel), "rows": rows,
|
||||
"secret_version": wireguard_key_store.version(tunnel.id),
|
||||
"hosts": [(str(h.id), h.base_url, h.enabled, h.verify_tls) for h in hosts],
|
||||
"deployments": sorted(
|
||||
[(str(d.host_id), d.opnsense_server_uuid, d.opnsense_peer_uuid, d.last_payload,
|
||||
@@ -123,7 +126,7 @@ def workflow(tunnel, deployments):
|
||||
hosts = effective_hosts(tunnel)
|
||||
active = [h for h in hosts if h.enabled]
|
||||
by_host = {d.host_id: d for d in deployments}
|
||||
payloads = build_payloads(tunnel)
|
||||
payloads = _redact_for_storage(build_payloads(tunnel))
|
||||
target_ids = {h.id for h in hosts}
|
||||
counts = {
|
||||
"targets": len(hosts),
|
||||
|
||||
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user