Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d489693d | ||
|
|
a9f562eec6 | ||
|
|
a3274f64ae | ||
|
|
9ba6357193 | ||
|
|
23d3a00ae1 | ||
|
|
4379a17b15 | ||
|
|
983e05a816 | ||
|
|
dd6a64d953 | ||
|
|
6964ec6537 | ||
|
|
5e5d51b57f | ||
|
|
1aba618ad5 | ||
|
|
ba954c05bf | ||
|
|
63d014b14c | ||
|
|
4838dad588 |
No files matched your search
+3
-1
@@ -73,6 +73,8 @@ TRUST_PROXY_HEADERS=false
|
||||
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
||||
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
|
||||
# (optional, siehe unten) per Klick im Webinterface.
|
||||
# Einfacher: im Webinterface unter Einstellungen › OPNexus aktualisieren einschalten.
|
||||
# Ein Wert hier hat Vorrang und sperrt den Schalter dort.
|
||||
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||
# UPDATE_CHECK_INTERVAL_HOURS=12
|
||||
|
||||
@@ -80,7 +82,7 @@ TRUST_PROXY_HEADERS=false
|
||||
# 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".
|
||||
# AUS. Braucht außerdem die Versionsprüfung (Webinterface oder UPDATE_CHECK_URL, oben). Siehe Handbuch, "Updates per Klick".
|
||||
# COMPOSE_PROFILES=updater
|
||||
|
||||
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
||||
|
||||
+169
@@ -31,6 +31,175 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.26.0] "Kite" — 2026-10-21
|
||||
|
||||
Geplant als MINOR-Release **1.26.0** (Regel-Analyse). Backend, MCP und Oberfläche.
|
||||
|
||||
- **Regel-Analyse „Welche Regel greift?“ (API):** `GET /api/hosts/{id}/rule-analysis/match` und
|
||||
`GET /api/rule-analysis/match` (ganze Flotte, Auswahl oder Gerätegruppe) beantworten für Quelle, Ziel, Protokoll
|
||||
und Port, ob eine Firewall das erste Paket einer neuen Verbindung durchlässt oder blockiert, welche Regel das
|
||||
entscheidet und warum (Prüfprotokoll Regel für Regel). Grundlage ist der tatsächlich geladene pf-Regelsatz
|
||||
inklusive automatischer Regeln, Floating- und Gruppenregeln, `quick`/letzter Treffer, Negation, verschachtelter
|
||||
Aliase (pf-Tabellentest der Firewall), Port-Aliase und Adressfamilien. Jede Antwort trägt eine ehrliche
|
||||
Konfidenz **sicher / vermutlich / unbekannt** mit Begründungscodes; was nicht belegbar ist (z. B. `tagged`,
|
||||
nicht auflösbare Aliase, geratenes Interface, NAT in der Nähe), wird nie als „sicher“ gemeldet. Nur lesend,
|
||||
auch mit `read`-Token nutzbar; es wird nichts gespeichert und nichts an der Firewall geändert.
|
||||
- **MCP:** neues Werkzeug `rule_match` für KI-Assistenten (gleiche Abfrage, ohne Prüfprotokoll).
|
||||
- **Seite „Regel-Analyse“ (Regelwerk):** Formular für Hosts oder Gerätegruppe, Quell-/Ziel-IP, Protokoll, Ports,
|
||||
Eingangs-Interface („automatisch“ oder Liste) und optional die Ausgangsseite. Die Abfrage steht in der Adresszeile
|
||||
(teilbar, wird beim Öffnen sofort ausgeführt). Je Firewall eine Karte mit Erlaubt/Blockiert/Abgewiesen, Sicherheit
|
||||
des Urteils, entscheidender Regel und Hinweisen; ein „unbekannt“-Ergebnis erscheint bewusst nicht als Urteil.
|
||||
Aufklappbares „Warum?“ zeigt jede geprüfte Regel mit ✓/✗/? je Feld, überstimmte Regeln durchgestrichen. Auch mit
|
||||
Nur-Lesen-Rolle nutzbar.
|
||||
|
||||
## [1.25.0] "Goshawk" — 2026-10-21
|
||||
|
||||
Geplant als MINOR-Release **1.25.0** (Flotten-Rollout).
|
||||
|
||||
- **Rollouts in Wellen (System › Rollouts):** Firmware-Updates für die ganze Flotte in Ringen, z. B. erst
|
||||
`ring-canary`, dann `ring-pilot`, dann `ring-prod` (Wellen aus Gerätegruppen). Jeder Host läuft über die
|
||||
bestehende geplante Wartung (Backup vor dem Update, CARP-Übergabe für den MASTER, Audit), immer nur ein Host
|
||||
gleichzeitig und nur ein Rollout zur selben Zeit; im Cluster zuerst der BACKUP, dann der MASTER. Nach jeder Welle
|
||||
folgen eine Beobachtungszeit und eine **Gesundheitsprüfung** (erreichbar, Zielversion installiert, CARP je VHID,
|
||||
Gateways und Dienste wie vorher, keine offenen Sperren, optional Sicherheits-Score). Rot hält den Rollout an
|
||||
(nach einer Nachprüfung zwei Minuten später) und meldet sich. Anhalten, Fortsetzen (prüft zuerst alles bisher
|
||||
Aktualisierte erneut), Welle freigeben, Host überspringen und Abbrechen auf der Lauf-Seite; die Seite zeigt
|
||||
Wellen, Hosts (Version vorher → nachher, aktueller Schritt) und die Prüfmatrix.
|
||||
- **Sicherheitsnetz:** Weicht die angebotene Version in einem späteren Ring von der des ersten Rings ab, hält der
|
||||
Rollout an (OPNsense installiert immer die neueste). Ein Host ohne Update wird übersprungen, ein fehlgeschlagener
|
||||
nie von selbst wiederholt. Angehalten wird auch, wenn die verantwortliche Person das Recht zum Ausrollen verliert,
|
||||
eine fremde Wartung im Cluster länger als 15 Minuten offen ist, der MASTER ohne Übergabe dran wäre oder ein Lauf
|
||||
älter als 14 Tage ist. Höchstens 10 Wellen und 100 Hosts je Plan.
|
||||
- **Wiederkehrende Wartungsfenster:** wöchentlich oder monatlich (n-ter Wochentag, letzter, fester Tag) in einer
|
||||
Zeitzone, mit Sommerzeit-Regel, Abschneidezeit („neue Hosts nur in den ersten N Minuten“) und einzelnen Terminen
|
||||
zum Auslassen oder Verschieben. Ein Plan mit Fenster startet neue Hosts nur darin; mit **automatischem Start**
|
||||
(nur stabile Updates) beginnt er zu jedem Termin von selbst, aber nur, solange Plan, Gruppen und Fenster seit
|
||||
der Aktivierung unverändert sind. Jede Änderung deaktiviert ihn.
|
||||
- **Geplante Wartung prüft sich selbst:** Neue Option „Ergebnis automatisch prüfen und abschließen“ (im Dialog
|
||||
vorausgewählt, bei Rollouts immer an). Nach dem Auftrag prüft OPNexus, ob die Firmware wieder bereit ist, der
|
||||
erwartete Neustart stattfand und die Version lesbar ist, und schließt die Wartung dann selbst ab. Bisher blieb
|
||||
sie auf „übergeben“ und sperrte Host und Cluster, bis jemand sie von Hand abschloss.
|
||||
- **Benachrichtigungen:** „Rollout angehalten“ und „Rollout nicht automatisch gestartet“ (ein Schalter, standardmäßig an).
|
||||
- **Rechte:** Rollout starten/aktivieren/fortsetzen/freigeben/überspringen/abbrechen und Wartungsfenster ändern
|
||||
gelten als Ausrollen (Aufgabentrennung); **Anhalten** darf jeder mit Schreibrecht. Update Center: Hosts im
|
||||
laufenden Rollout sind für manuelle Firmware-Aktionen gesperrt. Ein OPNexus-Update während eines Rollouts ist
|
||||
sicher, wird aber angekündigt.
|
||||
- **Getestet:** live auf den Test-Firewalls (OPNsense 26.7): automatischer Start im Fenster, Canary-Update
|
||||
26.7.1_1 → 26.7.7, absichtlich gestoppter Dienst → Gate rot → angehalten (zweiter Ring unberührt,
|
||||
Benachrichtigung), Fortsetzen, Pilot-Update 26.7.6 → 26.7.7 auf dem damaligen MASTER mit CARP-Übergabe und einem
|
||||
Neustart des OPNexus-Backends mitten im Auftrag (genau ein Update-Auftrag), Freigabe, Host ohne Update
|
||||
übersprungen, Anhalten/Fortsetzen/Abbrechen von Hand, Termin auslassen. **Nur gemockt:** Major-Upgrade als
|
||||
Rollout, ein Cluster in *einer* Welle, Cluster-Prüfung vor dem zweiten Knoten, abweichende Zielversion,
|
||||
Zeitüberschreitungen, Rechteverlust, Fenster schließt mitten im Lauf; die Sommerzeit-Regeln sind Rechentests.
|
||||
|
||||
## [1.24.0] "Merlin" — 2026-10-21
|
||||
|
||||
- **Alarme für Gateways, Ressourcen und Dienste:** Neue Ereignisse in Einstellungen › Benachrichtigungen.
|
||||
**Gateway gestört** (standardmäßig **an**) meldet ein Gateway, das OPNsense als „down“ führt oder dessen Paketverlust (ab 20 %) bzw.
|
||||
Latenz (ab 500 ms) über der Schwelle liegt; bewusst abgeschaltete und nicht überwachte Gateways bleiben still. **Ressourcen-Schwelle**
|
||||
(standardmäßig aus) prüft Festplatte (ab 90 %), Arbeitsspeicher (ab 95 %), Swap (ab 50 %) und CPU-Last (ab 2 pro Thread), **Dienst ausgefallen**
|
||||
(standardmäßig aus) eine einstellbare Liste (Unbound, Dnsmasq, DHCP, NTP, OpenVPN, WireGuard, IPsec …) und meldet nur Dienste, die zuvor
|
||||
liefen. Alle drei stützen sich auf die Minutenmessung und lösen erst aus, wenn die letzten 3 (Ressourcen: 5) Messungen übereinstimmen, und
|
||||
enden erst, wenn alle wieder in Ordnung sind: Einzelspitzen und flackernde Verbindungen schicken nichts. Der Gateway-Status (Verlust, Latenz,
|
||||
Abweichung) steht außerdem auf der Host-Seite, als Kachel auf dem Dashboard und als Prometheus-Metriken (`opnexus_host_gateway_*`,
|
||||
`opnexus_host_swap_used_percent`, `opnexus_host_service_running`). Die Messung speichert dafür zusätzlich Swap, CPU-Threads, Gateways
|
||||
und Dienste in der vorhandenen Messreihe (keine neue Tabelle).
|
||||
- **Weitere Benachrichtigungskanäle:** ntfy, Gotify, Telegram, Microsoft Teams (Workflows-/Incoming-Webhook) und Matrix, beliebig viele
|
||||
je Typ, mit „Testen“-Knopf pro Kanal. Zugangsdaten (Tokens, die Teams-Adresse) liegen wie das SMTP-Passwort in der Geheimnis-Ablage, nie
|
||||
in der Datenbank oder in Antworten, und tauchen nicht in Fehlermeldungen oder Logs auf. Webhook und neue Kanäle prüfen die Zieladresse:
|
||||
nur http(s), keine Zugangsdaten in der Adresse, keine lokalen/Link-Local-/Metadaten-Adressen; Weiterleitungen werden nicht verfolgt.
|
||||
Betrifft die bestehende Webhook-Adresse: `localhost`, Loopback, 169.254.x.x und Zugangsdaten in der Adresse (`https://user:pass@…`)
|
||||
werden jetzt abgelehnt. Eine solche gespeicherte Adresse wird nicht mehr beliefert (nur ein Log-Eintrag), und die
|
||||
Benachrichtigungs-Einstellungen lassen sich erst wieder speichern, wenn sie korrigiert oder geleert ist (die Meldung nennt den Grund).
|
||||
Neue Einstellungen kommen mit Migration 0020.
|
||||
|
||||
- **Bestand übernehmen für Aliase und NAT:** Auf den Seiten Aliase und NAT (Port Forward, 1:1, Outbound) gibt es jetzt **Von Firewall übernehmen**
|
||||
wie bei den Regeln. Der Dialog zeigt, was von Hand in OPNsense angelegt wurde, einzeln zum Übernehmen oder mit „Alle übernehmbaren“ in einem Schritt;
|
||||
verschachtelte Aliase werden in der richtigen Reihenfolge übernommen. Die Übernahme schreibt nichts auf die Firewall: Die Vorschau zeigt direkt
|
||||
danach „keine Änderung“, der nächste Rollout aktualisiert dasselbe Objekt (kein Duplikat). Nicht übernommen werden OPNsense-eigene Aliase,
|
||||
Typen, die OPNexus nicht kennt (z. B. URL, GeoIP), Aliase mit Verweis auf nicht übernommene Aliase sowie NAT-Regeln, die das Modell verfälschen würden
|
||||
(z. B. „Kein Redirect“, verknüpfte Filterregel, mehrere Schnittstellen, „Kein NAT“); die Oberfläche nennt den Grund je Objekt.
|
||||
Outbound-NAT lässt sich nur übernehmen, wenn der Modus der Firewall Hybrid oder Manuell ist. Einstellungen, die OPNexus nicht verwaltet (Protokollierung,
|
||||
Pool-Optionen, Tags, Kategorien), bleiben unverändert und werden angezeigt.
|
||||
|
||||
- **Images für ARM:** Alle Release-Images (backend, frontend, proxy, updater) gibt es jetzt für `linux/amd64` und `linux/arm64`
|
||||
(Raspberry Pi 4/5, ARM-Server, Apple-Silicon-Docker).
|
||||
|
||||
## [1.23.11] "Harrier" — 2026-10-20
|
||||
|
||||
- **Updates per Klick prüfen jetzt ebenfalls die Signatur:** Der Updater-Container enthält `cosign` (feste Version, Prüfsumme beim Bau
|
||||
geprüft) und verifiziert die neuen Images vor dem Wechsel, wenn `cosign.pub` im Projektverzeichnis liegt. Das Image wird dadurch größer
|
||||
(rund 266 statt 123 MB).
|
||||
|
||||
## [1.23.10] "Harrier" — 2026-10-20
|
||||
|
||||
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
|
||||
Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
|
||||
installiert ist. Anleitung: Handbuch, „Images prüfen“.
|
||||
|
||||
## [1.23.9] "Harrier" — 2026-10-20
|
||||
|
||||
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
|
||||
statt „baut aus dem Quellcode“.
|
||||
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
|
||||
|
||||
## [1.23.8] "Harrier" — 2026-10-20
|
||||
|
||||
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
|
||||
die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
|
||||
`docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
|
||||
|
||||
## [1.23.7] "Harrier" — 2026-10-20
|
||||
|
||||
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
|
||||
|
||||
## [1.23.6] "Harrier" — 2026-10-20
|
||||
|
||||
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
|
||||
und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
|
||||
`UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
|
||||
|
||||
## [1.23.5] "Harrier" — 2026-10-20
|
||||
|
||||
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
|
||||
und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
|
||||
bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
|
||||
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
|
||||
darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
|
||||
|
||||
## [1.23.4] "Harrier" — 2026-10-20
|
||||
|
||||
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
|
||||
im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
|
||||
anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
|
||||
Prüfung weicht ab“.
|
||||
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
|
||||
einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
|
||||
|
||||
## [1.23.3] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
|
||||
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
|
||||
Garage, MinIO, RustFS und Versity S3 Gateway.
|
||||
|
||||
## [1.23.2] "Harrier" — 2026-10-20
|
||||
|
||||
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
|
||||
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
|
||||
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
|
||||
|
||||
## [1.23.1] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
|
||||
- Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
|
||||
- Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
|
||||
- SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
|
||||
- Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
|
||||
- Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
|
||||
- Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
|
||||
- Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
|
||||
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
|
||||
|
||||
## [1.23.0] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
|
||||
|
||||
+8
-6
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **Stand:** `1.23.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
> **Stand:** `1.26.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,14 +16,16 @@ 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.
|
||||
- **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, Aliase und NAT-Regeln lassen sich übernehmen.
|
||||
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
|
||||
- **Alarme** — Gateway-/WAN-Ausfall (down, Paketverlust, Latenz), Schwellen für Platte, Speicher, Swap und CPU sowie ausgefallene Dienste; über mehrere Messungen entprellt, damit Einzelspitzen und flackernde Verbindungen schweigen. Zustellung per E-Mail, Webhook, ntfy, Gotify, Telegram, Microsoft Teams oder Matrix, jedes Ereignis einzeln schaltbar.
|
||||
- **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.
|
||||
- **Update-Center und Rollouts** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus; Rollouts in Wellen (Canary → Pilot → Prod) mit wiederkehrenden Wartungsfenstern, Gesundheitsprüfung zwischen den Wellen und automatischem Halt bei jeder Auffälligkeit.
|
||||
- **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.
|
||||
- **Regel-Analyse** — „Welche Regel trifft?“ für Quelle, Ziel, Protokoll und Port: die entscheidende Regel je Firewall (oder Flotte/Gruppe) mit ehrlicher Sicherheit (sicher / vermutlich / unbekannt) und Prüfprotokoll Regel für Regel, nur lesend.
|
||||
- **Backups & Wiederherstellung** — geplante Konfigurationssicherungen mit Aufbewahrung, verschlüsselte Offsite-Kopie auf S3-kompatiblen Speicher, Änderungsansicht zwischen Sicherungen und eine vollständige verschlüsselte Sicherung von OPNexus selbst (`opnexus backup --full`).
|
||||
- **Single Sign-on** — Anmeldung über deinen Identitätsanbieter (OIDC); die Rolle kommt bei jeder Anmeldung aus dessen Gruppen.
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Operator / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||
@@ -58,11 +60,11 @@ Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassis
|
||||
|
||||
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`:
|
||||
**Fertige Images** (amd64 und arm64) liegen in der Registry dieses Gitea. In der `.env`:
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.23.0
|
||||
OPNEXUS_VERSION=1.26.0
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -73,7 +75,7 @@ Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen,
|
||||
|
||||
## Datenschutz
|
||||
|
||||
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) ist **aus**, solange du ihn nicht setzt; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
||||
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (Schalter unter Einstellungen › OPNexus aktualisieren, oder `UPDATE_CHECK_URL` in der `.env`) ist **aus**, solange du ihn nicht einschaltest; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **Status:** `1.23.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
> **Status:** `1.26.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,14 +16,16 @@ 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.
|
||||
- **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, aliases and NAT rules created by hand on a firewall can be adopted.
|
||||
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
|
||||
- **Alerts** — gateway/WAN outage (down, packet loss, latency), disk/memory/swap/CPU thresholds and stopped services, debounced over several measurements so spikes and flapping links stay quiet; delivered by email, webhook, ntfy, Gotify, Telegram, Microsoft Teams or Matrix, all switchable per event.
|
||||
- **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.
|
||||
- **Update center & rollouts** — firmware updates for the fleet, aware of HA order and maintenance mode; rollouts in waves (canary → pilot → prod) with recurring maintenance windows, a health gate between the waves and an automatic stop on anything unusual.
|
||||
- **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.
|
||||
- **Rule analysis** — "Which rule matches?" for a source, destination, protocol and port: the deciding rule per firewall (or fleet/group) with an honest confidence (sure / probable / unknown) and a rule-by-rule trace, read-only.
|
||||
- **Backups & recovery** — scheduled config backups with retention, an encrypted offsite copy to S3-compatible storage, a redacted change view between backups, and a full encrypted backup/restore of OPNexus itself (`opnexus backup --full`).
|
||||
- **Single sign-on** — sign in with your identity provider (OIDC); roles come from its groups at every login.
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / operator / read-only), keyboard operation, dark mode, German and English UI.
|
||||
@@ -60,11 +62,11 @@ After the first sign-in a short **setup assistant** opens on its own: set your o
|
||||
|
||||
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`:
|
||||
**Prebuilt images** (amd64 and arm64) are published to this Gitea's registry. In `.env`:
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.23.0
|
||||
OPNEXUS_VERSION=1.26.0
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -77,7 +79,7 @@ docker compose pull && docker compose up -d --no-build
|
||||
|
||||
## Privacy
|
||||
|
||||
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
||||
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (switch under Settings › Update OPNexus, or `UPDATE_CHECK_URL` in the `.env`) is **off** unless you turn it on; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ _READ_ONLY_ALLOWED_WRITES = {
|
||||
_SECURITY_KINDS = {
|
||||
"ipsec": "ipsec_tunnel", "openvpn": "openvpn_instance", "wireguard": "wireguard_tunnel",
|
||||
"certificates": "certificate", "acme": "acme", "notifications": "notification", "auth": "auth",
|
||||
"tokens": "api_token",
|
||||
"tokens": "api_token", "rollouts": "rollout",
|
||||
}
|
||||
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings"}
|
||||
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings", "plans", "runs"}
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
@@ -117,7 +117,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
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", "backups": "backup"}
|
||||
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system", "backups": "backup", "maintenance-windows": "maintenance_window"}
|
||||
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}}
|
||||
|
||||
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
|
||||
# keine Instanz- oder Nutzerdaten.
|
||||
update_check_url: str = ""
|
||||
update_check_interval_hours: int = 12
|
||||
# Registry der Release-Images (OPNEXUS_IMAGE_REPO). Leer = OPNexus wird lokal aus dem
|
||||
# Quellcode gebaut; dann gibt es keine Updates per Klick (nur der Hinweis in den Einstellungen).
|
||||
opnexus_image_repo: str = ""
|
||||
|
||||
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
|
||||
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
|
||||
|
||||
@@ -11,6 +11,8 @@ class ErrorCode(str, Enum):
|
||||
UPDATER_REJECTED = "UPDATER_REJECTED"
|
||||
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
|
||||
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
|
||||
UPDATE_CHECK_MANAGED_BY_SERVER = "UPDATE_CHECK_MANAGED_BY_SERVER"
|
||||
UPDATE_CHECK_URL_INVALID = "UPDATE_CHECK_URL_INVALID"
|
||||
ADMIN_REQUIRED = "ADMIN_REQUIRED"
|
||||
BACKUP_EMPTY = "BACKUP_EMPTY"
|
||||
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
||||
@@ -114,6 +116,9 @@ class ErrorCode(str, Enum):
|
||||
NETWORK_FIELD_INVALID = "NETWORK_FIELD_INVALID"
|
||||
NETWORK_ITEM_NOT_FOUND = "NETWORK_ITEM_NOT_FOUND"
|
||||
RULE_NOT_ADOPTABLE = "RULE_NOT_ADOPTABLE"
|
||||
OBJECT_NOT_ADOPTABLE = "OBJECT_NOT_ADOPTABLE"
|
||||
ADOPT_OBJECT_NOT_FOUND = "ADOPT_OBJECT_NOT_FOUND"
|
||||
OUTBOUND_MODE_BLOCKS_ADOPT = "OUTBOUND_MODE_BLOCKS_ADOPT"
|
||||
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"
|
||||
@@ -160,6 +165,11 @@ class ErrorCode(str, Enum):
|
||||
VARIABLES_MISSING = "VARIABLES_MISSING"
|
||||
VARIABLE_INVALID = "VARIABLE_INVALID"
|
||||
OVERLAY_INVALID = "OVERLAY_INVALID"
|
||||
NOTIFICATION_CHANNEL_INVALID = "NOTIFICATION_CHANNEL_INVALID"
|
||||
NOTIFICATION_CHANNEL_NOT_FOUND = "NOTIFICATION_CHANNEL_NOT_FOUND"
|
||||
NOTIFICATION_CHANNEL_NAME_TAKEN = "NOTIFICATION_CHANNEL_NAME_TAKEN"
|
||||
NOTIFICATION_CHANNEL_TEST_FAILED = "NOTIFICATION_CHANNEL_TEST_FAILED"
|
||||
NOTIFICATION_WEBHOOK_URL_INVALID = "NOTIFICATION_WEBHOOK_URL_INVALID"
|
||||
OVERLAY_SUBNET_LOCKED = "OVERLAY_SUBNET_LOCKED"
|
||||
OVERLAY_NAME_TAKEN = "OVERLAY_NAME_TAKEN"
|
||||
OVERLAY_PEER_NOT_FOUND = "OVERLAY_PEER_NOT_FOUND"
|
||||
@@ -169,6 +179,32 @@ class ErrorCode(str, Enum):
|
||||
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"
|
||||
WINDOW_NOT_FOUND = "WINDOW_NOT_FOUND"
|
||||
WINDOW_INVALID = "WINDOW_INVALID"
|
||||
WINDOW_NAME_TAKEN = "WINDOW_NAME_TAKEN"
|
||||
WINDOW_IN_USE = "WINDOW_IN_USE"
|
||||
WINDOW_EXCEPTION_INVALID = "WINDOW_EXCEPTION_INVALID"
|
||||
WINDOW_EXCEPTION_EXISTS = "WINDOW_EXCEPTION_EXISTS"
|
||||
WINDOW_EXCEPTION_NOT_FOUND = "WINDOW_EXCEPTION_NOT_FOUND"
|
||||
ROLLOUT_PLAN_NOT_FOUND = "ROLLOUT_PLAN_NOT_FOUND"
|
||||
ROLLOUT_INVALID = "ROLLOUT_INVALID"
|
||||
ROLLOUT_NAME_TAKEN = "ROLLOUT_NAME_TAKEN"
|
||||
ROLLOUT_PLAN_INVALID = "ROLLOUT_PLAN_INVALID"
|
||||
ROLLOUT_PREVIEW_STALE = "ROLLOUT_PREVIEW_STALE"
|
||||
ROLLOUT_PLAN_BUSY = "ROLLOUT_PLAN_BUSY"
|
||||
ROLLOUT_GROUP_IN_USE = "ROLLOUT_GROUP_IN_USE"
|
||||
ROLLOUT_RUN_NOT_FOUND = "ROLLOUT_RUN_NOT_FOUND"
|
||||
ROLLOUT_ALREADY_ACTIVE = "ROLLOUT_ALREADY_ACTIVE"
|
||||
ROLLOUT_CONFIRM_NAME = "ROLLOUT_CONFIRM_NAME"
|
||||
ROLLOUT_ACTION_NOT_ALLOWED = "ROLLOUT_ACTION_NOT_ALLOWED"
|
||||
ROLLOUT_HOST_NOT_IN_RUN = "ROLLOUT_HOST_NOT_IN_RUN"
|
||||
ROLLOUT_HOST_RUNNING = "ROLLOUT_HOST_RUNNING"
|
||||
ROLLOUT_HOST_FAILED = "ROLLOUT_HOST_FAILED"
|
||||
ANALYSE_ADRESSE_UNGUELTIG = "ANALYSE_ADRESSE_UNGUELTIG"
|
||||
ANALYSE_FAMILIEN_MISCHUNG = "ANALYSE_FAMILIEN_MISCHUNG"
|
||||
ANALYSE_INTERFACE_UNBEKANNT = "ANALYSE_INTERFACE_UNBEKANNT"
|
||||
ANALYSE_PARAMETER_UNGUELTIG = "ANALYSE_PARAMETER_UNGUELTIG"
|
||||
ANALYSE_FIREWALL_NICHT_LESBAR = "ANALYSE_FIREWALL_NICHT_LESBAR"
|
||||
|
||||
|
||||
def api_error(status_code: int, code: ErrorCode, **params: Any) -> HTTPException:
|
||||
|
||||
@@ -46,10 +46,15 @@ from app.version import APP_VERSION
|
||||
from app.routers import dashboard as dashboard_router
|
||||
from app.routers import cluster as cluster_router
|
||||
from app.routers import services as services_router
|
||||
from app.routers import gateways as gateways_router
|
||||
from app.routers import resource_samples as resource_samples_router
|
||||
from app.services import resource_monitor_service
|
||||
from app.routers import scheduled_maintenance as scheduled_maintenance_router
|
||||
from app.routers import maintenance_windows as maintenance_windows_router
|
||||
from app.routers import rollouts as rollouts_router
|
||||
from app.routers import rule_analysis as rule_analysis_router
|
||||
from app.services import scheduled_maintenance_service
|
||||
from app.services import rollout_engine
|
||||
from app.services import compliance_service
|
||||
from app.services import audit_export_service
|
||||
from app.services import overlay_service, variables_service
|
||||
@@ -254,6 +259,12 @@ async def _scheduled_maintenance_poll_loop():
|
||||
except Exception:
|
||||
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
|
||||
in_progress = 0
|
||||
# Firmware rollouts (1.25) only ever create schedules; the next tick of the lines above fires them.
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
in_progress += await rollout_engine.advance_runs(db)
|
||||
except Exception:
|
||||
logger.exception("Rollout-Poll fehlgeschlagen")
|
||||
# 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)
|
||||
@@ -510,9 +521,13 @@ 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(gateways_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")
|
||||
app.include_router(maintenance_windows_router.router, prefix="/api")
|
||||
app.include_router(rollouts_router.router, prefix="/api")
|
||||
app.include_router(rule_analysis_router.router, prefix="/api")
|
||||
@@ -93,3 +93,9 @@ __all__ += ["HostResourceSample"]
|
||||
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
__all__ += ["ScheduledMaintenance"]
|
||||
|
||||
from app.models.maintenance_window import MaintenanceWindow, MaintenanceWindowException
|
||||
__all__ += ["MaintenanceWindow", "MaintenanceWindowException"]
|
||||
|
||||
from app.models.rollout import RolloutPlan, RolloutPlanWave, RolloutRun, RolloutRunHost, RolloutRunWave, rollout_wave_groups
|
||||
__all__ += ["RolloutPlan", "RolloutPlanWave", "RolloutRun", "RolloutRunHost", "RolloutRunWave", "rollout_wave_groups"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Recurring maintenance windows (1.25): "every Sunday 02:00-05:00 Europe/Berlin",
|
||||
"first Tuesday of the month 22:00 for 3 h". A window on its own changes nothing on any
|
||||
firewall; rollout plans (models/rollout.py) use it to decide WHEN they may start work on a host.
|
||||
|
||||
Times are wall-clock times in the window's own IANA time zone, so a window stays at 02:00
|
||||
local time across daylight-saving changes (see services/window_service.py for the rules).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import date, datetime, time, timezone
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, String, Time, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class MaintenanceWindow(Base):
|
||||
__tablename__ = "maintenance_windows"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64))
|
||||
recurrence: Mapped[str] = mapped_column(String(16)) # weekly | monthly
|
||||
# weekly: weekdays 0 (Monday) .. 6 (Sunday). JSON columns get a Python default only (Alembic gotcha, see 0007).
|
||||
weekdays: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||
# monthly: "nth_weekday" (month_nth 1-4 or -1 = last, month_weekday 0-6) or "day" (month_day 1-28)
|
||||
month_mode: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
month_nth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
month_weekday: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
month_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
start_time: Mapped[time] = mapped_column(Time)
|
||||
duration_minutes: Mapped[int] = mapped_column(Integer)
|
||||
# After this many minutes into the window no NEW host action starts (running ones finish).
|
||||
start_cutoff_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=60, server_default="60")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
created_by: Mapped[str] = mapped_column(String(150))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
exceptions: Mapped[list["MaintenanceWindowException"]] = relationship(
|
||||
"MaintenanceWindowException", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin",
|
||||
order_by="MaintenanceWindowException.occurrence_date",
|
||||
)
|
||||
|
||||
|
||||
class MaintenanceWindowException(Base):
|
||||
"""One occurrence skipped or moved. Keyed by the occurrence's LOCAL date."""
|
||||
|
||||
__tablename__ = "maintenance_window_exceptions"
|
||||
__table_args__ = (UniqueConstraint("window_id", "occurrence_date", name="uq_window_exception_date"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
window_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("maintenance_windows.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
occurrence_date: Mapped[date] = mapped_column(Date)
|
||||
kind: Mapped[str] = mapped_column(String(8)) # skip | move
|
||||
moved_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reason: Mapped[str] = mapped_column(String(300), default="")
|
||||
actor: Mapped[str] = mapped_column(String(150))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
@@ -6,7 +6,7 @@ OPNsense API secrets stay off the database entirely.
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Float, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -17,6 +17,13 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
DEFAULT_SERVICE_WATCHLIST = (
|
||||
"unbound", "dnsmasq", "dhcpd", "dhcpd6", "kea-dhcp4", "kea-dhcp6", "ntpd", "openvpn", "wireguard", "strongswan",
|
||||
)
|
||||
|
||||
CHANNEL_TYPES = ("ntfy", "gotify", "telegram", "teams", "matrix")
|
||||
|
||||
|
||||
class NotificationSettings(Base):
|
||||
"""Single row, id fixed at 1 — see notification_settings_service.get_settings
|
||||
for the get-or-create helper. The only admin-editable operational config
|
||||
@@ -55,6 +62,30 @@ class NotificationSettings(Base):
|
||||
# Off by default: switching it on for an existing fleet reports every long-standing finding at once.
|
||||
compliance_alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
# Monitoring alerts (1.24). The gateway alert is on by default (a WAN outage is the one thing every
|
||||
# operator wants to hear about); resource thresholds and service checks are off, like compliance alerts:
|
||||
# switching them on for an existing fleet may report long-standing conditions all at once.
|
||||
gateway_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
resource_threshold_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
service_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
# Rollouts (1.25): a run paused (gate, failure, window...) or an automatic start did not happen.
|
||||
rollout_problems_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
# Gateway: OPNsense' own verdict (down / loss / delay) decides "offline"; these two add an
|
||||
# OPNexus threshold on the measured values (0 = off).
|
||||
gateway_loss_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=20, server_default="20")
|
||||
gateway_delay_threshold_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=500, server_default="500")
|
||||
# Resources (0 = that threshold is off). Load is per CPU thread.
|
||||
disk_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=90, server_default="90")
|
||||
memory_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=95, server_default="95")
|
||||
swap_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=50, server_default="50")
|
||||
load_per_cpu_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=2.0, server_default="2.0")
|
||||
# Debouncing: consecutive 60 s resource samples that must agree before a problem opens or closes.
|
||||
alert_confirm_samples: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3")
|
||||
resource_confirm_samples: Mapped[int] = mapped_column(Integer, nullable=False, default=5, server_default="5")
|
||||
# OPNsense service names that are reported when they stop (the API cannot tell a crash from a stop on purpose).
|
||||
# NULL = the default list (an old row never got one).
|
||||
service_watchlist: Mapped[list | None] = mapped_column(JSON, nullable=True, default=lambda: list(DEFAULT_SERVICE_WATCHLIST))
|
||||
|
||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
@@ -85,3 +116,21 @@ class NotificationProblem(Base):
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolved_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class NotificationChannel(Base):
|
||||
"""An additional delivery channel (ntfy, Gotify, Telegram, Microsoft Teams, Matrix) next to the SMTP
|
||||
and generic-webhook settings above. Several channels of one type are allowed. ``config`` holds only
|
||||
non-secret parameters (server URL, topic, chat id, room id); the credential — token, or the whole
|
||||
webhook URL for Teams — lives in notification_secret_store under ``channel_<id>``, never here."""
|
||||
|
||||
__tablename__ = "notification_channels"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
channel_type: Mapped[str] = mapped_column(String(16))
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
@@ -22,7 +22,7 @@ class OidcSettings(Base):
|
||||
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email groups")
|
||||
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
|
||||
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
|
||||
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Firmware rollouts in waves (1.25): plans (what, in which order, when) and runs (what happened).
|
||||
|
||||
A plan's waves point at device groups ("rings": canary -> pilot -> prod). A run freezes the plan
|
||||
(``plan_snapshot``) and the resolved hosts (``RolloutRunHost``) when it starts, so later edits of
|
||||
the plan or of a group never change a run that is under way. The run never fires firmware actions
|
||||
itself: every host step is an ordinary ScheduledMaintenance row (origin="rollout",
|
||||
auto_verify=True) linked from ``RolloutRunHost.scheduled_maintenance_id`` — see
|
||||
services/rollout_service.py.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text,
|
||||
UniqueConstraint)
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
rollout_wave_groups = Table(
|
||||
"rollout_wave_groups", Base.metadata,
|
||||
Column("wave_id", PGUUID(as_uuid=True), ForeignKey("rollout_plan_waves.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class RolloutPlan(Base):
|
||||
__tablename__ = "rollout_plans"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
action: Mapped[str] = mapped_column(String(16)) # update | major_update
|
||||
window_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("maintenance_windows.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
# Start a run by itself at every occurrence of the window. Only for "update" and only while the plan
|
||||
# is activated AND unchanged since (activated_fingerprint).
|
||||
auto_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
gate_checks: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
compliance_mode: Mapped[str] = mapped_column(String(8), nullable=False, default="warn", server_default="warn")
|
||||
activated_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
activated_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Why the last automatic start did not happen: {"occurrence", "code", "params", "at"} (notification source).
|
||||
auto_start_error: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_by: Mapped[str] = mapped_column(String(150))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
waves: Mapped[list["RolloutPlanWave"]] = relationship(
|
||||
"RolloutPlanWave", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin",
|
||||
order_by="RolloutPlanWave.position")
|
||||
|
||||
|
||||
class RolloutPlanWave(Base):
|
||||
__tablename__ = "rollout_plan_waves"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("rollout_plans.id", ondelete="CASCADE"), index=True)
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
label: Mapped[str] = mapped_column(String(60))
|
||||
soak_minutes: Mapped[int] = mapped_column(Integer, default=30)
|
||||
carp_handover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
# Wait for a human "release" before this wave starts (not used for the first wave: starting is the release).
|
||||
require_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=rollout_wave_groups, lazy="selectin") # noqa: F821
|
||||
|
||||
|
||||
class RolloutRun(Base):
|
||||
__tablename__ = "rollout_runs"
|
||||
__table_args__ = (UniqueConstraint("plan_id", "window_occurrence", name="uq_rollout_run_occurrence"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
plan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("rollout_plans.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
plan_name: Mapped[str] = mapped_column(String(120))
|
||||
plan_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
action: Mapped[str] = mapped_column(String(16))
|
||||
# waiting_window | preflight | wave_running | soaking | gate | awaiting_approval | resuming | paused
|
||||
# | succeeded | cancelled
|
||||
status: Mapped[str] = mapped_column(String(20), index=True)
|
||||
paused_from: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
current_wave: Mapped[int] = mapped_column(Integer, default=0)
|
||||
target_version: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
pause_reason: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
pause_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
started_by: Mapped[str] = mapped_column(String(150))
|
||||
trigger: Mapped[str] = mapped_column(String(8)) # manual | window
|
||||
window_occurrence: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Optimistic lock: every state change bumps it; an engine step computed on an older revision is dropped.
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
progress: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
step_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
waves: Mapped[list["RolloutRunWave"]] = relationship(
|
||||
"RolloutRunWave", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin", order_by="RolloutRunWave.index")
|
||||
hosts: Mapped[list["RolloutRunHost"]] = relationship(
|
||||
"RolloutRunHost", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin",
|
||||
order_by=lambda: [RolloutRunHost.wave_index, RolloutRunHost.order])
|
||||
|
||||
|
||||
class RolloutRunWave(Base):
|
||||
__tablename__ = "rollout_run_waves"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
run_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("rollout_runs.id", ondelete="CASCADE"), index=True)
|
||||
index: Mapped[int] = mapped_column(Integer)
|
||||
label: Mapped[str] = mapped_column(String(60))
|
||||
# pending | awaiting_approval | preflight | running | soaking | gate | passed | skipped | cancelled
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
gate_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
soak_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
gate_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
approved_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class RolloutRunHost(Base):
|
||||
__tablename__ = "rollout_run_hosts"
|
||||
__table_args__ = (UniqueConstraint("run_id", "host_id", name="uq_rollout_run_host"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
run_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("rollout_runs.id", ondelete="CASCADE"), index=True)
|
||||
wave_index: Mapped[int] = mapped_column(Integer)
|
||||
host_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
host_name: Mapped[str] = mapped_column(String(120))
|
||||
cluster_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# queued | running | verifying | succeeded | skipped | failed | blocked | cancelled
|
||||
status: Mapped[str] = mapped_column(String(16), default="queued")
|
||||
scheduled_maintenance_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("scheduled_maintenance.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
version_before: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
version_after: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
# {"code": ..., "params": {...}} — translated in the UI (rollouts.hostNote.*)
|
||||
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -42,7 +42,12 @@ class ScheduledMaintenance(Base):
|
||||
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.
|
||||
# 1.25: check the result automatically (job_wait -> verify) and close the operation, instead of
|
||||
# leaving it "submitted" for a human. Every rollout step sets it; off for existing plans.
|
||||
auto_verify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
# manual | rollout (created by a rollout run, see models/rollout.py RolloutRunHost.scheduled_maintenance_id)
|
||||
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="manual", server_default="manual")
|
||||
# Only while status == "running" with a handover or auto_verify: handover_wait | job_wait | verify | 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)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Version check configured in the web UI (singleton row, id fixed at 1).
|
||||
|
||||
Off by default: OPNexus makes no outgoing request until an admin switches it on. An
|
||||
UPDATE_CHECK_URL in the server's .env still wins over this row (server-managed)."""
|
||||
from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
|
||||
|
||||
|
||||
class UpdateCheckSettings(Base):
|
||||
__tablename__ = "update_check_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
feed_url: Mapped[str] = mapped_column(String(500), nullable=False, default=DEFAULT_FEED_URL, server_default=DEFAULT_FEED_URL)
|
||||
@@ -99,6 +99,12 @@ class Hub:
|
||||
code, out = self.run("iptables", "-I", *rule)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
# The hub only needs its own proxy: never route between peers or into the compose network.
|
||||
fwd = ("FORWARD", "-j", "DROP")
|
||||
if self.run("iptables", "-C", *fwd)[0] != 0:
|
||||
code, out = self.run("iptables", "-I", *fwd)
|
||||
if code != 0:
|
||||
return f"iptables: {out}"[:300]
|
||||
return None
|
||||
|
||||
def status(self, error: str | None) -> dict:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""The three "take over what exists on the firewall" endpoints that aliases, port forwards, 1:1 NAT and
|
||||
outbound NAT share (services/object_adopt_service.py has the logic and the evidence):
|
||||
|
||||
GET <prefix>/unmanaged?host_id=… candidates found live on the host, with reasons for the refused ones
|
||||
POST <prefix>/adopt take over one object {host_id, uuid}; returns the new central object
|
||||
POST <prefix>/adopt-all take over everything adoptable {host_id}; returns adopted + skipped
|
||||
|
||||
Like rule adoption this only creates central records; nothing is written to the firewall."""
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
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.host import OpnsenseHost
|
||||
from app.schemas.adopt import AdoptAllRequest, AdoptAllResult, AdoptObjectRequest, UnmanagedObject
|
||||
from app.services import object_adopt_service
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def add_adopt_routes(router: APIRouter, *, kind: str, model, load_options: Callable, to_read: Callable, read_model) -> None:
|
||||
@router.get("/unmanaged", response_model=list[UnmanagedObject])
|
||||
async def list_unmanaged(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host(db, host_id)
|
||||
try:
|
||||
return await object_adopt_service.list_unmanaged(db, kind, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
|
||||
@router.post("/adopt", response_model=read_model, status_code=201)
|
||||
async def adopt(payload: AdoptObjectRequest, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host(db, payload.host_id)
|
||||
try:
|
||||
created = await object_adopt_service.adopt(db, kind, host, payload.uuid)
|
||||
except object_adopt_service.NotFoundOnHost as exc:
|
||||
raise api_error(404, ErrorCode.ADOPT_OBJECT_NOT_FOUND) from exc
|
||||
except object_adopt_service.NotAdoptable as exc:
|
||||
raise api_error(409, ErrorCode.OBJECT_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.
|
||||
created = (await db.execute(
|
||||
select(model).where(model.id == created.id).options(*load_options())
|
||||
.execution_options(populate_existing=True)
|
||||
)).scalar_one()
|
||||
await hydrate_hierarchy_targets(db, [created])
|
||||
return to_read(created, created.targets, created.deployments)
|
||||
|
||||
@router.post("/adopt-all", response_model=AdoptAllResult)
|
||||
async def adopt_all(payload: AdoptAllRequest, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host(db, payload.host_id)
|
||||
try:
|
||||
result = await object_adopt_service.adopt_all(db, kind, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
await db.commit()
|
||||
return result
|
||||
@@ -17,6 +17,7 @@ from app.schemas.alias import DeploymentResult, AliasCreate, AliasRead, AliasEdi
|
||||
from app.services.alias_deploy_service import confirm_alias, deploy_alias, undeploy_alias, revert_alias_deployment
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
|
||||
from app.routers.adopt_routes import add_adopt_routes
|
||||
|
||||
router = APIRouter(prefix="/aliases", tags=["aliases"])
|
||||
|
||||
@@ -70,6 +71,9 @@ def _to_read(alias: FirewallAlias, hosts: list[OpnsenseHost], deployments: list[
|
||||
)
|
||||
|
||||
|
||||
add_adopt_routes(router, kind="alias", model=FirewallAlias, load_options=_load_options, to_read=_to_read, read_model=AliasRead)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AliasRead])
|
||||
async def list_aliases(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(FirewallAlias).options(*_load_options()))
|
||||
|
||||
@@ -30,11 +30,11 @@ def client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=settings.session_lifetime,
|
||||
max_age=max_age or settings.session_lifetime,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.cookie_secure,
|
||||
@@ -305,6 +305,7 @@ async def change_password(
|
||||
# an attacker may hold) and the replacement session commit as one unit.
|
||||
user.password_hash = await auth_service.hash_password_async(payload.new_password)
|
||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ locks are deliberately NOT included here (no fleet-wide list endpoint
|
||||
exists for those, only per-host, and it's a rarely-triggered flow) —
|
||||
a documented v1 gap, not an oversight.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -46,7 +48,10 @@ from app.models.rule_deployment import RuleDeployment
|
||||
from app.models.wireguard_tunnel import WireguardTunnel
|
||||
from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment
|
||||
from app.schemas.dashboard import ActivityItem, DashboardHost, DashboardSummary, PendingAction
|
||||
from app.models.resource_sample import HostResourceSample
|
||||
from app.services import gateway_health_service
|
||||
from app.services.host_status_service import get_statuses
|
||||
from app.services.monitor_checks import STALE_AFTER
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
@@ -202,14 +207,34 @@ async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
|
||||
return pending_actions
|
||||
|
||||
|
||||
async def _gateways_by_host(db: AsyncSession, hosts) -> dict:
|
||||
"""Gateway state from each host's newest resource sample; hosts without a fresh sample are left out."""
|
||||
result: dict = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
for host in hosts:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(HostResourceSample.sampled_at, HostResourceSample.raw["gateways"].as_json())
|
||||
.where(HostResourceSample.host_id == host.id)
|
||||
.order_by(HostResourceSample.sampled_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
if row is not None and row[1] is not None and now - row[0] <= STALE_AFTER:
|
||||
result[host.id] = gateway_health_service.from_compact(row[1])
|
||||
return result
|
||||
|
||||
|
||||
@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)
|
||||
gateways = await _gateways_by_host(db, 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,
|
||||
gateways=gateways.get(host.id),
|
||||
)
|
||||
for host in hosts
|
||||
]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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.schemas.gateway import GatewayHealth
|
||||
from app.services import gateway_health_service
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/gateways", tags=["host-gateways"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[GatewayHealth])
|
||||
async def list_gateways(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""Live gateway state (loss, delay, deviation, OPNsense' verdict) of one firewall."""
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
return await gateway_health_service.get_gateway_health(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
@@ -99,6 +99,9 @@ async def delete_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
for assignment in (port_forward_groups, one_to_one_groups, outbound_nat_groups):
|
||||
if await db.scalar(select(assignment.c.group_id).where(assignment.c.group_id == group_id).limit(1)):
|
||||
raise api_error(409, ErrorCode.GRUPPE_WIRD_NOCH_VON_NAT_REGELN_VERWENDET)
|
||||
from app.models.rollout import rollout_wave_groups
|
||||
if await db.scalar(select(rollout_wave_groups.c.group_id).where(rollout_wave_groups.c.group_id == group_id).limit(1)):
|
||||
raise api_error(409, ErrorCode.ROLLOUT_GROUP_IN_USE)
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Recurring maintenance windows and their exceptions (1.25).
|
||||
|
||||
Every write here counts as a rollout action (api_token_service.is_rollout): a window decides WHEN
|
||||
unattended firmware actions may run, so an operator needs the same right as for deploying.
|
||||
"""
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.maintenance_window import MaintenanceWindow, MaintenanceWindowException
|
||||
from app.services import audit_service, window_service
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
router = APIRouter(prefix="/maintenance-windows", tags=["maintenance-windows"])
|
||||
|
||||
_TIME = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
||||
|
||||
|
||||
class WindowIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
timezone: str = Field(min_length=1, max_length=64)
|
||||
recurrence: Literal["weekly", "monthly"]
|
||||
weekdays: list[int] = Field(default_factory=list, max_length=7)
|
||||
month_mode: Literal["nth_weekday", "day"] | None = None
|
||||
month_nth: int | None = None
|
||||
month_weekday: int | None = None
|
||||
month_day: int | None = None
|
||||
start_time: str
|
||||
duration_minutes: int = Field(ge=15, le=window_service.MAX_DURATION_MINUTES)
|
||||
start_cutoff_minutes: int = Field(default=60, ge=5, le=window_service.MAX_DURATION_MINUTES)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ExceptionIn(BaseModel):
|
||||
occurrence_date: date
|
||||
kind: Literal["skip", "move"]
|
||||
moved_start: datetime | None = None
|
||||
reason: str = Field(default="", max_length=300)
|
||||
|
||||
|
||||
def _invalid(field: str):
|
||||
return api_error(422, ErrorCode.WINDOW_INVALID, field=field)
|
||||
|
||||
|
||||
def _validated(payload: WindowIn) -> dict:
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise _invalid("name")
|
||||
if not window_service.valid_timezone(payload.timezone):
|
||||
raise _invalid("timezone")
|
||||
match = _TIME.match(payload.start_time)
|
||||
if not match:
|
||||
raise _invalid("start_time")
|
||||
values = {
|
||||
"name": name, "timezone": payload.timezone, "recurrence": payload.recurrence,
|
||||
"weekdays": [], "month_mode": None, "month_nth": None, "month_weekday": None, "month_day": None,
|
||||
"start_time": time(int(match.group(1)), int(match.group(2))),
|
||||
"duration_minutes": payload.duration_minutes,
|
||||
"start_cutoff_minutes": min(payload.start_cutoff_minutes, payload.duration_minutes),
|
||||
"enabled": payload.enabled,
|
||||
}
|
||||
if payload.recurrence == "weekly":
|
||||
days = sorted(set(payload.weekdays))
|
||||
if not days or any(d < 0 or d > 6 for d in days):
|
||||
raise _invalid("weekdays")
|
||||
values["weekdays"] = days
|
||||
elif payload.month_mode == "day":
|
||||
if payload.month_day is None or not 1 <= payload.month_day <= 28:
|
||||
raise _invalid("month_day")
|
||||
values.update(month_mode="day", month_day=payload.month_day)
|
||||
elif payload.month_mode == "nth_weekday":
|
||||
if payload.month_nth not in (1, 2, 3, 4, -1):
|
||||
raise _invalid("month_nth")
|
||||
if payload.month_weekday is None or not 0 <= payload.month_weekday <= 6:
|
||||
raise _invalid("month_weekday")
|
||||
values.update(month_mode="nth_weekday", month_nth=payload.month_nth, month_weekday=payload.month_weekday)
|
||||
else:
|
||||
raise _invalid("month_mode")
|
||||
return values
|
||||
|
||||
|
||||
async def _plans_using(db: AsyncSession, window_id) -> list[dict]:
|
||||
from app.models.rollout import RolloutPlan
|
||||
rows = (await db.scalars(select(RolloutPlan).where(RolloutPlan.window_id == window_id).order_by(RolloutPlan.name))).all()
|
||||
return [{"id": str(p.id), "name": p.name, "auto_start": p.auto_start, "active": p.activated_at is not None} for p in rows]
|
||||
|
||||
|
||||
def read_window(window: MaintenanceWindow, *, now: datetime | None = None, count: int = 5) -> dict:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
return {
|
||||
"id": str(window.id), "name": window.name, "timezone": window.timezone, "recurrence": window.recurrence,
|
||||
"weekdays": window.weekdays or [], "month_mode": window.month_mode, "month_nth": window.month_nth,
|
||||
"month_weekday": window.month_weekday, "month_day": window.month_day,
|
||||
"start_time": window.start_time.strftime("%H:%M"), "duration_minutes": window.duration_minutes,
|
||||
"start_cutoff_minutes": window.start_cutoff_minutes, "enabled": window.enabled,
|
||||
"created_by": window.created_by, "created_at": window.created_at,
|
||||
"open_now": window_service.current(window, now) is not None,
|
||||
"next": [o.as_dict() for o in window_service.occurrences(window, now, count, with_skipped=True)] if window.enabled else [],
|
||||
"exceptions": [
|
||||
{"id": str(e.id), "occurrence_date": e.occurrence_date.isoformat(), "kind": e.kind,
|
||||
"moved_start": e.moved_start, "reason": e.reason, "actor": e.actor, "created_at": e.created_at}
|
||||
for e in window.exceptions
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _window_or_404(db: AsyncSession, window_id: uuid.UUID) -> MaintenanceWindow:
|
||||
window = await db.get(MaintenanceWindow, window_id)
|
||||
if window is None:
|
||||
raise api_error(404, ErrorCode.WINDOW_NOT_FOUND)
|
||||
return window
|
||||
|
||||
|
||||
async def _name_taken(db: AsyncSession, name: str, own_id=None) -> bool:
|
||||
other = await db.scalar(select(MaintenanceWindow.id).where(MaintenanceWindow.name == name))
|
||||
return other is not None and other != own_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_windows(db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.scalars(select(MaintenanceWindow).order_by(MaintenanceWindow.name))).all()
|
||||
result = []
|
||||
for w in rows:
|
||||
item = read_window(w)
|
||||
item["used_by"] = await _plans_using(db, w.id)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_window(payload: WindowIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
values = _validated(payload)
|
||||
if await _name_taken(db, values["name"]):
|
||||
raise api_error(409, ErrorCode.WINDOW_NAME_TAKEN)
|
||||
window = MaintenanceWindow(**values, created_by=request.state.user.username, created_at=datetime.now(timezone.utc))
|
||||
window.exceptions = []
|
||||
db.add(window)
|
||||
await db.commit()
|
||||
item = read_window(await _window_or_404(db, window.id))
|
||||
item["used_by"] = []
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{window_id}")
|
||||
async def get_window(window_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
item = read_window(await _window_or_404(db, window_id))
|
||||
item["used_by"] = await _plans_using(db, window_id)
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/{window_id}")
|
||||
async def update_window(window_id: uuid.UUID, payload: WindowIn, db: AsyncSession = Depends(get_db)):
|
||||
"""Changing a window changes the fingerprint of every plan that uses it, so an activated
|
||||
automatic start stops until the plan is activated again (see rollout_service.fingerprint)."""
|
||||
await policy_lock(db)
|
||||
window = await _window_or_404(db, window_id)
|
||||
values = _validated(payload)
|
||||
if await _name_taken(db, values["name"], window.id):
|
||||
raise api_error(409, ErrorCode.WINDOW_NAME_TAKEN)
|
||||
for key, value in values.items():
|
||||
setattr(window, key, value)
|
||||
await db.commit()
|
||||
item = read_window(await _window_or_404(db, window_id))
|
||||
item["used_by"] = await _plans_using(db, window_id)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{window_id}", status_code=204)
|
||||
async def delete_window(window_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
window = await _window_or_404(db, window_id)
|
||||
# A plan without a window means "no time limit": deleting its window must never turn it into one.
|
||||
if await _plans_using(db, window_id):
|
||||
raise api_error(409, ErrorCode.WINDOW_IN_USE)
|
||||
await db.delete(window)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/{window_id}/occurrences")
|
||||
async def window_occurrences(window_id: uuid.UUID, count: int = Query(10, ge=1, le=50), db: AsyncSession = Depends(get_db)):
|
||||
window = await _window_or_404(db, window_id)
|
||||
return [o.as_dict() for o in window_service.occurrences(window, datetime.now(timezone.utc), count, with_skipped=True)]
|
||||
|
||||
|
||||
@router.post("/{window_id}/exceptions", status_code=201)
|
||||
async def add_exception(window_id: uuid.UUID, payload: ExceptionIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
window = await _window_or_404(db, window_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if not window_service.is_occurrence_date(window, payload.occurrence_date):
|
||||
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="occurrence_date")
|
||||
# Only future occurrences (or the one open now) can be changed; history stays as it was.
|
||||
if window_service.local_start(window, payload.occurrence_date) + timedelta(minutes=window.duration_minutes) <= now:
|
||||
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="occurrence_date")
|
||||
moved = None
|
||||
if payload.kind == "move":
|
||||
if payload.moved_start is None:
|
||||
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="moved_start")
|
||||
moved = payload.moved_start if payload.moved_start.tzinfo else payload.moved_start.replace(tzinfo=timezone.utc)
|
||||
if moved <= now:
|
||||
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="moved_start")
|
||||
if any(e.occurrence_date == payload.occurrence_date for e in window.exceptions):
|
||||
raise api_error(409, ErrorCode.WINDOW_EXCEPTION_EXISTS)
|
||||
exc = MaintenanceWindowException(
|
||||
window_id=window.id, occurrence_date=payload.occurrence_date, kind=payload.kind, moved_start=moved,
|
||||
reason=payload.reason.strip(), actor=request.state.user.username, created_at=now,
|
||||
)
|
||||
db.add(exc)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise api_error(409, ErrorCode.WINDOW_EXCEPTION_EXISTS)
|
||||
await audit_service.persist(
|
||||
kind="maintenance_window", action=f"exception_{payload.kind}", status="saved", entity_id=str(window.id),
|
||||
entity_name=window.name, details={"occurrence_date": payload.occurrence_date.isoformat(),
|
||||
"moved_start": moved.isoformat() if moved else None, "reason": exc.reason},
|
||||
)
|
||||
db.expire_all()
|
||||
return read_window(await _window_or_404(db, window_id))
|
||||
|
||||
|
||||
@router.delete("/{window_id}/exceptions/{exception_id}")
|
||||
async def delete_exception(window_id: uuid.UUID, exception_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
window = await _window_or_404(db, window_id)
|
||||
exc = await db.get(MaintenanceWindowException, exception_id)
|
||||
if exc is None or exc.window_id != window.id:
|
||||
raise api_error(404, ErrorCode.WINDOW_EXCEPTION_NOT_FOUND)
|
||||
await db.delete(exc)
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
return read_window(await _window_or_404(db, window_id))
|
||||
|
||||
@@ -88,6 +88,31 @@ TOOLS = [
|
||||
"path": "/api/search",
|
||||
"query": ("q",),
|
||||
},
|
||||
{
|
||||
"name": "rule_match",
|
||||
"description": "Which firewall rule decides a packet (first packet of a new connection), per firewall, "
|
||||
"with the verdict (pass/block/reject), the deciding rule and an honest confidence "
|
||||
"(sure/probable/unknown plus reason codes). Reads only; nothing is changed.",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["src", "dst"],
|
||||
"properties": {
|
||||
"src": {"type": "string", "description": "source IP address"},
|
||||
"dst": {"type": "string", "description": "destination IP address"},
|
||||
"proto": {"type": "string", "description": "tcp, udp, icmp, ipv6-icmp or a protocol number", "default": "tcp"},
|
||||
"dport": {"type": "integer", "minimum": 0, "maximum": 65535},
|
||||
"sport": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "default: any ephemeral port"},
|
||||
"icmptype": {"type": "string", "description": "e.g. echoreq or 8"},
|
||||
"iface": {"type": "string", "description": "ingress interface identifier (e.g. lan); default: guessed per firewall"},
|
||||
"egress": {"type": "string", "description": "also check the outgoing side: auto or an interface identifier"},
|
||||
"host_ids": {"type": "string", "description": "comma separated firewall ids (default: all)"},
|
||||
"group_id": {"type": "string", "description": "device group id instead of host_ids"},
|
||||
},
|
||||
},
|
||||
"path": "/api/rule-analysis/match",
|
||||
"query": ("src", "dst", "proto", "dport", "sport", "icmptype", "iface", "egress", "host_ids", "group_id"),
|
||||
# The per-rule trace is for the UI; the verdict, deciding rule and reasons are what matters here.
|
||||
"drop": ("trace",),
|
||||
},
|
||||
{
|
||||
"name": "api_get",
|
||||
"description": "Read any other OPNexus API endpoint (GET only), e.g. /api/rules, /api/aliases, /api/logs. "
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import uuid
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.models.notification import NotificationChannel, NotificationProblem
|
||||
from app.schemas.notification import (
|
||||
NotificationChannelCreate,
|
||||
NotificationChannelRead,
|
||||
NotificationChannelUpdate,
|
||||
NotificationProblemRead,
|
||||
NotificationSettingsRead,
|
||||
NotificationSettingsUpdate,
|
||||
TestNotificationRequest,
|
||||
)
|
||||
from app.services import notification_delivery, notification_settings_service
|
||||
from app.services import notification_channels, notification_delivery, notification_secret_store, notification_settings_service, url_guard
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
@@ -43,6 +48,11 @@ async def read_settings(db: AsyncSession = Depends(get_db), user: AdminUser = De
|
||||
|
||||
@router.put("/settings", response_model=NotificationSettingsRead)
|
||||
async def update_settings(payload: NotificationSettingsUpdate, db: AsyncSession = Depends(get_db)):
|
||||
if payload.webhook_url:
|
||||
try:
|
||||
payload.webhook_url = url_guard.validate_url(payload.webhook_url)
|
||||
except ValueError as exc:
|
||||
raise api_error(422, ErrorCode.NOTIFICATION_WEBHOOK_URL_INVALID, reason=str(exc)) from None
|
||||
return await notification_settings_service.update_settings(db, payload)
|
||||
|
||||
|
||||
@@ -79,3 +89,103 @@ async def test_notification(payload: TestNotificationRequest, db: AsyncSession =
|
||||
except notification_delivery.NotificationDeliveryError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ── Additional channels (ntfy, Gotify, Telegram, Teams, Matrix) ────────────────
|
||||
|
||||
def _channel_read(row: NotificationChannel, user: AdminUser) -> NotificationChannelRead:
|
||||
read = NotificationChannelRead.model_validate(row)
|
||||
# An ntfy topic or a chat id is as good as a password for whoever knows it: only admins see the settings.
|
||||
return read if user.role == "admin" else read.model_copy(update={"config": {}})
|
||||
|
||||
|
||||
async def _get_channel(db: AsyncSession, channel_id: uuid.UUID) -> NotificationChannel:
|
||||
row = await db.get(NotificationChannel, channel_id)
|
||||
if row is None:
|
||||
raise api_error(404, ErrorCode.NOTIFICATION_CHANNEL_NOT_FOUND)
|
||||
return row
|
||||
|
||||
|
||||
def _invalid(exc: ValueError):
|
||||
return api_error(422, ErrorCode.NOTIFICATION_CHANNEL_INVALID, reason=str(exc))
|
||||
|
||||
|
||||
@router.get("/channels", response_model=list[NotificationChannelRead])
|
||||
async def list_channels(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
rows = await db.scalars(select(NotificationChannel).order_by(NotificationChannel.name))
|
||||
return [_channel_read(r, user) for r in rows.all()]
|
||||
|
||||
|
||||
@router.post("/channels", response_model=NotificationChannelRead, status_code=201)
|
||||
async def create_channel(payload: NotificationChannelCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
try:
|
||||
config = notification_channels.clean_config(payload.channel_type, payload.config)
|
||||
secret = notification_channels.clean_secret(payload.channel_type, payload.secret) if payload.secret else None
|
||||
except ValueError as exc:
|
||||
raise _invalid(exc) from None
|
||||
row = NotificationChannel(
|
||||
name=payload.name.strip(), channel_type=payload.channel_type, enabled=payload.enabled, config=config,
|
||||
secret_set=secret is not None,
|
||||
)
|
||||
row.id = uuid.uuid4()
|
||||
if secret is None and notification_channels.missing_requirements(row) == "Zugangsdaten fehlen":
|
||||
raise _invalid(ValueError("Zugangsdaten fehlen"))
|
||||
db.add(row)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise api_error(409, ErrorCode.NOTIFICATION_CHANNEL_NAME_TAKEN) from None
|
||||
if secret is not None:
|
||||
notification_secret_store.save_secret(notification_channels.secret_name(row.id), secret)
|
||||
await db.commit()
|
||||
return _channel_read(row, user)
|
||||
|
||||
|
||||
@router.put("/channels/{channel_id}", response_model=NotificationChannelRead)
|
||||
async def update_channel(
|
||||
channel_id: uuid.UUID, payload: NotificationChannelUpdate,
|
||||
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
|
||||
):
|
||||
row = await _get_channel(db, channel_id)
|
||||
try:
|
||||
if payload.config is not None:
|
||||
row.config = notification_channels.clean_config(row.channel_type, payload.config)
|
||||
new_secret = notification_channels.clean_secret(row.channel_type, payload.secret) if payload.secret else None
|
||||
except ValueError as exc:
|
||||
raise _invalid(exc) from None
|
||||
if payload.name is not None:
|
||||
row.name = payload.name.strip()
|
||||
if payload.enabled is not None:
|
||||
row.enabled = payload.enabled
|
||||
if payload.clear_secret:
|
||||
notification_secret_store.delete_secret(notification_channels.secret_name(row.id))
|
||||
row.secret_set = False
|
||||
elif new_secret is not None:
|
||||
notification_secret_store.save_secret(notification_channels.secret_name(row.id), new_secret)
|
||||
row.secret_set = True
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise api_error(409, ErrorCode.NOTIFICATION_CHANNEL_NAME_TAKEN) from None
|
||||
return _channel_read(row, user)
|
||||
|
||||
|
||||
@router.delete("/channels/{channel_id}", status_code=204)
|
||||
async def delete_channel(channel_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
row = await _get_channel(db, channel_id)
|
||||
notification_secret_store.delete_secret(notification_channels.secret_name(row.id))
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/channels/{channel_id}/test")
|
||||
async def test_channel(channel_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
row = await _get_channel(db, channel_id)
|
||||
try:
|
||||
await notification_channels.send_test(row)
|
||||
except notification_delivery.NotificationDeliveryError as exc:
|
||||
# The text names the channel and the HTTP status; it never contains a URL or a token.
|
||||
raise api_error(502, ErrorCode.NOTIFICATION_CHANNEL_TEST_FAILED, reason=str(exc)) from None
|
||||
return {"success": True}
|
||||
@@ -1,5 +1,7 @@
|
||||
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -10,6 +12,7 @@ from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import _set_session_cookie, client_ip, current_user
|
||||
from app.config import settings
|
||||
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
|
||||
from app.security_middleware import _origin_allowed
|
||||
from app.services import auth_service, notification_secret_store, oidc_service
|
||||
@@ -18,10 +21,21 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["oidc"])
|
||||
CALLBACK_PATH = "/api/auth/oidc/callback"
|
||||
STATE_COOKIE = "opnexus-oidc-state"
|
||||
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
|
||||
# must not keep a 30-day session.
|
||||
SSO_SESSION_SECONDS = 12 * 3600
|
||||
|
||||
|
||||
def _login_error(code: str) -> RedirectResponse:
|
||||
return RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
def _printable(value: str) -> str:
|
||||
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
|
||||
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
|
||||
|
||||
|
||||
@router.get("/auth/oidc/config")
|
||||
@@ -42,11 +56,15 @@ async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db
|
||||
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
url = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||
except oidc_service.OidcError as exc:
|
||||
logger.warning("OIDC start fehlgeschlagen: %s", exc)
|
||||
return _login_error(exc.code)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
response = RedirectResponse(url, status_code=302)
|
||||
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
|
||||
# code+state pair is useless without the cookie.
|
||||
response.set_cookie(STATE_COOKIE, state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure, path="/api/auth/oidc")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
@@ -59,7 +77,9 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
|
||||
return _login_error("disabled")
|
||||
try:
|
||||
if error or not code or not state:
|
||||
raise oidc_service.OidcError("provider", f"Anbieter meldet {error or 'keinen Code'}")
|
||||
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
|
||||
if not secrets.compare_digest(request.cookies.get(STATE_COOKIE, ""), state):
|
||||
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
|
||||
user = await oidc_service.complete(db, cfg, code, state)
|
||||
except oidc_service.OidcError as exc:
|
||||
auth_service.throttle.record_failure(ip, "oidc")
|
||||
@@ -67,9 +87,12 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
|
||||
return _login_error(exc.code)
|
||||
auth_service.throttle.reset(ip, "oidc")
|
||||
await auth_service.finalize_login(db, user, ip)
|
||||
token, _ = await auth_service.create_session(db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip)
|
||||
token, _ = await auth_service.create_session(
|
||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
|
||||
)
|
||||
response = RedirectResponse("/dashboard", status_code=302)
|
||||
_set_session_cookie(response, token)
|
||||
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
|
||||
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
|
||||
return response
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.services.interface_service import check_interface_consistency
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.one_to_one_deploy_service import confirm_one_to_one, deploy_one_to_one, undeploy_one_to_one, revert_deployment
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
|
||||
from app.routers.adopt_routes import add_adopt_routes
|
||||
|
||||
router = APIRouter(prefix="/one-to-one", tags=["one-to-one"])
|
||||
|
||||
@@ -79,6 +80,9 @@ def _to_read(rule: OneToOneRule, hosts: list[OpnsenseHost], deployments: list[On
|
||||
)
|
||||
|
||||
|
||||
add_adopt_routes(router, kind="one_to_one", model=OneToOneRule, load_options=_load_options, to_read=_to_read, read_model=OneToOneRead)
|
||||
|
||||
|
||||
@router.get("", response_model=list[OneToOneRead])
|
||||
async def list_one_to_one_rules(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(OneToOneRule).options(*_load_options()))
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.services.interface_service import check_interface_consistency
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.outbound_nat_deploy_service import confirm_outbound_nat, deploy_outbound_nat, undeploy_outbound_nat, revert_deployment
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
|
||||
from app.routers.adopt_routes import add_adopt_routes
|
||||
|
||||
router = APIRouter(prefix="/outbound-nat", tags=["outbound-nat"])
|
||||
|
||||
@@ -82,6 +83,9 @@ def _to_read(rule: OutboundNatRule, hosts: list[OpnsenseHost], deployments: list
|
||||
)
|
||||
|
||||
|
||||
add_adopt_routes(router, kind="outbound_nat", model=OutboundNatRule, load_options=_load_options, to_read=_to_read, read_model=OutboundNatRead)
|
||||
|
||||
|
||||
@router.get("", response_model=list[OutboundNatRead])
|
||||
async def list_outbound_nat_rules(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(OutboundNatRule).options(*_load_options()))
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.services.interface_service import check_interface_consistency
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.port_forward_deploy_service import confirm_port_forward, deploy_port_forward, undeploy_port_forward, revert_deployment
|
||||
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
|
||||
from app.routers.adopt_routes import add_adopt_routes
|
||||
|
||||
router = APIRouter(prefix="/port-forward", tags=["port-forward"])
|
||||
|
||||
@@ -80,6 +81,9 @@ def _to_read(rule: PortForwardRule, hosts: list[OpnsenseHost], deployments: list
|
||||
)
|
||||
|
||||
|
||||
add_adopt_routes(router, kind="port_forward", model=PortForwardRule, load_options=_load_options, to_read=_to_read, read_model=PortForwardRead)
|
||||
|
||||
|
||||
@router.get("", response_model=list[PortForwardRead])
|
||||
async def list_port_forward_rules(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(PortForwardRule).options(*_load_options()))
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Firmware rollouts in waves (1.25): plans and runs.
|
||||
|
||||
Roles (enforced in AuthMiddleware, see api_token_service.is_rollout): everybody signed in reads;
|
||||
operators prepare plans; starting/activating a plan and resuming/approving/skipping/aborting a run
|
||||
need the deploy right (admin, or operator while operators may deploy). Pausing is deliberately NOT
|
||||
a rollout action: anybody who may write at all may always brake.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, Field
|
||||
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.maintenance_window import MaintenanceWindow
|
||||
from app.models.policy import DeviceGroup
|
||||
from app.models.rollout import RolloutPlan, RolloutPlanWave, RolloutRun
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.services import audit_service, rollout_engine, rollout_health, rollout_service
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
router = APIRouter(prefix="/rollouts", tags=["rollouts"])
|
||||
|
||||
ACTIVE_RUN = rollout_engine.ACTIVE
|
||||
|
||||
|
||||
class WaveIn(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=60)
|
||||
group_ids: list[uuid.UUID] = Field(min_length=1, max_length=20)
|
||||
soak_minutes: int = Field(default=30, ge=0, le=rollout_service.MAX_SOAK_MINUTES)
|
||||
carp_handover: bool = False
|
||||
require_approval: bool = False
|
||||
|
||||
|
||||
class PlanIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
action: Literal["update", "major_update"] = "update"
|
||||
window_id: uuid.UUID | None = None
|
||||
auto_start: bool = False
|
||||
gate_checks: dict[str, bool] = Field(default_factory=dict)
|
||||
compliance_mode: Literal["warn", "fail"] = "warn"
|
||||
waves: list[WaveIn] = Field(min_length=1, max_length=rollout_service.MAX_WAVES)
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
preview_token: str = Field(min_length=64, max_length=64)
|
||||
|
||||
|
||||
async def _plan_or_404(db: AsyncSession, plan_id: uuid.UUID) -> RolloutPlan:
|
||||
plan = await rollout_service.load_plan(db, plan_id)
|
||||
if plan is None:
|
||||
raise api_error(404, ErrorCode.ROLLOUT_PLAN_NOT_FOUND)
|
||||
return plan
|
||||
|
||||
|
||||
async def read_plan(db: AsyncSession, plan: RolloutPlan, resolved: dict | None = None) -> dict:
|
||||
resolved = resolved or await rollout_service.resolve(db, plan)
|
||||
active = plan.activated_at is not None
|
||||
last_run = await db.scalar(select(RolloutRun).where(RolloutRun.plan_id == plan.id).order_by(RolloutRun.created_at.desc()).limit(1))
|
||||
return {
|
||||
**rollout_service.definition(plan),
|
||||
"description": plan.description,
|
||||
"waves": [{"id": str(w.id), "position": w.position, "label": w.label, "soak_minutes": w.soak_minutes,
|
||||
"carp_handover": w.carp_handover, "require_approval": w.require_approval,
|
||||
"group_ids": [str(g.id) for g in w.groups], "groups": [g.name for g in w.groups],
|
||||
"host_count": len(resolved["waves"][i]["hosts"]) if i < len(resolved["waves"]) else 0}
|
||||
for i, w in enumerate(plan.waves)],
|
||||
"active": active, "activated_by": plan.activated_by, "activated_at": plan.activated_at,
|
||||
# Activated but the plan, its groups or its window changed since: the automatic start is held back.
|
||||
"fingerprint_ok": (not active) or plan.activated_fingerprint == rollout_service.fingerprint(plan, resolved),
|
||||
"auto_start_error": plan.auto_start_error,
|
||||
"window": resolved["window"], "errors": resolved["errors"], "warnings": resolved["warnings"],
|
||||
"host_count": sum(len(w["hosts"]) for w in resolved["waves"]),
|
||||
"last_run": {"id": str(last_run.id), "status": last_run.status, "created_at": last_run.created_at} if last_run else None,
|
||||
"created_by": plan.created_by, "created_at": plan.created_at, "updated_at": plan.updated_at,
|
||||
}
|
||||
|
||||
|
||||
async def _apply(db: AsyncSession, plan: RolloutPlan, payload: PlanIn) -> None:
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="name")
|
||||
other = await db.scalar(select(RolloutPlan.id).where(RolloutPlan.name == name))
|
||||
if other is not None and other != plan.id:
|
||||
raise api_error(409, ErrorCode.ROLLOUT_NAME_TAKEN)
|
||||
if payload.window_id is not None and await db.get(MaintenanceWindow, payload.window_id) is None:
|
||||
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="window_id")
|
||||
if any(k not in rollout_health.CHECKS for k in payload.gate_checks):
|
||||
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="gate_checks")
|
||||
wanted = {g for w in payload.waves for g in w.group_ids}
|
||||
groups = {g.id: g for g in (await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(wanted)))).all()}
|
||||
if len(groups) != len(wanted):
|
||||
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="group_ids")
|
||||
plan.name, plan.description, plan.action = name, payload.description.strip(), payload.action
|
||||
plan.window_id, plan.auto_start = payload.window_id, payload.auto_start
|
||||
plan.gate_checks = {k: bool(v) for k, v in payload.gate_checks.items()}
|
||||
plan.compliance_mode = payload.compliance_mode
|
||||
plan.waves = [
|
||||
RolloutPlanWave(position=i, label=w.label.strip() or f"{i + 1}", soak_minutes=w.soak_minutes, carp_handover=w.carp_handover,
|
||||
require_approval=w.require_approval, groups=[groups[g] for g in dict.fromkeys(w.group_ids)])
|
||||
for i, w in enumerate(payload.waves)
|
||||
]
|
||||
# Any edit disarms the automatic start: an operator cannot quietly change what an admin activated.
|
||||
plan.activated_at = plan.activated_by = plan.activated_fingerprint = None
|
||||
plan.auto_start_error = None
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def list_plans(db: AsyncSession = Depends(get_db)):
|
||||
plans = (await db.scalars(select(RolloutPlan).order_by(RolloutPlan.name))).all()
|
||||
return [await read_plan(db, p) for p in plans]
|
||||
|
||||
|
||||
@router.post("/plans", status_code=201)
|
||||
async def create_plan(payload: PlanIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
plan = RolloutPlan(created_by=request.state.user.username, created_at=datetime.now(timezone.utc), waves=[])
|
||||
await _apply(db, plan, payload)
|
||||
db.add(plan)
|
||||
await db.commit()
|
||||
return await read_plan(db, await _plan_or_404(db, plan.id))
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}")
|
||||
async def get_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
return await read_plan(db, await _plan_or_404(db, plan_id))
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}")
|
||||
async def update_plan(plan_id: uuid.UUID, payload: PlanIn, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
plan = await _plan_or_404(db, plan_id)
|
||||
was_active = plan.activated_at is not None
|
||||
await _apply(db, plan, payload)
|
||||
await db.commit()
|
||||
if was_active:
|
||||
await audit_service.persist(kind="rollout", action="deactivate", status="completed", entity_id=str(plan.id),
|
||||
entity_name=plan.name, message="Plan geändert: automatischer Start deaktiviert")
|
||||
return await read_plan(db, await _plan_or_404(db, plan_id))
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}", status_code=204)
|
||||
async def delete_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
plan = await _plan_or_404(db, plan_id)
|
||||
if await db.scalar(select(RolloutRun.id).where(RolloutRun.plan_id == plan.id, RolloutRun.status.in_(ACTIVE_RUN)).limit(1)):
|
||||
raise api_error(409, ErrorCode.ROLLOUT_PLAN_BUSY)
|
||||
await db.delete(plan)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/preview")
|
||||
async def preview_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
plan = await _plan_or_404(db, plan_id)
|
||||
resolved = await rollout_service.resolve(db, plan)
|
||||
return {**resolved, "preview_token": rollout_service.fingerprint(plan, resolved),
|
||||
"can_start": not resolved["errors"], "plan": await read_plan(db, plan, resolved)}
|
||||
|
||||
|
||||
async def _checked_token(db: AsyncSession, plan: RolloutPlan, token: str) -> tuple[dict, str]:
|
||||
resolved = await rollout_service.resolve(db, plan)
|
||||
current = rollout_service.fingerprint(plan, resolved)
|
||||
if token != current:
|
||||
raise api_error(409, ErrorCode.ROLLOUT_PREVIEW_STALE)
|
||||
if resolved["errors"]:
|
||||
raise api_error(422, ErrorCode.ROLLOUT_PLAN_INVALID, reason=resolved["errors"][0]["code"])
|
||||
return resolved, current
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/activate")
|
||||
async def activate_plan(plan_id: uuid.UUID, payload: TokenIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
plan = await _plan_or_404(db, plan_id)
|
||||
if not plan.auto_start:
|
||||
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="auto_start")
|
||||
_resolved, current = await _checked_token(db, plan, payload.preview_token)
|
||||
plan.activated_by, plan.activated_at, plan.activated_fingerprint = request.state.user.username, datetime.now(timezone.utc), current
|
||||
plan.auto_start_error = None
|
||||
await db.commit()
|
||||
await audit_service.persist(kind="rollout", action="activate", status="completed", entity_id=str(plan.id),
|
||||
entity_name=plan.name, details={"fingerprint": current})
|
||||
return await read_plan(db, await _plan_or_404(db, plan_id))
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/deactivate")
|
||||
async def deactivate_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
plan = await _plan_or_404(db, plan_id)
|
||||
plan.activated_by = plan.activated_at = plan.activated_fingerprint = None
|
||||
await db.commit()
|
||||
await audit_service.persist(kind="rollout", action="deactivate", status="completed", entity_id=str(plan.id), entity_name=plan.name)
|
||||
return await read_plan(db, await _plan_or_404(db, plan_id))
|
||||
|
||||
|
||||
# ── runs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class StartIn(TokenIn):
|
||||
confirm_name: str = Field(min_length=1, max_length=120)
|
||||
acknowledge_unattended: Literal[True]
|
||||
|
||||
|
||||
class ReasonIn(BaseModel):
|
||||
reason: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class SkipIn(BaseModel):
|
||||
host_id: uuid.UUID
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
class AbortIn(BaseModel):
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
confirm_name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
def _read_run_summary(run: RolloutRun) -> dict:
|
||||
counts: dict[str, int] = {}
|
||||
for h in run.hosts:
|
||||
counts[h.status] = counts.get(h.status, 0) + 1
|
||||
return {
|
||||
"id": str(run.id), "plan_id": str(run.plan_id) if run.plan_id else None, "plan_name": run.plan_name,
|
||||
"action": run.action, "status": run.status, "paused_from": run.paused_from, "current_wave": run.current_wave,
|
||||
"wave_count": len(run.waves), "current_wave_label": next((w.label for w in run.waves if w.index == run.current_wave), None),
|
||||
"target_version": run.target_version, "pause_reason": run.pause_reason, "pause_detail": run.pause_detail,
|
||||
"started_by": run.started_by, "trigger": run.trigger, "window_occurrence": run.window_occurrence,
|
||||
"created_at": run.created_at, "finished_at": run.finished_at, "step_at": run.step_at, "host_counts": counts,
|
||||
"active": run.status in rollout_engine.ACTIVE,
|
||||
}
|
||||
|
||||
|
||||
async def read_run(db: AsyncSession, run: RolloutRun) -> dict:
|
||||
scheds = {}
|
||||
ids = [h.scheduled_maintenance_id for h in run.hosts if h.scheduled_maintenance_id]
|
||||
if ids:
|
||||
scheds = {s.id: s for s in (await db.scalars(select(ScheduledMaintenance).where(ScheduledMaintenance.id.in_(ids)))).all()}
|
||||
soak_until = next((w.soak_until for w in run.waves if w.index == run.current_wave), None)
|
||||
return {
|
||||
**_read_run_summary(run),
|
||||
"soak_until": soak_until, "progress": {k: v for k, v in (run.progress or {}).items()
|
||||
if k in ("gate_recheck_at", "cluster_recheck_at", "blocked_since", "resume_gate",
|
||||
"cluster_gate", "preflight_started", "resume_recheck_at")},
|
||||
"snapshot": run.plan_snapshot,
|
||||
"waves": [{"index": w.index, "label": w.label, "status": w.status, "gate_result": w.gate_result,
|
||||
"gate_attempts": w.gate_attempts, "soak_until": w.soak_until, "approved_by": w.approved_by,
|
||||
"started_at": w.started_at, "finished_at": w.finished_at,
|
||||
"spec": run.plan_snapshot["waves"][w.index] if w.index < len(run.plan_snapshot.get("waves", [])) else None}
|
||||
for w in run.waves],
|
||||
"hosts": [{"id": str(h.id), "host_id": str(h.host_id) if h.host_id else None, "host_name": h.host_name,
|
||||
"cluster_name": h.cluster_name, "wave_index": h.wave_index, "order": h.order, "status": h.status,
|
||||
"version_before": h.version_before, "version_after": h.version_after, "detail": h.detail,
|
||||
"started_at": h.started_at, "finished_at": h.finished_at,
|
||||
"schedule": ({"id": str(s.id), "status": s.status, "phase": s.phase, "carp_handover": s.carp_handover,
|
||||
"host_operation_id": str(s.host_operation_id) if s.host_operation_id else None}
|
||||
if (s := scheds.get(h.scheduled_maintenance_id)) else None)}
|
||||
for h in sorted(run.hosts, key=lambda h: (h.wave_index, h.order, h.host_name))],
|
||||
}
|
||||
|
||||
|
||||
async def _cancel_if_pending(db: AsyncSession, sched_id) -> bool:
|
||||
"""Cancel a host's schedule only if it has not fired. Locked: the poll loop may be firing it right now
|
||||
(fire_due holds the row), and a blind write would turn a running action into "cancelled"."""
|
||||
if sched_id is None:
|
||||
return False
|
||||
sched = await db.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == sched_id)
|
||||
.with_for_update().execution_options(populate_existing=True))
|
||||
if sched is not None and sched.status == "pending":
|
||||
sched.status = "cancelled"
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _run_or_404(db: AsyncSession, run_id: uuid.UUID, *, lock: bool = False) -> RolloutRun:
|
||||
stmt = select(RolloutRun).where(RolloutRun.id == run_id).execution_options(populate_existing=True)
|
||||
if lock:
|
||||
stmt = stmt.with_for_update()
|
||||
run = await db.scalar(stmt)
|
||||
if run is None:
|
||||
raise api_error(404, ErrorCode.ROLLOUT_RUN_NOT_FOUND)
|
||||
return run
|
||||
|
||||
|
||||
def _not_now(run: RolloutRun):
|
||||
return api_error(409, ErrorCode.ROLLOUT_ACTION_NOT_ALLOWED, status=run.status)
|
||||
|
||||
|
||||
async def _done(db: AsyncSession, run: RolloutRun, action: str, status: str, request: Request, **details) -> dict:
|
||||
run.revision += 1
|
||||
await db.commit()
|
||||
await audit_service.persist(kind="rollout", action=action, status=status, entity_id=str(run.id), entity_name=run.plan_name,
|
||||
details={"wave": run.current_wave, **details})
|
||||
return await read_run(db, await _run_or_404(db, run.id))
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/start", status_code=201)
|
||||
async def start_plan(plan_id: uuid.UUID, payload: StartIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
plan = await _plan_or_404(db, plan_id)
|
||||
if payload.confirm_name != plan.name:
|
||||
raise api_error(409, ErrorCode.ROLLOUT_CONFIRM_NAME)
|
||||
resolved, _token = await _checked_token(db, plan, payload.preview_token)
|
||||
if (other := await rollout_engine.active_run(db)) is not None:
|
||||
# One run at a time: two runs could otherwise touch the same cluster at once.
|
||||
raise api_error(409, ErrorCode.ROLLOUT_ALREADY_ACTIVE, plan=other.plan_name)
|
||||
run = await rollout_engine.create_run(db, plan, resolved, started_by=request.state.user.username, trigger="manual")
|
||||
await db.commit()
|
||||
await audit_service.persist(kind="rollout", action="start", status="started", entity_id=str(run.id), entity_name=plan.name,
|
||||
details={"trigger": "manual", "hosts": sum(len(w["hosts"]) for w in resolved["waves"]),
|
||||
"waves": [w["label"] for w in resolved["waves"]]})
|
||||
return await read_run(db, await _run_or_404(db, run.id))
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def list_runs(db: AsyncSession = Depends(get_db)):
|
||||
runs = (await db.scalars(select(RolloutRun).order_by(RolloutRun.created_at.desc()).limit(50))).all()
|
||||
return [_read_run_summary(r) for r in runs]
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def rollout_status(db: AsyncSession = Depends(get_db)):
|
||||
"""For /updates and the host pages: the active run and the hosts it still owns."""
|
||||
run = await rollout_engine.active_run(db)
|
||||
if run is None:
|
||||
return {"active": None, "host_ids": [], "current_host_ids": []}
|
||||
return {
|
||||
"active": _read_run_summary(run),
|
||||
"host_ids": [str(h.host_id) for h in run.hosts if h.host_id and h.status in ("queued", "blocked", "running", "verifying", "failed")],
|
||||
"current_host_ids": [str(h.host_id) for h in run.hosts if h.host_id and h.wave_index == run.current_wave
|
||||
and h.status in ("queued", "blocked", "running", "verifying", "failed")],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}")
|
||||
async def get_run(run_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
return await read_run(db, await _run_or_404(db, run_id))
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: uuid.UUID, payload: ReasonIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
run = await _run_or_404(db, run_id, lock=True)
|
||||
if run.status not in rollout_engine.ACTIVE or run.status == "paused":
|
||||
raise _not_now(run)
|
||||
# A brake that lets the next host fire anyway is no brake: a step not fired yet goes back to the queue.
|
||||
requeued = [h.host_name for h in run.hosts if await rollout_engine.requeue_if_pending(db, h)]
|
||||
rollout_engine.pause(run, "manual", by=request.state.user.username, text=payload.reason.strip()[:300])
|
||||
return await _done(db, run, "pause", "paused", request, reason="manual", text=payload.reason.strip()[:300], requeued=requeued)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
run = await _run_or_404(db, run_id, lock=True)
|
||||
if run.status != "paused":
|
||||
raise _not_now(run)
|
||||
failed = [h.host_name for h in run.hosts if h.status == "failed"]
|
||||
if failed:
|
||||
# A failed host is never retried by itself: check it, then skip it explicitly.
|
||||
raise api_error(409, ErrorCode.ROLLOUT_HOST_FAILED, hosts=", ".join(failed))
|
||||
previous = run.started_by
|
||||
# Whoever resumes takes over the responsibility (and the permission check) from here on.
|
||||
run.started_by = request.state.user.username
|
||||
run.status = "resuming"
|
||||
run.pause_reason = run.pause_detail = None
|
||||
run.progress = {**(run.progress or {}), "resume_attempts": 0, "resume_recheck_at": None, "paused_at": None}
|
||||
return await _done(db, run, "resume", "resuming", request, previous_owner=previous)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/approve")
|
||||
async def approve_wave(run_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
run = await _run_or_404(db, run_id, lock=True)
|
||||
if run.status != "awaiting_approval":
|
||||
raise _not_now(run)
|
||||
wave = rollout_engine.wave_row(run, run.current_wave)
|
||||
wave.approved_by, wave.status = request.state.user.username, "preflight"
|
||||
run.started_by = request.state.user.username
|
||||
run.status = "preflight"
|
||||
run.progress = {**(run.progress or {}), "preflight_started": None, "preflight_requested_at": None}
|
||||
return await _done(db, run, "approve", "approved", request, wave_label=wave.label)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/skip-host")
|
||||
async def skip_host(run_id: uuid.UUID, payload: SkipIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
run = await _run_or_404(db, run_id, lock=True)
|
||||
if run.status not in rollout_engine.ACTIVE:
|
||||
raise _not_now(run)
|
||||
host = next((h for h in run.hosts if h.host_id == payload.host_id), None)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.ROLLOUT_HOST_NOT_IN_RUN)
|
||||
if host.status in ("running", "verifying"):
|
||||
if not await _cancel_if_pending(db, host.scheduled_maintenance_id):
|
||||
raise api_error(409, ErrorCode.ROLLOUT_HOST_RUNNING, host=host.host_name)
|
||||
# not fired yet: nothing happened on the firewall
|
||||
elif host.status not in ("queued", "blocked", "failed"):
|
||||
raise api_error(409, ErrorCode.ROLLOUT_HOST_RUNNING, host=host.host_name)
|
||||
host.status = "skipped"
|
||||
host.detail = {"code": "skipped_by", "params": {"user": request.state.user.username, "reason": payload.reason.strip()[:300]}}
|
||||
host.finished_at = datetime.now(timezone.utc)
|
||||
return await _done(db, run, "skip_host", "skipped", request, host=host.host_name, reason=payload.reason.strip()[:300])
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/abort")
|
||||
async def abort_run(run_id: uuid.UUID, payload: AbortIn, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
run = await _run_or_404(db, run_id, lock=True)
|
||||
if run.status not in rollout_engine.ACTIVE:
|
||||
raise _not_now(run)
|
||||
if payload.confirm_name != run.plan_name:
|
||||
raise api_error(409, ErrorCode.ROLLOUT_CONFIRM_NAME)
|
||||
now = datetime.now(timezone.utc)
|
||||
still_running = []
|
||||
for host in run.hosts:
|
||||
if host.status in ("running", "verifying") and host.scheduled_maintenance_id:
|
||||
if await _cancel_if_pending(db, host.scheduled_maintenance_id):
|
||||
host.status, host.finished_at = "cancelled", now
|
||||
else:
|
||||
still_running.append(host.host_name) # an action already on the firewall is finished in a controlled way
|
||||
elif host.status in ("queued", "blocked"):
|
||||
host.status, host.finished_at = "cancelled", now
|
||||
for wave in run.waves:
|
||||
if wave.status not in ("passed", "skipped"):
|
||||
wave.status = "cancelled"
|
||||
run.status, run.finished_at = "cancelled", now
|
||||
run.pause_reason, run.pause_detail = "aborted", {"by": request.state.user.username, "reason": payload.reason.strip()[:300],
|
||||
"still_running": still_running}
|
||||
return await _done(db, run, "abort", "cancelled", request, reason=payload.reason.strip()[:300], still_running=still_running)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Rule analysis: which rule decides a packet (read-only, GET so ``read`` tokens and MCP can use it).
|
||||
|
||||
Nothing is stored and nothing is written to a firewall; every request reads the rules, the
|
||||
loaded pf ruleset and the interfaces (cached for 60 s per firewall, ``refresh=true`` skips
|
||||
the cache) and evaluates them with services/rule_analysis/engine.py.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.policy import DeviceGroup
|
||||
from app.services.rule_analysis import service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["rule-analysis"])
|
||||
_PARALLEL = 5
|
||||
|
||||
|
||||
def _query(src: str, dst: str, proto: str, sport: str | None, dport: str | None, icmptype: str | None,
|
||||
iface: str | None, direction: str, egress: str | None):
|
||||
return service.build_query(src=src, dst=dst, proto=proto, sport=sport, dport=dport, icmptype=icmptype,
|
||||
iface=iface, direction=direction, egress=egress)
|
||||
|
||||
|
||||
async def _run(host: OpnsenseHost, q, refresh: bool) -> dict:
|
||||
head = {"host_id": str(host.id), "host_name": host.name, "cluster_name": host.cluster_name}
|
||||
try:
|
||||
return {**head, **await service.analyze_host(host, q, refresh=refresh), "error": None}
|
||||
except HTTPException as exc:
|
||||
return {**head, "error": exc.detail}
|
||||
except Exception as exc: # noqa: BLE001 - one unreachable firewall must not fail the fleet answer
|
||||
logger.info("rule analysis failed for %s: %s", host.name, exc)
|
||||
return {**head, "error": {"code": ErrorCode.ANALYSE_FIREWALL_NICHT_LESBAR.value, "params": {}}}
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/rule-analysis/match")
|
||||
async def host_match(
|
||||
host_id: uuid.UUID, src: str, dst: str, proto: str = "tcp", sport: str | None = None, dport: str | None = None,
|
||||
icmptype: str | None = None, iface: str | None = None, direction: str = "in", egress: str | None = None,
|
||||
refresh: bool = False, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
q = _query(src, dst, proto, sport, dport, icmptype, iface, direction, egress)
|
||||
result = await _run(host, q, refresh)
|
||||
error = result.get("error")
|
||||
if error:
|
||||
status = 422 if error.get("code") == ErrorCode.ANALYSE_INTERFACE_UNBEKANNT.value else 502
|
||||
raise HTTPException(status_code=status, detail=error)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/rule-analysis/match")
|
||||
async def fleet_match(
|
||||
src: str, dst: str, proto: str = "tcp", sport: str | None = None, dport: str | None = None,
|
||||
icmptype: str | None = None, iface: str | None = None, direction: str = "in", egress: str | None = None,
|
||||
host_ids: str | None = Query(None, description="comma separated host ids; default: all enabled hosts"),
|
||||
group_id: uuid.UUID | None = None, refresh: bool = False, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = _query(src, dst, proto, sport, dport, icmptype, iface, direction, egress)
|
||||
if group_id is not None:
|
||||
group = await db.scalar(select(DeviceGroup).options(selectinload(DeviceGroup.hosts)).where(DeviceGroup.id == group_id))
|
||||
if group is None:
|
||||
raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN)
|
||||
hosts = sorted(group.hosts, key=lambda h: h.name)
|
||||
elif host_ids:
|
||||
try:
|
||||
ids = [uuid.UUID(part.strip()) for part in host_ids.split(",") if part.strip()]
|
||||
except ValueError:
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="host_ids", value=host_ids[:64]) from None
|
||||
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(ids)).order_by(OpnsenseHost.name))).all())
|
||||
if len(hosts) != len(set(ids)):
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
else:
|
||||
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
|
||||
gate = asyncio.Semaphore(_PARALLEL)
|
||||
|
||||
async def one(host):
|
||||
async with gate:
|
||||
return await _run(host, q, refresh)
|
||||
|
||||
return list(await asyncio.gather(*(one(h) for h in hosts)))
|
||||
@@ -24,6 +24,8 @@ class Schedule(BaseModel):
|
||||
acknowledge_unattended: Literal[True]
|
||||
# Hand the traffic over to the CARP partner first and switch back afterwards (cluster nodes only).
|
||||
carp_handover: bool = False
|
||||
# Check the result automatically afterwards and close the operation (no manual "Wartung abschließen").
|
||||
auto_verify: bool = False
|
||||
|
||||
|
||||
def _read(sched: ScheduledMaintenance) -> dict:
|
||||
@@ -32,6 +34,8 @@ def _read(sched: ScheduledMaintenance) -> dict:
|
||||
"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,
|
||||
"auto_verify": sched.auto_verify, "origin": sched.origin,
|
||||
"rollout_run_id": ((sched.progress or {}).get("rollout") or {}).get("run_id"),
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +84,7 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
|
||||
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, carp_handover=payload.carp_handover,
|
||||
auto_verify=payload.auto_verify,
|
||||
)
|
||||
db.add(sched)
|
||||
await db.commit()
|
||||
@@ -90,7 +95,9 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
|
||||
async def cancel_scheduled(host_id: uuid.UUID, schedule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
await _host_or_404(db, host_id)
|
||||
sched = await db.get(ScheduledMaintenance, schedule_id)
|
||||
# Locked and re-read: the poll loop may be firing this very row right now (it holds the lock meanwhile).
|
||||
sched = await db.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == schedule_id)
|
||||
.with_for_update().execution_options(populate_existing=True))
|
||||
if not sched or sched.host_id != host_id:
|
||||
raise api_error(404, ErrorCode.GEPLANTE_WARTUNG_NICHT_GEFUNDEN)
|
||||
if sched.status != "pending":
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.services import rollout_engine
|
||||
from app.services import update_check_service, updater_client
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/version-check")
|
||||
async def version_check() -> dict:
|
||||
async def version_check(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
||||
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
||||
return await update_check_service.check()
|
||||
return await update_check_service.check(db)
|
||||
|
||||
|
||||
def _admin(user: AdminUser) -> AdminUser:
|
||||
@@ -31,16 +35,63 @@ def _major(version: str | None) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
class UpdateCheckRead(BaseModel):
|
||||
enabled: bool
|
||||
feed_url: str
|
||||
managed_by_server: bool
|
||||
|
||||
|
||||
class UpdateCheckUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
feed_url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
async def _update_check_settings(db: AsyncSession) -> UpdateCheckRead:
|
||||
row = await update_check_service.get_settings(db)
|
||||
managed = update_check_service.managed_by_server()
|
||||
return UpdateCheckRead(
|
||||
enabled=True if managed else row.enabled,
|
||||
feed_url=settings.update_check_url.strip() if managed else row.feed_url,
|
||||
managed_by_server=managed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/update-check", response_model=UpdateCheckRead)
|
||||
async def read_update_check(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Versionsprüfung für „Einstellungen › OPNexus aktualisieren“ (nur Admins)."""
|
||||
_admin(user)
|
||||
return await _update_check_settings(db)
|
||||
|
||||
|
||||
@router.put("/update-check", response_model=UpdateCheckRead)
|
||||
async def update_update_check(payload: UpdateCheckUpdate, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)):
|
||||
_admin(user)
|
||||
if update_check_service.managed_by_server():
|
||||
raise api_error(409, ErrorCode.UPDATE_CHECK_MANAGED_BY_SERVER)
|
||||
row = await update_check_service.get_settings(db)
|
||||
if payload.feed_url is not None:
|
||||
url = payload.feed_url.strip()
|
||||
if not update_check_service.valid_feed_url(url):
|
||||
raise api_error(422, ErrorCode.UPDATE_CHECK_URL_INVALID)
|
||||
row.feed_url = url
|
||||
if payload.enabled is not None:
|
||||
row.enabled = payload.enabled
|
||||
await db.commit()
|
||||
update_check_service.reset_cache()
|
||||
return await _update_check_settings(db)
|
||||
|
||||
|
||||
class UpdateRequest(BaseModel):
|
||||
version: str = Field(min_length=5, max_length=20)
|
||||
|
||||
|
||||
@router.get("/updater")
|
||||
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
||||
async def updater_status(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
|
||||
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check()
|
||||
check = await update_check_service.check(db)
|
||||
result: dict = {
|
||||
"available": False, "reason": None, "mode": None,
|
||||
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
|
||||
@@ -49,11 +100,17 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
||||
"can_update": False, "major_change": False,
|
||||
"job": None, "phases": [], "log": [], "services": {},
|
||||
"rollback_available": False, "rollback_loses_data": False, "rollback_version": None,
|
||||
# A restart in the middle of a firmware rollout is safe (its state is in the database), but the
|
||||
# UI warns: the run pauses its 15-second steps for as long as OPNexus is down.
|
||||
"active_rollouts": await rollout_engine.count_active(db),
|
||||
}
|
||||
try:
|
||||
updater = await updater_client.status()
|
||||
except updater_client.UpdaterUnavailable as exc:
|
||||
result["reason"] = str(exc)
|
||||
# Without the updater the install mode is still known from the image registry setting; the UI uses it
|
||||
# to not offer enabling the updater on an install that builds from source (it could not update anyway).
|
||||
result["mode"] = "release" if settings.opnexus_image_repo.strip() else "local"
|
||||
return result
|
||||
except updater_client.UpdaterRejected:
|
||||
result["reason"] = "unreachable"
|
||||
@@ -77,11 +134,12 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
||||
|
||||
|
||||
@router.post("/updater/update", status_code=202)
|
||||
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
|
||||
async def updater_update(payload: UpdateRequest, db: AsyncSession = Depends(get_db),
|
||||
user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
|
||||
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check()
|
||||
check = await update_check_service.check(db)
|
||||
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
|
||||
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
||||
if _major(check["latest"]) != _major(check["current"]):
|
||||
|
||||
@@ -81,9 +81,9 @@ async def replace(host_id: uuid.UUID, payload: VariablesUpdate, db: AsyncSession
|
||||
"""Replace this host's variables with exactly the given set."""
|
||||
host = await _host(db, host_id)
|
||||
for name, value in payload.variables.items():
|
||||
if not variables_service.NAME_RE.match(name):
|
||||
if not variables_service.NAME_RE.fullmatch(name):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Name „{name}“: Großbuchstaben, Ziffern und _, beginnend mit einem Buchstaben")
|
||||
if not variables_service.VALUE_RE.match(value):
|
||||
if not variables_service.VALUE_RE.fullmatch(value):
|
||||
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Wert von {name}: 1–255 Zeichen aus Buchstaben, Ziffern und . : / , @ + - _ Leerzeichen")
|
||||
existing = {v.name: v for v in host.variables}
|
||||
for name in set(existing) - set(payload.variables):
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdoptObjectRequest(BaseModel):
|
||||
host_id: uuid.UUID
|
||||
uuid: str = Field(min_length=1, max_length=64) # the object's UUID on the firewall
|
||||
|
||||
|
||||
class AdoptAllRequest(BaseModel):
|
||||
host_id: uuid.UUID
|
||||
|
||||
|
||||
class UnmanagedObject(BaseModel):
|
||||
"""An alias / NAT rule found on a host that OPNexus does not manage yet (see object_adopt_service.py)."""
|
||||
|
||||
uuid: str
|
||||
title: str
|
||||
enabled: bool
|
||||
adoptable: bool # can be adopted on its own right now
|
||||
# Adoptable in the same "adopt all" run although not on its own (an alias whose referenced aliases come first).
|
||||
bulk_adoptable: bool
|
||||
# Codes such as "unsupported_type" or "no_target"; the UI turns them into text.
|
||||
reasons: list[str]
|
||||
reason_details: dict[str, str] = Field(default_factory=dict) # code -> extra text (alias names, type, ...)
|
||||
# Settings OPNexus does not manage that stay untouched on the firewall (log, pool options, tags, ...).
|
||||
kept: list[str] = Field(default_factory=list)
|
||||
# Facts for display, depend on the object type.
|
||||
info: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AdoptedObject(BaseModel):
|
||||
uuid: str
|
||||
title: str
|
||||
id: uuid.UUID
|
||||
|
||||
|
||||
class SkippedObject(BaseModel):
|
||||
uuid: str
|
||||
title: str
|
||||
reasons: list[str]
|
||||
reason_details: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AdoptAllResult(BaseModel):
|
||||
adopted: list[AdoptedObject]
|
||||
skipped: list[SkippedObject]
|
||||
@@ -3,6 +3,8 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.gateway import GatewayHealth
|
||||
|
||||
|
||||
class DashboardHost(BaseModel):
|
||||
id: uuid.UUID
|
||||
@@ -10,6 +12,8 @@ class DashboardHost(BaseModel):
|
||||
status: str
|
||||
carp_status: str | None
|
||||
cluster_name: str | None
|
||||
# From the last resource sample (no live call); None when there is no sample from the last 3 minutes.
|
||||
gateways: list[GatewayHealth] | None = None
|
||||
|
||||
|
||||
class PendingAction(BaseModel):
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
# online: all fine · degraded: OPNsense flags loss and/or latency above the gateway's own limits ·
|
||||
# offline: down · forced_down: switched off by the admin on purpose · unknown: nothing measured
|
||||
# (monitoring disabled) or the verdict is still pending.
|
||||
GatewayState = Literal["online", "degraded", "offline", "forced_down", "unknown"]
|
||||
|
||||
|
||||
class GatewayHealth(BaseModel):
|
||||
name: str
|
||||
address: str | None = None
|
||||
status: str # OPNsense' raw verdict: none | down | force_down | delay | loss | delay+loss | …
|
||||
state: GatewayState
|
||||
loss: float | None = None # percent
|
||||
delay: float | None = None # milliseconds
|
||||
stddev: float | None = None # milliseconds
|
||||
monitor: str | None = None # the monitored address
|
||||
@@ -1,10 +1,12 @@
|
||||
import re
|
||||
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.config import settings
|
||||
from app.models.notification import DEFAULT_SERVICE_WATCHLIST
|
||||
|
||||
SmtpSecurity = Literal["none", "starttls", "ssl"]
|
||||
ProblemType = Literal[
|
||||
@@ -14,7 +16,10 @@ ProblemType = Literal[
|
||||
"backup_stale",
|
||||
"backup_offsite_failed",
|
||||
"compliance_finding",
|
||||
"gateway_down", "resource_threshold", "service_down",
|
||||
"rollout_paused", "rollout_start_failed",
|
||||
]
|
||||
ChannelType = Literal["ntfy", "gotify", "telegram", "teams", "matrix"]
|
||||
|
||||
|
||||
class NotificationSettingsRead(BaseModel):
|
||||
@@ -45,10 +50,28 @@ class NotificationSettingsRead(BaseModel):
|
||||
scheduled_maintenance_failed_enabled: bool = True
|
||||
backup_failed_enabled: bool = True
|
||||
compliance_alerts_enabled: bool = False
|
||||
gateway_down_enabled: bool = True
|
||||
resource_threshold_enabled: bool = False
|
||||
service_down_enabled: bool = False
|
||||
rollout_problems_enabled: bool = True
|
||||
gateway_loss_threshold_pct: int = 20
|
||||
gateway_delay_threshold_ms: int = 500
|
||||
disk_threshold_pct: int = 90
|
||||
memory_threshold_pct: int = 95
|
||||
swap_threshold_pct: int = 50
|
||||
load_per_cpu_threshold: float = 2.0
|
||||
alert_confirm_samples: int = 3
|
||||
resource_confirm_samples: int = 5
|
||||
service_watchlist: list[str] = Field(default_factory=lambda: list(DEFAULT_SERVICE_WATCHLIST))
|
||||
|
||||
poll_interval_seconds: int
|
||||
updated_at: datetime
|
||||
|
||||
@field_validator("service_watchlist", mode="before")
|
||||
@classmethod
|
||||
def _default_watchlist(cls, value):
|
||||
return list(DEFAULT_SERVICE_WATCHLIST) if value is None else value
|
||||
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
"""Partial update — everything optional, only provided fields change.
|
||||
@@ -84,6 +107,34 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
scheduled_maintenance_failed_enabled: bool | None = None
|
||||
backup_failed_enabled: bool | None = None
|
||||
compliance_alerts_enabled: bool | None = None
|
||||
gateway_down_enabled: bool | None = None
|
||||
resource_threshold_enabled: bool | None = None
|
||||
service_down_enabled: bool | None = None
|
||||
rollout_problems_enabled: bool | None = None
|
||||
# 0 switches that single threshold off.
|
||||
gateway_loss_threshold_pct: int | None = Field(default=None, ge=0, le=100)
|
||||
gateway_delay_threshold_ms: int | None = Field(default=None, ge=0, le=60000)
|
||||
disk_threshold_pct: int | None = Field(default=None, ge=0, le=100)
|
||||
memory_threshold_pct: int | None = Field(default=None, ge=0, le=100)
|
||||
swap_threshold_pct: int | None = Field(default=None, ge=0, le=100)
|
||||
load_per_cpu_threshold: float | None = Field(default=None, ge=0, le=1000)
|
||||
alert_confirm_samples: int | None = Field(default=None, ge=1, le=60)
|
||||
resource_confirm_samples: int | None = Field(default=None, ge=1, le=60)
|
||||
service_watchlist: list[str] | None = Field(default=None, max_length=100)
|
||||
|
||||
@field_validator("service_watchlist")
|
||||
@classmethod
|
||||
def _clean_watchlist(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
names = []
|
||||
for raw in value:
|
||||
name = str(raw).strip()
|
||||
if not re.fullmatch(r"[A-Za-z0-9._+-]{1,64}", name):
|
||||
raise ValueError("Dienstnamen bestehen aus Buchstaben, Ziffern und . _ + -")
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
poll_interval_seconds: int | None = Field(
|
||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||
@@ -106,3 +157,55 @@ class NotificationProblemRead(BaseModel):
|
||||
|
||||
class TestNotificationRequest(BaseModel):
|
||||
channel: Literal["webhook", "email"]
|
||||
|
||||
|
||||
class NotificationChannelRead(BaseModel):
|
||||
"""Never carries the credential; ``config`` is withheld from non-admins (an ntfy topic is a password)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
channel_type: ChannelType
|
||||
enabled: bool
|
||||
config: dict
|
||||
secret_set: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
def _channel_name(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not value.strip():
|
||||
raise ValueError("Name darf nicht leer sein")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class NotificationChannelCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
channel_type: ChannelType
|
||||
enabled: bool = True
|
||||
config: dict = Field(default_factory=dict)
|
||||
# Token, or for Teams the webhook address. Write-only.
|
||||
secret: str | None = Field(default=None, max_length=2100)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _clean_name(cls, value):
|
||||
return _channel_name(value)
|
||||
|
||||
|
||||
class NotificationChannelUpdate(BaseModel):
|
||||
"""Partial: omitted fields stay. ``secret`` replaces the credential, ``clear_secret`` removes it."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
enabled: bool | None = None
|
||||
config: dict | None = None
|
||||
secret: str | None = Field(default=None, max_length=2100)
|
||||
clear_secret: bool = False
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _clean_name(cls, value):
|
||||
return _channel_name(value)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Small pieces shared by every "take over what was made by hand on a firewall" feature
|
||||
(rule_adopt_service.py for filter rules, object_adopt_service.py for aliases and NAT)."""
|
||||
|
||||
|
||||
class NotAdoptable(Exception):
|
||||
"""The object exists on the host but cannot be described without distortion.
|
||||
|
||||
`reasons` are machine-readable codes (the UI translates them); `details` maps a code to
|
||||
extra text the translation can show (e.g. the alias names a reference points to)."""
|
||||
|
||||
def __init__(self, reasons: list[str], details: dict[str, str] | None = None):
|
||||
super().__init__(", ".join(reasons))
|
||||
self.reasons = reasons
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class NotFoundOnHost(Exception):
|
||||
"""The UUID is not (or no longer) present in the host's live configuration."""
|
||||
|
||||
|
||||
def on(value) -> bool:
|
||||
"""OPNsense booleans arrive as "1"/"0" strings, sometimes as real booleans."""
|
||||
return value in ("1", 1, True)
|
||||
|
||||
|
||||
def clean(value) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
@@ -82,14 +82,36 @@ def operator_denies(method: str, path: str) -> bool:
|
||||
|
||||
|
||||
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
|
||||
# maintenance (reboot/update/CARP switch), scheduling it, and applying network changes.
|
||||
_ROLLOUT = re.compile(
|
||||
r"/(deploy|deployments/[^/]+/(remove|retry-rollback)|maintenance/(execute|carp-execute|schedule)|network/changes)$"
|
||||
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
|
||||
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
|
||||
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
|
||||
_ROLLOUT_POST = re.compile(
|
||||
r"/(deploy|push|sign|poll|register|reassert-ordering"
|
||||
r"|deployments/[^/]+/(remove|retry-rollback)"
|
||||
r"|maintenance/(execute|carp-execute|schedule)"
|
||||
r"|network/changes"
|
||||
r"|services/[^/]+/(start|stop|restart)"
|
||||
# Firmware rollouts (1.25): starting/arming a plan and moving a run forward. Pausing is deliberately absent.
|
||||
r"|rollouts/plans/[^/]+/(start|activate)"
|
||||
r"|rollouts/runs/[^/]+/(resume|approve|skip-host|abort))$"
|
||||
)
|
||||
_ROLLOUT_DELETE = re.compile(
|
||||
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
|
||||
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
|
||||
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
|
||||
)
|
||||
|
||||
|
||||
# Recurring maintenance windows decide WHEN unattended firmware actions run (1.25): every write counts.
|
||||
_ROLLOUT_ANY_WRITE = re.compile(r"^/api/maintenance-windows(/|$)")
|
||||
|
||||
|
||||
def is_rollout(method: str, path: str) -> bool:
|
||||
return method == "POST" and bool(_ROLLOUT.search(path))
|
||||
if method in ("POST", "PUT", "PATCH", "DELETE") and _ROLLOUT_ANY_WRITE.match(path):
|
||||
return True
|
||||
if method == "POST":
|
||||
return bool(_ROLLOUT_POST.search(path))
|
||||
return method == "DELETE" and bool(_ROLLOUT_DELETE.match(path))
|
||||
|
||||
|
||||
def bearer_from_header(header: str) -> str | None:
|
||||
|
||||
@@ -236,6 +236,7 @@ async def create_session(
|
||||
user_agent: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
verified_hash: str | None = None,
|
||||
lifetime_seconds: int | None = None,
|
||||
) -> tuple[str, datetime]:
|
||||
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
|
||||
|
||||
@@ -253,7 +254,7 @@ async def create_session(
|
||||
await db.rollback()
|
||||
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
||||
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
|
||||
expires_at = _now() + timedelta(seconds=settings.session_lifetime)
|
||||
expires_at = _now() + timedelta(seconds=lifetime_seconds or settings.session_lifetime)
|
||||
session = Session(
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(token),
|
||||
|
||||
@@ -7,6 +7,8 @@ but its value never leaves the server. Output is a unified diff, capped in size.
|
||||
"""
|
||||
import difflib
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
from xml.etree.ElementTree import Element, indent, tostring
|
||||
|
||||
@@ -14,7 +16,7 @@ import defusedxml.ElementTree as safe_ET
|
||||
|
||||
MAX_LINES = 3000
|
||||
MAX_BYTES = 32 * 1024 * 1024
|
||||
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
|
||||
_SECRET = re.compile(r"pass|pwd|psk|secret|key|token|prv|hash|community|bindpw|privpass|otp|seed|cred", re.I)
|
||||
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
|
||||
_BLOB = 120
|
||||
|
||||
@@ -23,15 +25,17 @@ class InvalidConfig(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _redact(root: Element) -> None:
|
||||
def _redact(root: Element, salt: bytes) -> None:
|
||||
for el in root.iter():
|
||||
if len(el) == 0 and el.text and el.text.strip():
|
||||
text = el.text.strip()
|
||||
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
|
||||
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
|
||||
# Salted per diff: both sides of one diff share the salt (so a change shows), but the value cannot be
|
||||
# looked up in a dictionary or compared across hosts/requests.
|
||||
el.text = "‹" + hmac.new(salt, text.encode(), hashlib.sha256).hexdigest()[:12] + "›"
|
||||
|
||||
|
||||
def normalise(xml_bytes: bytes) -> list[str]:
|
||||
def normalise(xml_bytes: bytes, salt: bytes) -> list[str]:
|
||||
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
|
||||
raise InvalidConfig("leer oder zu groß")
|
||||
try:
|
||||
@@ -42,13 +46,14 @@ def normalise(xml_bytes: bytes) -> list[str]:
|
||||
raise InvalidConfig("keine OPNsense-Konfiguration")
|
||||
for volatile in root.findall("revision"):
|
||||
root.remove(volatile)
|
||||
_redact(root)
|
||||
_redact(root, salt)
|
||||
indent(root, space=" ")
|
||||
return tostring(root, encoding="unicode").splitlines()
|
||||
|
||||
|
||||
def diff(old: bytes, new: bytes) -> dict:
|
||||
a, b = normalise(old), normalise(new)
|
||||
salt = os.urandom(16)
|
||||
a, b = normalise(old, salt), normalise(new, salt)
|
||||
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
|
||||
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||||
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||||
|
||||
@@ -12,6 +12,7 @@ in ``filter/rule`` (legacy). Both are normalised to the same shape below.
|
||||
decided. The score starts at 100 and loses a penalty per failed check, so one
|
||||
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from xml.etree.ElementTree import Element
|
||||
@@ -276,15 +277,18 @@ async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
|
||||
await rule_hit_service.sample_host(db, host)
|
||||
except OpnsenseAPIError:
|
||||
pass # usage data is a bonus; the check does not depend on it
|
||||
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id))
|
||||
# Untrusted XML, O(n^2) rule analysis: keep it off the event loop, and treat ANY parse failure as 'invalid'.
|
||||
findings = await asyncio.to_thread(
|
||||
evaluate, xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id),
|
||||
)
|
||||
row.findings, row.score, row.error = findings, score(findings), None
|
||||
except OpnsenseAPIError as exc:
|
||||
row.findings, row.score, row.error = [], None, "unreachable"
|
||||
row.error_detail = str(exc)[:300]
|
||||
except ValueError as exc:
|
||||
except Exception as exc: # ParseError is a SyntaxError, not a ValueError; one bad config must not stop the other hosts
|
||||
row.findings, row.score, row.error = [], None, "invalid_config"
|
||||
row.error_detail = str(exc)[:300]
|
||||
row.error_detail = f"{type(exc).__name__}: {exc}"[:300]
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Gateway health of a firewall (dpinger numbers, as OPNsense reports them).
|
||||
|
||||
``get_gateway_health(host)`` is the one reusable entry point: a live read of
|
||||
/routes/gateway/status, one :class:`GatewayHealth` per gateway. The notification
|
||||
engine, the host page and the metrics read the SAME numbers from the periodic
|
||||
resource sample (``compact`` / ``from_compact``), so a scrape or a page view never
|
||||
fans out to the firewalls; the 1.25 health gate can call ``get_gateway_health``
|
||||
directly. Parsing is lenient on purpose: "~" means "nothing measured", never zero.
|
||||
"""
|
||||
import re
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.schemas.gateway import GatewayHealth, GatewayState
|
||||
from app.services import opnsense_client as client
|
||||
|
||||
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
|
||||
_DEGRADED = {"delay", "loss", "delay+loss"}
|
||||
|
||||
|
||||
def _number(value) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
match = _NUMBER.search(str(value))
|
||||
return float(match.group()) if match else None
|
||||
|
||||
|
||||
def _state(status: str, measured: bool) -> GatewayState:
|
||||
if status == "down":
|
||||
return "offline"
|
||||
if status == "force_down":
|
||||
return "forced_down"
|
||||
if status in _DEGRADED:
|
||||
return "degraded"
|
||||
if status == "none":
|
||||
# "none" is also what a gateway without monitoring reports; only numbers make it "online".
|
||||
return "online" if measured else "unknown"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def parse_gateway_status(payload) -> list[GatewayHealth]:
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
result: list[GatewayHealth] = []
|
||||
for item in items or []:
|
||||
if not isinstance(item, dict) or not item.get("name"):
|
||||
continue
|
||||
status = str(item.get("status") or "").lower()
|
||||
loss, delay, stddev = _number(item.get("loss")), _number(item.get("delay")), _number(item.get("stddev"))
|
||||
if loss is not None and loss >= 100:
|
||||
delay = stddev = None # dpinger prints 0.0 ms when nothing answers: no round trip, not a fast one
|
||||
measured = loss is not None or delay is not None
|
||||
address = item.get("address")
|
||||
monitor = item.get("monitor")
|
||||
result.append(GatewayHealth(
|
||||
name=str(item["name"]),
|
||||
address=None if address in (None, "", "~") else str(address),
|
||||
status=status or "unknown",
|
||||
state=_state(status, measured),
|
||||
loss=loss, delay=delay, stddev=stddev,
|
||||
monitor=None if monitor in (None, "", "~") else str(monitor),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
async def get_gateway_health(host: OpnsenseHost) -> list[GatewayHealth]:
|
||||
"""Live gateway state of ``host``. Raises OpnsenseAPIError if the firewall cannot be asked."""
|
||||
return parse_gateway_status(await client.get_gateway_status(host))
|
||||
|
||||
|
||||
def compact(gateways: list[GatewayHealth]) -> list[dict]:
|
||||
"""What the resource sampler stores per sample (small, no addresses)."""
|
||||
return [
|
||||
{"name": g.name, "status": g.status, "state": g.state, "loss": g.loss, "delay": g.delay, "stddev": g.stddev}
|
||||
for g in gateways
|
||||
]
|
||||
|
||||
|
||||
def from_compact(rows) -> list[GatewayHealth]:
|
||||
return [
|
||||
GatewayHealth(
|
||||
name=r["name"], status=r.get("status") or "unknown", state=r.get("state") or "unknown",
|
||||
loss=r.get("loss"), delay=r.get("delay"), stddev=r.get("stddev"),
|
||||
)
|
||||
for r in rows or [] if isinstance(r, dict) and r.get("name")
|
||||
]
|
||||
|
||||
|
||||
def is_bad(gateway: GatewayHealth, loss_threshold_pct: float = 0, delay_threshold_ms: float = 0) -> bool:
|
||||
"""Alarm-worthy: down, or (when the thresholds are on) measured loss / latency beyond them.
|
||||
A gateway forced down by the admin and one that is not measured are not alarms."""
|
||||
if gateway.state == "offline":
|
||||
return True
|
||||
if loss_threshold_pct and gateway.loss is not None and gateway.loss >= loss_threshold_pct:
|
||||
return True
|
||||
if delay_threshold_ms and gateway.delay is not None and gateway.delay >= delay_threshold_ms:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -32,6 +32,14 @@ def _extract_carp_status(data: dict | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _vip_states(data: dict | None) -> dict[str, str]:
|
||||
"""Per-VHID CARP state ("MASTER", "BACKUP", "DISABLED", "INIT", ...). The host-level carp_status above
|
||||
collapses these, so one VHID that is down on a node is invisible there."""
|
||||
rows = data.get("rows") if isinstance(data, dict) else None
|
||||
return {str(row.get("vhid")): str(row.get("status", "")).upper()
|
||||
for row in rows or [] if isinstance(row, dict) and row.get("vhid") not in (None, "")}
|
||||
|
||||
|
||||
async def _status_for(host: OpnsenseHost) -> dict:
|
||||
if not host.enabled:
|
||||
return {"status": "disabled", "carp_status": None, "status_detail": None}
|
||||
@@ -43,6 +51,7 @@ async def _status_for(host: OpnsenseHost) -> dict:
|
||||
return {
|
||||
"status": "online",
|
||||
"carp_status": _extract_carp_status(data),
|
||||
"carp_vips": _vip_states(data),
|
||||
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
|
||||
"carp_allowed": _truthy(carp.get("allow"), default=True),
|
||||
"carp_demotion": carp.get("demotion"),
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
* host reachability, resources, gateway health and service state — 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,
|
||||
@@ -19,11 +19,11 @@ 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.notification import DEFAULT_SERVICE_WATCHLIST, 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.services import compliance_service, gateway_health_service, notification_settings_service
|
||||
from app.version import APP_VERSION
|
||||
|
||||
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
|
||||
@@ -99,6 +99,14 @@ async def render(db: AsyncSession) -> str:
|
||||
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_swap_used_percent", "Swap in use, percent (last sample; absent without swap).")
|
||||
out.family("opnexus_host_gateway_up", "1 if the gateway is not down (online, degraded or not monitored), 0 if OPNsense reports it down.")
|
||||
out.family("opnexus_host_gateway_loss_percent", "Packet loss to the gateway monitor address, percent (last sample).")
|
||||
out.family("opnexus_host_gateway_delay_seconds", "Round-trip time to the gateway monitor address (last sample).")
|
||||
out.family("opnexus_host_gateway_stddev_seconds", "Round-trip time deviation to the gateway monitor address (last sample).")
|
||||
out.family("opnexus_host_service_running", "1 if a watched service (Settings > Notifications) runs, 0 if it is stopped (last sample).")
|
||||
configured = (await notification_settings_service.get_settings(db)).service_watchlist
|
||||
watchlist = set(DEFAULT_SERVICE_WATCHLIST if configured is None else configured)
|
||||
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 ""}
|
||||
@@ -115,6 +123,22 @@ async def render(db: AsyncSession) -> str:
|
||||
):
|
||||
if value is not None:
|
||||
out.sample(name, value, **labels)
|
||||
if sample is not None:
|
||||
raw = sample.raw or {}
|
||||
if raw.get("swap_used_pct") is not None:
|
||||
out.sample("opnexus_host_swap_used_percent", raw["swap_used_pct"], **labels)
|
||||
for gw in gateway_health_service.from_compact(raw.get("gateways")):
|
||||
gw_labels = {**labels, "gateway": gw.name}
|
||||
out.sample("opnexus_host_gateway_up", 0 if gw.state == "offline" else 1, **gw_labels)
|
||||
for name, value, factor in (
|
||||
("opnexus_host_gateway_loss_percent", gw.loss, 1), ("opnexus_host_gateway_delay_seconds", gw.delay, 0.001),
|
||||
("opnexus_host_gateway_stddev_seconds", gw.stddev, 0.001),
|
||||
):
|
||||
if value is not None:
|
||||
out.sample(name, round(value * factor, 6), **gw_labels)
|
||||
for key, running in sorted((raw.get("services") or {}).items()):
|
||||
if key.split("/")[0] in watchlist:
|
||||
out.sample("opnexus_host_service_running", running, **labels, service=key.split("/")[0], instance=key.split("/", 1)[1] if "/" in key else None)
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Monitoring alerts (1.24): gateway down, resource thresholds, stopped services.
|
||||
|
||||
Same contract as notification_checks.py (each check returns {dedup_key: context} of what is
|
||||
bad right now), with one addition: they never judge a single reading. The inputs are the
|
||||
60-second samples the resource sampler stores (resource_monitor_service, HostResourceSample),
|
||||
and a problem needs the last N samples to agree before it opens AND before it closes again:
|
||||
|
||||
* all N bad -> problem,
|
||||
* all N good -> no problem,
|
||||
* anything in between -> keep the current state (open stays open, closed stays closed).
|
||||
|
||||
So a single spike never alerts, a flapping link does not open and close the alert every few
|
||||
minutes, and a sample that could not be read ("unknown") neither raises nor clears anything.
|
||||
A host without a fresh sample is not judged at all (check_host_offline reports it); its open
|
||||
problems are kept.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.notification import DEFAULT_SERVICE_WATCHLIST, NotificationProblem
|
||||
from app.models.resource_sample import HostResourceSample
|
||||
from app.services import gateway_health_service, notification_settings_service
|
||||
from app.services.notification_dedup import Problems
|
||||
|
||||
# The sampler runs every 60 s; three missed rounds mean the host is not reachable (same limit as metrics_service).
|
||||
STALE_AFTER = timedelta(seconds=180)
|
||||
# How far back a service must have been seen running before its absence is reported (samples, ~2 h at 60 s).
|
||||
SERVICE_EVIDENCE_SAMPLES = 120
|
||||
_DISK_TYPES = {"ufs", "zfs"}
|
||||
|
||||
|
||||
def verdict(flags: list[bool | None], confirm: int, is_open: bool) -> bool:
|
||||
"""flags: newest first; True = bad, False = good, None = unknown."""
|
||||
window = flags[:confirm]
|
||||
if len(window) >= confirm:
|
||||
if all(f is True for f in window):
|
||||
return True
|
||||
if all(f is False for f in window):
|
||||
return False
|
||||
return is_open
|
||||
|
||||
|
||||
async def _enabled_hosts(db: AsyncSession) -> list[OpnsenseHost]:
|
||||
return list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)))).all())
|
||||
|
||||
|
||||
async def _open_keys(db: AsyncSession, problem_type: str) -> set[str]:
|
||||
rows = await db.scalars(
|
||||
select(NotificationProblem.dedup_key).where(
|
||||
NotificationProblem.problem_type == problem_type, NotificationProblem.status == "open"
|
||||
)
|
||||
)
|
||||
return set(rows.all())
|
||||
|
||||
|
||||
async def _recent(db: AsyncSession, host_id, limit: int, *raw_keys: str) -> list[dict]:
|
||||
"""The newest ``limit`` samples of a host, newest first, with just the JSON keys asked for."""
|
||||
columns = [HostResourceSample.sampled_at, HostResourceSample.mem_used_pct, HostResourceSample.load_avg_1m]
|
||||
columns += [HostResourceSample.raw[key].as_json() for key in raw_keys]
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(*columns).where(HostResourceSample.host_id == host_id).order_by(HostResourceSample.sampled_at.desc()).limit(limit)
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
{"sampled_at": r[0], "mem_used_pct": r[1], "load_avg_1m": r[2], **{k: r[3 + i] for i, k in enumerate(raw_keys)}}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _fresh(samples: list[dict]) -> bool:
|
||||
return bool(samples) and datetime.now(timezone.utc) - samples[0]["sampled_at"] <= STALE_AFTER
|
||||
|
||||
|
||||
def _keys_of_host(open_keys: set[str], host: OpnsenseHost) -> set[str]:
|
||||
prefix = f"{host.id}:"
|
||||
return {k[len(prefix):] for k in open_keys if k.startswith(prefix)}
|
||||
|
||||
|
||||
# ── Gateways ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _gateway_reason(g) -> str:
|
||||
if g.state == "offline":
|
||||
return "Gateway offline"
|
||||
parts = []
|
||||
if g.loss is not None:
|
||||
parts.append(f"Paketverlust {g.loss:g} %")
|
||||
if g.delay is not None:
|
||||
parts.append(f"Latenz {g.delay:g} ms")
|
||||
return ", ".join(parts) or g.status
|
||||
|
||||
|
||||
async def check_gateway_down(db: AsyncSession) -> dict[str, dict]:
|
||||
cfg = await notification_settings_service.get_settings(db)
|
||||
confirm = max(1, cfg.alert_confirm_samples)
|
||||
loss_limit, delay_limit = cfg.gateway_loss_threshold_pct, cfg.gateway_delay_threshold_ms
|
||||
open_keys = await _open_keys(db, "gateway_down")
|
||||
problems = Problems()
|
||||
for host in await _enabled_hosts(db):
|
||||
samples = await _recent(db, host.id, confirm, "gateways")
|
||||
if not _fresh(samples):
|
||||
problems.preserve_prefixes.add(f"{host.id}:")
|
||||
continue
|
||||
parsed = [
|
||||
None if s["gateways"] is None else {g.name: g for g in gateway_health_service.from_compact(s["gateways"])}
|
||||
for s in samples
|
||||
]
|
||||
names = {name for p in parsed if p for name in p} | _keys_of_host(open_keys, host)
|
||||
for name in sorted(names):
|
||||
flags: list[bool | None] = []
|
||||
for p in parsed:
|
||||
if p is None:
|
||||
flags.append(None)
|
||||
else:
|
||||
g = p.get(name) # a gateway that no longer exists is "fine": its problem resolves
|
||||
flags.append(False if g is None else gateway_health_service.is_bad(g, loss_limit, delay_limit))
|
||||
key = f"{host.id}:{name}"
|
||||
if not verdict(flags, confirm, key in open_keys):
|
||||
continue
|
||||
latest = next((p[name] for p in parsed if p and name in p), None)
|
||||
problems[key] = {
|
||||
"host_id": str(host.id), "host_name": host.name, "gateway": name,
|
||||
"grund": _gateway_reason(latest) if latest else "keine aktuellen Messwerte",
|
||||
"status": latest.status if latest else None,
|
||||
"paketverlust_prozent": latest.loss if latest else None,
|
||||
"latenz_ms": latest.delay if latest else None,
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
# ── Resources ───────────────────────────────────────────────────────────────
|
||||
|
||||
def disk_groups(devices) -> dict[str, tuple[str, float]]:
|
||||
"""{group: (label, used %)} — one entry per ZFS pool / UFS device, the fullest dataset of a pool wins."""
|
||||
groups: dict[str, tuple[str, float]] = {}
|
||||
for dev in devices or []:
|
||||
if not isinstance(dev, dict) or dev.get("type") not in _DISK_TYPES:
|
||||
continue
|
||||
try:
|
||||
pct = float(dev["used_pct"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
device = str(dev.get("device") or dev.get("mountpoint") or "?")
|
||||
group = device.split("/")[0] if dev["type"] == "zfs" else device
|
||||
label = group if dev["type"] == "zfs" else str(dev.get("mountpoint") or device)
|
||||
if group not in groups or pct > groups[group][1]:
|
||||
groups[group] = (label, pct)
|
||||
return groups
|
||||
|
||||
|
||||
async def check_resource_threshold(db: AsyncSession) -> dict[str, dict]:
|
||||
cfg = await notification_settings_service.get_settings(db)
|
||||
confirm = max(1, cfg.resource_confirm_samples)
|
||||
open_keys = await _open_keys(db, "resource_threshold")
|
||||
problems = Problems()
|
||||
|
||||
def over(value, limit) -> bool | None:
|
||||
if not limit or value is None:
|
||||
return None if value is None else False
|
||||
return value >= limit
|
||||
|
||||
for host in await _enabled_hosts(db):
|
||||
samples = await _recent(db, host.id, confirm, "disk", "swap_used_pct", "cpu_threads")
|
||||
if not _fresh(samples):
|
||||
problems.preserve_prefixes.add(f"{host.id}:")
|
||||
continue
|
||||
base = {"host_id": str(host.id), "host_name": host.name}
|
||||
was_open = _keys_of_host(open_keys, host)
|
||||
|
||||
# Disk: one problem per pool/device.
|
||||
disk = [None if s["disk"] is None else disk_groups(s["disk"]) for s in samples]
|
||||
group_names = {g for d in disk if d for g in d} | {k[5:] for k in was_open if k.startswith("disk:")}
|
||||
for group in sorted(group_names):
|
||||
flags = [None if d is None else (over(d[group][1], cfg.disk_threshold_pct) if group in d else False) for d in disk]
|
||||
if verdict(flags, confirm, f"disk:{group}" in was_open):
|
||||
label, pct = next((d[group] for d in disk if d and group in d), (group, None))
|
||||
problems[f"{host.id}:disk:{group}"] = {
|
||||
**base, "ressource": f"Festplatte {label}", "wert_prozent": pct, "schwelle_prozent": cfg.disk_threshold_pct,
|
||||
}
|
||||
|
||||
def simple(key: str, label: str, flags: list, value, threshold, unit: str) -> None:
|
||||
if verdict(flags, confirm, key in was_open):
|
||||
problems[f"{host.id}:{key}"] = {**base, "ressource": label, "wert": f"{value}{unit}", "schwelle": f"{threshold}{unit}"}
|
||||
|
||||
latest = samples[0]
|
||||
simple("memory", "Arbeitsspeicher", [over(s["mem_used_pct"], cfg.memory_threshold_pct) for s in samples],
|
||||
latest["mem_used_pct"], cfg.memory_threshold_pct, " %")
|
||||
simple("swap", "Swap", [over(s["swap_used_pct"], cfg.swap_threshold_pct) for s in samples],
|
||||
latest["swap_used_pct"], cfg.swap_threshold_pct, " %")
|
||||
|
||||
def per_cpu(s):
|
||||
if s["load_avg_1m"] is None or not s["cpu_threads"]:
|
||||
return None # thread count unknown (older firmware): the load cannot be judged
|
||||
return s["load_avg_1m"] / s["cpu_threads"]
|
||||
|
||||
simple("cpu", "CPU-Last (pro Thread)", [over(per_cpu(s), cfg.load_per_cpu_threshold) for s in samples],
|
||||
None if per_cpu(latest) is None else round(per_cpu(latest), 2), cfg.load_per_cpu_threshold, "")
|
||||
return problems
|
||||
|
||||
|
||||
# ── Services ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def check_service_down(db: AsyncSession) -> dict[str, dict]:
|
||||
cfg = await notification_settings_service.get_settings(db)
|
||||
confirm = max(1, cfg.alert_confirm_samples)
|
||||
watch = set(cfg.service_watchlist if cfg.service_watchlist is not None else DEFAULT_SERVICE_WATCHLIST)
|
||||
open_keys = await _open_keys(db, "service_down")
|
||||
problems = Problems()
|
||||
for host in await _enabled_hosts(db):
|
||||
samples = await _recent(db, host.id, confirm + SERVICE_EVIDENCE_SAMPLES, "services")
|
||||
if not _fresh(samples):
|
||||
problems.preserve_prefixes.add(f"{host.id}:")
|
||||
continue
|
||||
listings = [s["services"] for s in samples]
|
||||
was_open = _keys_of_host(open_keys, host)
|
||||
candidates = {k for k in (listings[0] or {}) if k.split("/")[0] in watch} | was_open
|
||||
for svc in sorted(candidates):
|
||||
if svc.split("/")[0] not in watch:
|
||||
continue # taken off the watchlist: the problem resolves
|
||||
flags = [None if sv is None else (sv[svc] == 0 if svc in sv else False) for sv in listings]
|
||||
bad = verdict(flags, confirm, svc in was_open)
|
||||
# Only a service that was seen running before counts: one that never ran (enabled but idle,
|
||||
# e.g. DHCPv6 without a prefix) or was stopped on purpose long ago is not a failure.
|
||||
if bad and svc not in was_open and not any(sv and sv.get(svc) == 1 for sv in listings[confirm:]):
|
||||
bad = False
|
||||
if bad:
|
||||
problems[f"{host.id}:{svc}"] = {
|
||||
"host_id": str(host.id), "host_name": host.name, "dienst": svc.split("/")[0],
|
||||
"hinweis": "Dienst läuft nicht mehr; absichtlich gestoppt? Dann aus der Überwachungsliste nehmen.",
|
||||
}
|
||||
return problems
|
||||
@@ -22,6 +22,7 @@ taken before the change is the way back.
|
||||
Secrets (the CARP password) are never returned, hashed into the token only, and
|
||||
never written to the audit log.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
@@ -362,6 +363,20 @@ async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None,
|
||||
return False, ["unreadable"]
|
||||
|
||||
|
||||
async def _carp_vip_active(host: OpnsenseHost, vhid: str, attempts: int = 5) -> bool:
|
||||
"""True once the firewall reports the VHID as MASTER or BACKUP, i.e. the VIP really is on the interface and
|
||||
taking part in CARP (not DISABLED/INIT). The config can look right while the live VIP is gone."""
|
||||
for attempt in range(attempts):
|
||||
if attempt:
|
||||
await asyncio.sleep(2)
|
||||
data = await opnsense_client.get_vip_status(host)
|
||||
rows = data.get("rows") if isinstance(data, dict) else None
|
||||
states = {str(r.get("vhid")): str(r.get("status", "")).upper() for r in rows or [] if isinstance(r, dict)}
|
||||
if states.get(vhid) in ("MASTER", "BACKUP"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
|
||||
token: str, acknowledged: bool) -> dict:
|
||||
await ensure_no_maintenance(db, [host])
|
||||
@@ -384,6 +399,12 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
|
||||
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
|
||||
# the request, so every VIP update must repeat it (found live: a partial set was rejected / could wipe it).
|
||||
send.setdefault("network", plan["_before"]["network"])
|
||||
# VipSettingsController::setItemAction queues "delete the old address" whenever the posted interface differs
|
||||
# from the stored one, and a partial update that leaves "interface" out counts as different. The following
|
||||
# reconfigure then removes the live VIP and, working from a stale address list, never puts it back (found
|
||||
# live 2026-10-20: CARP VIP stuck on DISABLED after a description edit). So repeat the interface as well.
|
||||
if plan["_before"].get("interface"):
|
||||
send.setdefault("interface", plan["_before"]["interface"])
|
||||
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
|
||||
expected = {**plan["_before"], **send} if action == "set" else dict(send)
|
||||
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
|
||||
@@ -407,14 +428,27 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
|
||||
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
|
||||
# A description is not wired into anything, so there is nothing to re-apply for it.
|
||||
cosmetic = kind == "vip" and action == "set" and {c["field"] for c in plan["changes"]} <= {"descr"}
|
||||
carp_live = kind == "vip" and action != "del" and expected.get("mode") == "carp" and not cosmetic
|
||||
try:
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
if not cosmetic:
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
live_ok = True
|
||||
if carp_live:
|
||||
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||
if not live_ok: # one more apply usually brings it back up
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||
except OpnsenseAPIError as exc:
|
||||
# Written but not (provably) applied: say so instead of reporting success.
|
||||
await audit_service.persist(**audit, status="apply_uncertain", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id=backup_id) from exc
|
||||
|
||||
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
|
||||
if not live_ok:
|
||||
verified = False
|
||||
mismatches = [*mismatches, "carp_vip_inactive"]
|
||||
await audit_service.persist(**audit, status="applied" if verified else "unverified",
|
||||
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
||||
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Extra notification channels: ntfy, Gotify, Telegram, Microsoft Teams, Matrix.
|
||||
|
||||
They sit beside SMTP and the generic webhook (notification_delivery.py) and reuse its message
|
||||
text. Each channel is a row in ``notification_channels`` (see models/notification.py); the
|
||||
credential never touches the database: it is stored through notification_secret_store as
|
||||
``channel_<id>`` (mode 0600 file) and read only at send time. Where the credential is part of the
|
||||
URL (a Telegram bot token, the whole Teams webhook URL) the URL is built per request, and the
|
||||
HTTP request log is muted while it is in flight (``url_guard.muted``), so neither the logs nor an
|
||||
error message ever contain it.
|
||||
|
||||
API formats, checked against the vendors' documentation:
|
||||
|
||||
* ntfy — JSON publish: POST <server>/ with {"topic", "title", "message", "priority", "tags"}
|
||||
(docs.ntfy.sh/publish, "Publish as JSON"); optional ``Authorization: Bearer <token>``.
|
||||
* Gotify — POST <server>/message, header ``X-Gotify-Key: <app token>`` (keeps the token out of
|
||||
the URL), JSON {"title", "message", "priority"} (gotify.net/docs/pushmsg).
|
||||
* Telegram — POST <api>/bot<token>/sendMessage, JSON {"chat_id", "text"}; text limit 4096
|
||||
characters; answers {"ok": true, ...} (core.telegram.org/bots/api#sendmessage).
|
||||
* Teams — POST to the (Workflows / incoming) webhook URL with a message envelope carrying an
|
||||
Adaptive Card (learn.microsoft.com ... add-incoming-webhook); the URL is the secret.
|
||||
* Matrix — PUT <homeserver>/_matrix/client/v3/rooms/<roomId>/send/m.room.message/<txnId> with
|
||||
``Authorization: Bearer <access token>``, JSON {"msgtype": "m.text", "body"}; a fresh
|
||||
transaction id per message (Matrix client-server API, "Sending events to a room").
|
||||
"""
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.notification import CHANNEL_TYPES, NotificationChannel, NotificationProblem
|
||||
from app.services import notification_delivery, notification_secret_store, url_guard
|
||||
from app.services.notification_delivery import NotificationDeliveryError
|
||||
|
||||
TELEGRAM_LIMIT = 4096
|
||||
TEAMS_LIMIT = 20000
|
||||
_NTFY_TOPIC = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
_MATRIX_ROOM = re.compile(r"^![^\s:]+:[^\s/]+$")
|
||||
|
||||
# type -> (required config keys, secret required?)
|
||||
_SPEC = {
|
||||
"ntfy": ({"topic"}, False),
|
||||
"gotify": ({"server_url"}, True),
|
||||
"telegram": ({"chat_id"}, True),
|
||||
"teams": (set(), True),
|
||||
"matrix": ({"homeserver_url", "room_id"}, True),
|
||||
}
|
||||
assert set(_SPEC) == set(CHANNEL_TYPES)
|
||||
|
||||
|
||||
def secret_name(channel_id) -> str:
|
||||
return f"channel_{channel_id}"
|
||||
|
||||
|
||||
def clean_config(channel_type: str, config: dict) -> dict:
|
||||
"""Validates and normalises the non-secret settings of a channel. Raises ValueError (German reason)."""
|
||||
if channel_type not in _SPEC:
|
||||
raise ValueError("Unbekannter Kanaltyp")
|
||||
config = dict(config or {})
|
||||
out: dict = {}
|
||||
|
||||
def url(key: str, default: str | None = None) -> str | None:
|
||||
raw = str(config.get(key) or "").strip() or default
|
||||
if raw is None:
|
||||
return None
|
||||
return url_guard.validate_url(raw).rstrip("/")
|
||||
|
||||
def integer(key: str, low: int, high: int, default: int) -> int:
|
||||
value = config.get(key)
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"{key}: ganze Zahl erwartet") from None
|
||||
if not low <= number <= high:
|
||||
raise ValueError(f"{key}: Wert zwischen {low} und {high} erwartet")
|
||||
return number
|
||||
|
||||
if channel_type == "ntfy":
|
||||
out["server_url"] = url("server_url", "https://ntfy.sh")
|
||||
topic = str(config.get("topic") or "").strip()
|
||||
if not _NTFY_TOPIC.match(topic):
|
||||
raise ValueError("Thema: 1–64 Zeichen aus Buchstaben, Ziffern, _ und -")
|
||||
out["topic"] = topic
|
||||
out["priority"] = integer("priority", 1, 5, 4)
|
||||
elif channel_type == "gotify":
|
||||
out["server_url"] = url("server_url")
|
||||
if not out["server_url"]:
|
||||
raise ValueError("Serveradresse fehlt")
|
||||
out["priority"] = integer("priority", 0, 10, 5)
|
||||
elif channel_type == "telegram":
|
||||
chat_id = str(config.get("chat_id") or "").strip()
|
||||
if not re.match(r"^(-?\d{1,20}|@[A-Za-z0-9_]{4,64})$", chat_id):
|
||||
raise ValueError("Chat-ID: Zahl (z. B. -1001234567890) oder @Kanalname")
|
||||
out["chat_id"] = chat_id
|
||||
out["api_base"] = url("api_base", "https://api.telegram.org")
|
||||
elif channel_type == "matrix":
|
||||
out["homeserver_url"] = url("homeserver_url")
|
||||
if not out["homeserver_url"]:
|
||||
raise ValueError("Homeserver-Adresse fehlt")
|
||||
room = str(config.get("room_id") or "").strip()
|
||||
if not _MATRIX_ROOM.match(room):
|
||||
raise ValueError("Raum-ID: Form !abc123:example.org (nicht der Alias #raum:…)")
|
||||
out["room_id"] = room
|
||||
return out
|
||||
|
||||
|
||||
def clean_secret(channel_type: str, secret: str) -> str:
|
||||
value = (secret or "").strip()
|
||||
if not value:
|
||||
raise ValueError("Zugangsdaten fehlen")
|
||||
if channel_type == "teams":
|
||||
value = url_guard.validate_url(value, max_length=2000)
|
||||
if not value.startswith("https://"):
|
||||
raise ValueError("Die Teams-Webhook-Adresse muss mit https:// beginnen")
|
||||
elif any(c.isspace() for c in value) or len(value) > 500:
|
||||
raise ValueError("Token: keine Leerzeichen, höchstens 500 Zeichen")
|
||||
return value
|
||||
|
||||
|
||||
def missing_requirements(channel: NotificationChannel) -> str | None:
|
||||
required, needs_secret = _SPEC[channel.channel_type]
|
||||
if any(not channel.config.get(k) for k in required):
|
||||
return "Konfiguration unvollständig"
|
||||
if needs_secret and not channel.secret_set:
|
||||
return "Zugangsdaten fehlen"
|
||||
return None
|
||||
|
||||
|
||||
# ── Message formatting (one function per channel, pure — unit-testable) ───────────
|
||||
|
||||
def _parts(problem: NotificationProblem, transition: str) -> tuple[str, str]:
|
||||
"""(title, details) — the title repeated at the top of render_message's body is dropped."""
|
||||
subject, body = notification_delivery.render_message(problem, transition)
|
||||
details = body.split("\n", 2)[2] if body.count("\n") >= 2 else ""
|
||||
return subject, details.strip()
|
||||
|
||||
|
||||
def _clip(text: str, limit: int) -> str:
|
||||
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def format_ntfy(config: dict, problem, transition: str) -> dict:
|
||||
title, details = _parts(problem, transition)
|
||||
resolved = transition == "resolved"
|
||||
return {
|
||||
"topic": config["topic"], "title": title, "message": details or title,
|
||||
"priority": 3 if resolved else int(config.get("priority", 4)),
|
||||
"tags": ["white_check_mark"] if resolved else ["warning"],
|
||||
}
|
||||
|
||||
|
||||
def format_gotify(config: dict, problem, transition: str) -> dict:
|
||||
title, details = _parts(problem, transition)
|
||||
return {"title": title, "message": details or title, "priority": 1 if transition == "resolved" else int(config.get("priority", 5))}
|
||||
|
||||
|
||||
def format_telegram(config: dict, problem, transition: str) -> dict:
|
||||
title, details = _parts(problem, transition)
|
||||
return {
|
||||
"chat_id": config["chat_id"], "text": _clip(f"{title}\n\n{details}".strip(), TELEGRAM_LIMIT),
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
|
||||
|
||||
def format_teams(config: dict, problem, transition: str) -> dict:
|
||||
title, details = _parts(problem, transition)
|
||||
body: list[dict] = [{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium", "wrap": True}]
|
||||
if details:
|
||||
body.append({"type": "TextBlock", "text": _clip(details, TEAMS_LIMIT), "wrap": True})
|
||||
return {
|
||||
"type": "message",
|
||||
"attachments": [{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None,
|
||||
"content": {
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard", "version": "1.4", "body": body,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def format_matrix(config: dict, problem, transition: str) -> dict:
|
||||
title, details = _parts(problem, transition)
|
||||
return {"msgtype": "m.text", "body": f"{title}\n\n{details}".strip()}
|
||||
|
||||
|
||||
# ── Sending ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _request_for(channel: NotificationChannel, secret: str, problem, transition: str) -> tuple[str, str, dict, dict, str | None]:
|
||||
"""(method, url, headers, json body, expected "ok" check key)."""
|
||||
cfg, kind = channel.config, channel.channel_type
|
||||
if kind == "ntfy":
|
||||
headers = {"Authorization": f"Bearer {secret}"} if secret else {}
|
||||
return "POST", cfg["server_url"].rstrip("/") + "/", headers, format_ntfy(cfg, problem, transition), None
|
||||
if kind == "gotify":
|
||||
return "POST", cfg["server_url"].rstrip("/") + "/message", {"X-Gotify-Key": secret}, format_gotify(cfg, problem, transition), None
|
||||
if kind == "telegram":
|
||||
return "POST", f"{cfg['api_base'].rstrip('/')}/bot{secret}/sendMessage", {}, format_telegram(cfg, problem, transition), "ok"
|
||||
if kind == "teams":
|
||||
return "POST", secret, {}, format_teams(cfg, problem, transition), None
|
||||
room = quote(cfg["room_id"], safe="")
|
||||
url = f"{cfg['homeserver_url'].rstrip('/')}/_matrix/client/v3/rooms/{room}/send/m.room.message/{uuid.uuid4().hex}"
|
||||
return "PUT", url, {"Authorization": f"Bearer {secret}"}, format_matrix(cfg, problem, transition), None
|
||||
|
||||
|
||||
async def send(channel: NotificationChannel, problem: NotificationProblem, transition: str) -> None:
|
||||
problem_missing = missing_requirements(channel)
|
||||
if problem_missing:
|
||||
raise NotificationDeliveryError(f"Kanal „{channel.name}“: {problem_missing}")
|
||||
secret = notification_secret_store.read_secret(secret_name(channel.id)) or ""
|
||||
if channel.secret_set and not secret:
|
||||
raise NotificationDeliveryError(f"Kanal „{channel.name}“: Zugangsdaten nicht lesbar – bitte neu setzen")
|
||||
method, url, headers, payload, ok_key = _request_for(channel, secret, problem, transition)
|
||||
label = f"Kanal „{channel.name}“ ({channel.channel_type})"
|
||||
with url_guard.muted():
|
||||
try:
|
||||
url_guard.validate_url(url, max_length=2100)
|
||||
await url_guard.ensure_resolved_safe(url)
|
||||
except ValueError as exc:
|
||||
raise NotificationDeliveryError(f"{label}: {exc}") from None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
|
||||
resp = await client.request(method, url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
if ok_key and not (resp.json() or {}).get(ok_key):
|
||||
raise NotificationDeliveryError(f"{label}: Zustellung abgelehnt")
|
||||
except httpx.HTTPError as exc:
|
||||
# str(exc) names the full URL, which for Telegram/Teams is the credential itself.
|
||||
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
|
||||
raise NotificationDeliveryError(f"{label}: Zustellung fehlgeschlagen ({reason})") from None
|
||||
except ValueError:
|
||||
raise NotificationDeliveryError(f"{label}: Antwort nicht lesbar") from None
|
||||
|
||||
|
||||
async def send_test(channel: NotificationChannel) -> None:
|
||||
fake = NotificationProblem(
|
||||
problem_type="test", dedup_key="test",
|
||||
context={"message": "Dies ist eine Testbenachrichtigung von OPNexus."},
|
||||
)
|
||||
await send(channel, fake, "opened")
|
||||
|
||||
|
||||
async def enabled_channels(db: AsyncSession) -> list[NotificationChannel]:
|
||||
rows = await db.scalars(select(NotificationChannel).where(NotificationChannel.enabled.is_(True)).order_by(NotificationChannel.name))
|
||||
return list(rows.all())
|
||||
@@ -236,6 +236,28 @@ async def check_carp_health(db: AsyncSession) -> dict[str, dict]:
|
||||
"condition": "no_master",
|
||||
"host_names": sorted(h.name for h in online),
|
||||
}
|
||||
problems.update(_carp_vip_problems(cluster_name, online, statuses, split_brain=len(masters) == 2))
|
||||
return problems
|
||||
|
||||
|
||||
def _carp_vip_problems(cluster_name: str, online: list[OpnsenseHost], statuses: dict, split_brain: bool) -> dict[str, dict]:
|
||||
"""Per-VHID checks. The host-level role is "MASTER" as soon as one VIP is, so a VIP that is down on a
|
||||
node (a node without the VIP never advertises it, the peer takes it over) looks healthy in the totals."""
|
||||
problems: dict[str, dict] = {}
|
||||
for host in online:
|
||||
vips = statuses.get(host.id, {}).get("carp_vips") or {}
|
||||
down = sorted(v for v, state in vips.items() if state not in ("MASTER", "BACKUP"))
|
||||
if down:
|
||||
problems[f"{cluster_name}:vip_down:{host.name}"] = {
|
||||
"cluster_name": cluster_name, "condition": "vip_down", "host_names": [host.name], "vhids": down,
|
||||
}
|
||||
# One node MASTER on some VHIDs and BACKUP on others: a partial failover. Already covered by the
|
||||
# split-brain alert when both nodes hold a MASTER.
|
||||
if not split_brain and {"MASTER", "BACKUP"} <= set(vips.values()):
|
||||
problems[f"{cluster_name}:role_mismatch:{host.name}"] = {
|
||||
"cluster_name": cluster_name, "condition": "role_mismatch", "host_names": [host.name],
|
||||
"vhids": sorted(v for v, state in vips.items() if state == "MASTER"),
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
@@ -341,3 +363,49 @@ async def check_compliance(db: AsyncSession) -> dict[str, dict]:
|
||||
"beispiele": ", ".join(params.get("examples", [])) or None,
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
# ── Rollouts (1.25) ─────────────────────────────────────────────────────────
|
||||
|
||||
def _short(detail) -> str | None:
|
||||
if not detail:
|
||||
return None
|
||||
parts = []
|
||||
for key, value in detail.items():
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value[:8])
|
||||
parts.append(f"{key}: {value}")
|
||||
return "; ".join(parts)[:500]
|
||||
|
||||
|
||||
async def check_rollout_paused(db: AsyncSession) -> dict[str, dict]:
|
||||
"""Open while a rollout run stands still waiting for a human (gate, failed host, window gone...)."""
|
||||
from app.models.rollout import RolloutRun
|
||||
problems: dict[str, dict] = {}
|
||||
for run in (await db.scalars(select(RolloutRun).where(RolloutRun.status == "paused"))).all():
|
||||
wave = next((w.label for w in run.waves if w.index == run.current_wave), None)
|
||||
problems[str(run.id)] = {
|
||||
"run_id": str(run.id), "plan": run.plan_name, "grund": run.pause_reason, "welle": wave,
|
||||
"zielversion": run.target_version, "details": _short(run.pause_detail),
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
ROLLOUT_START_FAILURE_WINDOW = timedelta(days=3)
|
||||
|
||||
|
||||
async def check_rollout_start_failed(db: AsyncSession) -> dict[str, dict]:
|
||||
"""An automatic start at a window occurrence did not happen (event: closed without a 'resolved' notice)."""
|
||||
from app.models.rollout import RolloutPlan
|
||||
since = datetime.now(timezone.utc) - ROLLOUT_START_FAILURE_WINDOW
|
||||
problems: dict[str, dict] = {}
|
||||
for plan in (await db.scalars(select(RolloutPlan).where(RolloutPlan.auto_start_error.is_not(None)))).all():
|
||||
error = plan.auto_start_error or {}
|
||||
at = datetime.fromisoformat(error["at"]) if error.get("at") else None
|
||||
if at is None or at < since:
|
||||
continue
|
||||
problems[f"{plan.id}:{error.get('occurrence')}"] = {
|
||||
"plan_id": str(plan.id), "plan": plan.name, "grund": error.get("code"), "termin": error.get("occurrence"),
|
||||
"details": _short(error.get("params")),
|
||||
}
|
||||
return problems
|
||||
@@ -16,7 +16,7 @@ import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.models.notification import NotificationProblem, NotificationSettings
|
||||
from app.services import notification_secret_store
|
||||
from app.services import notification_secret_store, url_guard
|
||||
|
||||
|
||||
class NotificationDeliveryError(Exception):
|
||||
@@ -37,6 +37,12 @@ _LABELS = {
|
||||
"backup_stale": "Kein aktuelles Backup",
|
||||
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
|
||||
"compliance_finding": "Sicherheitsbefund",
|
||||
"gateway_down": "Gateway gestört",
|
||||
"resource_threshold": "Ressourcen-Schwelle überschritten",
|
||||
"service_down": "Dienst ausgefallen",
|
||||
"rollout_paused": "Rollout angehalten",
|
||||
"rollout_start_failed": "Rollout nicht automatisch gestartet",
|
||||
"test": "Testbenachrichtigung",
|
||||
}
|
||||
|
||||
|
||||
@@ -84,9 +90,16 @@ async def send_webhook(settings_row: NotificationSettings, problem: Notification
|
||||
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()
|
||||
url_guard.validate_url(settings_row.webhook_url)
|
||||
await url_guard.ensure_resolved_safe(settings_row.webhook_url)
|
||||
except ValueError as exc:
|
||||
raise NotificationDeliveryError(f"Webhook-Adresse nicht erlaubt: {exc}") from None
|
||||
try:
|
||||
# follow_redirects stays off: a redirect must not move the request to an address the check above never saw.
|
||||
async with httpx.AsyncClient(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
|
||||
with url_guard.muted():
|
||||
resp = await client.post(settings_row.webhook_url, content=raw, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
# str(exc) names the full URL, which often is the credential itself
|
||||
# (token in path/query) — keep it out of messages and logs.
|
||||
|
||||
@@ -8,8 +8,8 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import NotificationProblem, NotificationSettings
|
||||
from app.services import notification_checks, notification_dedup, notification_delivery
|
||||
from app.models.notification import NotificationChannel, NotificationProblem, NotificationSettings
|
||||
from app.services import monitor_checks, notification_channels, notification_checks, notification_dedup, notification_delivery
|
||||
from app.services import notification_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,14 +34,21 @@ _CHECKS = [
|
||||
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
|
||||
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
|
||||
("compliance_finding", "compliance_alerts_enabled", notification_checks.check_compliance),
|
||||
("gateway_down", "gateway_down_enabled", monitor_checks.check_gateway_down),
|
||||
("resource_threshold", "resource_threshold_enabled", monitor_checks.check_resource_threshold),
|
||||
("service_down", "service_down_enabled", monitor_checks.check_service_down),
|
||||
("rollout_paused", "rollout_problems_enabled", notification_checks.check_rollout_paused),
|
||||
("rollout_start_failed", "rollout_problems_enabled", notification_checks.check_rollout_start_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"}
|
||||
_NO_RESOLVED_NOTICE = {"scheduled_maintenance_failed", "rollout_start_failed"}
|
||||
|
||||
|
||||
async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None:
|
||||
async def _deliver(
|
||||
settings_row: NotificationSettings, problem: NotificationProblem, transition: str, channels: list[NotificationChannel] = (),
|
||||
) -> 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."""
|
||||
@@ -60,6 +67,13 @@ async def _deliver(settings_row: NotificationSettings, problem: NotificationProb
|
||||
except notification_delivery.NotificationDeliveryError:
|
||||
logger.exception("Email delivery failed for %s/%s", problem.problem_type, problem.dedup_key)
|
||||
|
||||
for channel in channels:
|
||||
try:
|
||||
await notification_channels.send(channel, problem, transition)
|
||||
except notification_delivery.NotificationDeliveryError:
|
||||
# The error text names the channel and the HTTP status, never the URL or token.
|
||||
logger.exception("Channel %s (%s) delivery failed for %s/%s", channel.name, channel.channel_type, problem.problem_type, problem.dedup_key)
|
||||
|
||||
if transition == "resolved":
|
||||
problem.resolved_notified_at = _now()
|
||||
else:
|
||||
@@ -69,6 +83,7 @@ async def _deliver(settings_row: NotificationSettings, problem: NotificationProb
|
||||
async def run_notification_checks(db: AsyncSession) -> dict:
|
||||
settings_row = await notification_settings_service.get_settings(db)
|
||||
result = {"opened": 0, "resolved": 0}
|
||||
channels = await notification_channels.enabled_channels(db)
|
||||
|
||||
for problem_type, enabled_flag, check_fn in _CHECKS:
|
||||
if not getattr(settings_row, enabled_flag):
|
||||
@@ -85,9 +100,9 @@ async def run_notification_checks(db: AsyncSession) -> dict:
|
||||
|
||||
opened, resolved = await notification_dedup.diff_and_update(db, problem_type, current)
|
||||
for problem in opened:
|
||||
await _deliver(settings_row, problem, "opened")
|
||||
await _deliver(settings_row, problem, "opened", channels)
|
||||
for problem in resolved:
|
||||
await _deliver(settings_row, problem, "resolved")
|
||||
await _deliver(settings_row, problem, "resolved", channels)
|
||||
result["opened"] += len(opened)
|
||||
result["resolved"] += len(resolved)
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
|
||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
||||
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
|
||||
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled", "rollout_problems_enabled",
|
||||
"gateway_loss_threshold_pct", "gateway_delay_threshold_ms", "disk_threshold_pct", "memory_threshold_pct",
|
||||
"swap_threshold_pct", "load_per_cpu_threshold", "alert_confirm_samples", "resource_confirm_samples",
|
||||
"service_watchlist",
|
||||
"poll_interval_seconds",
|
||||
]
|
||||
for field in simple_fields:
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Takes over aliases, port forwards, 1:1 NAT and outbound NAT rules that were created by hand on a
|
||||
firewall, so they can be edited, versioned and deployed from OPNexus like any other object. This is
|
||||
the generalisation of rule_adopt_service.py (filter rules keep their own module: their model has
|
||||
more special cases and the code is relied upon).
|
||||
|
||||
Adoption itself writes NOTHING to the firewall. It records the object as it is right now (read fresh
|
||||
from the host at adoption time, never from what the browser sent) together with its OPNsense UUID
|
||||
(`remote_uuid` = the deployment's opnsense_*_uuid), and stores the payload OPNexus would send for it as
|
||||
the deployment's last_payload. So the preview directly afterwards says "unchanged", a later deploy
|
||||
updates the same object via setItem/setRule (no duplicate), and the normal preview → push →
|
||||
confirm/auto-revert flow applies from the first change on.
|
||||
|
||||
Only objects OPNexus can describe without distortion are adopted. Anything the model cannot express
|
||||
is refused with machine-readable reasons (the UI translates them) instead of being imported in a way
|
||||
that would silently change its meaning on the next edit. Fields OPNexus does not manage (log flag of a
|
||||
port forward, pool options, tags, categories, ...) are never sent, and OPNsense's set*/setItem only
|
||||
overwrites the posted fields (ApiMutableModelControllerBase::setBase -> setNodes), so they stay as
|
||||
they are; the candidate list shows them as `kept`.
|
||||
|
||||
Evidence (OPNsense core master, src/opnsense/mvc/app/…, read 2026-10-20, and live against OPNsense
|
||||
26.7 on test-fw-3 on the same day with hand-made objects of every case below):
|
||||
|
||||
* Aliases — controllers/OPNsense/Firewall/Api/AliasController.php::searchItemAction -> searchBase
|
||||
("aliases.alias"), model models/OPNsense/Firewall/Alias.xml. Row keys used: uuid, name, type
|
||||
("host", "network", "port", "networkgroup", "mac", "asn", plus the unsupported "url", "urltable",
|
||||
"urljson", "geoip", "dynipv6host", "authgroup", and the system types "internal"/"external"),
|
||||
enabled "1"/"0", content (entries separated by "\n", live-verified; for "authgroup" the controller
|
||||
rewrites it to group names), description, proto (only meaningful for asn/geoip). The built-in rows
|
||||
are bogons, bogonsv6, sshlockout, virusprot (type external) and one __<if>_network per interface
|
||||
(type internal); they are OPNsense's own and never adoptable. A "networkgroup" lists alias NAMES;
|
||||
host/network/port aliases may also contain alias names (nesting).
|
||||
* Port forward — controllers/OPNsense/Firewall/Api/DNatController.php::searchRuleAction merges the
|
||||
configured rules (getFlatNodes: dotted keys "source.network", "source.port", "source.not",
|
||||
"destination.network", "destination.port", "destination.not", plus descr, disabled, interface,
|
||||
ipprotocol, protocol, target, local-port, natreflection, nordr, pass, log, poolopts, tag, tagged,
|
||||
nosync, categories, sequence) with synthetic automatic rows (uuid "automatic_*"/"lockout_*",
|
||||
is_automatic true) that setRule/delRule cannot target. models/OPNsense/Firewall/DNat.xml: interface is
|
||||
Multiple=Y (comma separated), ipprotocol may be blank/inet/inet6/inet46, protocol blank = any,
|
||||
`nordr` ("no redirect") requires an empty target/local-port (live: "Target must be empty when No
|
||||
RDR is set"), `pass` ("" manual / "pass" / "rule") links an associated filter rule, natreflection is
|
||||
""/purenat/disable. OPNexus sends pass "" and not "0", so a rule using one of those would change
|
||||
meaning on its first edit and is refused.
|
||||
* 1:1 NAT — OneToOneController::searchRuleAction (flat keys), model Filter.xml onetoone.rule:
|
||||
enabled, log, interface, type (binat/nat), source_net/source_not, destination_net/destination_not,
|
||||
external, natreflection ""/enable/disable, description. All of these are managed by OPNexus.
|
||||
* Outbound NAT — SourceNatController::searchRuleAction returns the configured rules ONLY when the
|
||||
global mode (filter.settings.nat.snat_mode) is hybrid or advanced; in automatic/disabled mode the
|
||||
manual rules do not show up at all, so adopting is refused up front (outbound_nat_mode.py) instead
|
||||
of presenting an empty list. Model Filter.xml snatrules.rule: `nonat` rules ("do not NAT") are not
|
||||
expressible and refused; staticnatport, poolopts, log, tag, tagged, nosync, endpoint-independent
|
||||
are left alone.
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, get_args
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.alias import FirewallAlias
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.one_to_one import OneToOneRule
|
||||
from app.models.one_to_one_deployment import OneToOneDeployment
|
||||
from app.models.outbound_nat import OutboundNatRule
|
||||
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||
from app.models.port_forward import PortForwardRule
|
||||
from app.models.port_forward_deployment import PortForwardDeployment
|
||||
from app.schemas.alias import AliasType
|
||||
from app.services import alias_deploy_service, alias_policy_service
|
||||
from app.services import one_to_one_deploy_service, one_to_one_policy_service
|
||||
from app.services import opnsense_client, outbound_nat_deploy_service, outbound_nat_policy_service
|
||||
from app.services import port_forward_deploy_service, port_forward_policy_service
|
||||
from app.services.adopt_common import NotAdoptable, NotFoundOnHost, clean, on
|
||||
from app.services.nat_live_service import split_rows
|
||||
from app.services.outbound_nat_mode import host_mode
|
||||
|
||||
ADOPT_NOTE = "Von Firewall übernommen"
|
||||
ADOPT_MESSAGE = "Von der Firewall übernommen"
|
||||
|
||||
ALIAS_NAME = re.compile(r"^[A-Za-z0-9_]+$") # schemas/alias.py AliasCreate.name
|
||||
ALIAS_TYPES = frozenset(get_args(AliasType))
|
||||
ALIAS_CONTENT_MAX = 8000 # schemas/alias.py AliasCreate.content
|
||||
# OPNsense's own aliases that are not user objects (Firewall/Alias.php: bogons & co. are `external`
|
||||
# rows created by the system, __<if>_network are `internal`).
|
||||
SYSTEM_ALIAS_NAMES = frozenset({"bogons", "bogonsv6", "sshlockout", "virusprot"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
row: dict
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
details: dict[str, str] = field(default_factory=dict)
|
||||
bulk: bool = False # not adoptable alone yet, but adoptable in the same run (its references come first)
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
return clean(self.row.get("uuid"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Kind:
|
||||
key: str
|
||||
model: type
|
||||
deployment: type
|
||||
fk: str # deployment column pointing at the central object
|
||||
uuid_attr: str # deployment column holding the OPNsense UUID (= remote_uuid)
|
||||
fetch: Callable[[OpnsenseHost], Awaitable[list[dict]]]
|
||||
check: Callable[[dict, dict], tuple[list[str], dict[str, str]]]
|
||||
title: Callable[[dict], str]
|
||||
info: Callable[[dict], dict[str, str]]
|
||||
build: Callable[[dict], dict]
|
||||
payload: Callable[[Any], dict]
|
||||
revision: Callable
|
||||
kept: Callable[[dict], list[str]] = lambda row: []
|
||||
context: Callable[[AsyncSession, list[dict]], Awaitable[dict]] | None = None
|
||||
refs: Callable[[dict], set[str]] | None = None
|
||||
name: Callable[[dict], str] | None = None
|
||||
|
||||
|
||||
# ── shared helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _single_interface(row: dict) -> bool:
|
||||
interface = clean(row.get("interface"))
|
||||
return bool(interface) and "," not in interface
|
||||
|
||||
|
||||
def _too_long(kind_model: type, values: dict) -> list[str]:
|
||||
"""Columns are bounded; a live value that does not fit would fail (or be cut) on the first edit."""
|
||||
columns = kind_model.__table__.columns
|
||||
return [name for name, value in values.items()
|
||||
if name in columns and getattr(columns[name].type, "length", None)
|
||||
and isinstance(value, str) and len(value) > columns[name].type.length]
|
||||
|
||||
|
||||
def _port(value) -> str | None:
|
||||
return clean(value) or None
|
||||
|
||||
|
||||
def _kept(row: dict, keys: tuple[str, ...]) -> list[str]:
|
||||
return [key for key in keys if clean(row.get(key)) not in ("", "0")]
|
||||
|
||||
|
||||
def _fetch_nat(search) -> Callable[[OpnsenseHost], Awaitable[list[dict]]]:
|
||||
async def fetch(host: OpnsenseHost) -> list[dict]:
|
||||
data = await search(host)
|
||||
# split_rows drops automatic_*/lockout_* and is_automatic rows: they are OPNsense's own
|
||||
return [r for r in split_rows(data)["rules"] if clean(r.get("uuid"))]
|
||||
return fetch
|
||||
|
||||
|
||||
# ── aliases ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _alias_content(row: dict) -> str:
|
||||
# same normalisation as AliasCreate.clean_content
|
||||
return "\n".join(line.strip() for line in str(row.get("content") or "").splitlines() if line.strip())
|
||||
|
||||
|
||||
def _alias_is_system(row: dict) -> bool:
|
||||
name = clean(row.get("name"))
|
||||
return clean(row.get("type")) == "internal" or name.startswith("__") or name in SYSTEM_ALIAS_NAMES
|
||||
|
||||
|
||||
async def _fetch_aliases(host: OpnsenseHost) -> list[dict]:
|
||||
data = await opnsense_client.search_aliases(host)
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [r for r in rows if isinstance(r, dict) and clean(r.get("uuid")) and clean(r.get("name"))]
|
||||
|
||||
|
||||
async def _alias_context(db: AsyncSession, rows: list[dict]) -> dict:
|
||||
return {"central_names": set((await db.scalars(select(FirewallAlias.name))).all())}
|
||||
|
||||
|
||||
def _alias_refs(row: dict) -> set[str]:
|
||||
return {line.lstrip("!").strip() for line in _alias_content(row).splitlines()} - {""}
|
||||
|
||||
|
||||
def _alias_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
|
||||
if _alias_is_system(row):
|
||||
return ["system"], {}
|
||||
reasons: list[str] = []
|
||||
details: dict[str, str] = {}
|
||||
name, type_ = clean(row.get("name")), clean(row.get("type"))
|
||||
if type_ not in ALIAS_TYPES:
|
||||
reasons.append("unsupported_type")
|
||||
details["unsupported_type"] = clean(row.get("%type")) or type_
|
||||
if not ALIAS_NAME.fullmatch(name) or len(name) > 64:
|
||||
reasons.append("name_invalid")
|
||||
if name in ctx["central_names"]:
|
||||
reasons.append("name_exists")
|
||||
if len(_alias_content(row)) > ALIAS_CONTENT_MAX:
|
||||
reasons.append("content_too_large")
|
||||
if type_ == "asn" and clean(row.get("proto")):
|
||||
reasons.append("proto_set") # OPNexus has no field for it: a deploy elsewhere would drop the restriction
|
||||
if _too_long(FirewallAlias, {"description": clean(row.get("description"))}):
|
||||
reasons.append("value_too_long")
|
||||
return reasons, details
|
||||
|
||||
|
||||
def _alias_info(row: dict) -> dict[str, str]:
|
||||
lines = _alias_content(row).splitlines()
|
||||
return {
|
||||
"type": clean(row.get("type")),
|
||||
"type_label": clean(row.get("%type")) or clean(row.get("type")),
|
||||
"description": clean(row.get("description")),
|
||||
"content": "\n".join(lines[:6]) + ("\n…" if len(lines) > 6 else ""),
|
||||
"entries": str(len(lines)),
|
||||
}
|
||||
|
||||
|
||||
def _alias_build(row: dict) -> dict:
|
||||
return {
|
||||
"name": clean(row.get("name")),
|
||||
"type": clean(row.get("type")),
|
||||
"content": _alias_content(row),
|
||||
"description": clean(row.get("description")),
|
||||
"enabled": on(row.get("enabled")),
|
||||
}
|
||||
|
||||
|
||||
# ── port forward ─────────────────────────────────────────────────────────
|
||||
|
||||
def _pf_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
|
||||
reasons = []
|
||||
if not _single_interface(row):
|
||||
reasons.append("interface_not_single")
|
||||
if clean(row.get("ipprotocol")) not in ("inet", "inet6"):
|
||||
reasons.append("ipprotocol_unsupported") # blank / inet46 have no counterpart in OPNexus
|
||||
if not clean(row.get("protocol")):
|
||||
reasons.append("protocol_any")
|
||||
if on(row.get("nordr")):
|
||||
reasons.append("no_redirect")
|
||||
elif not clean(row.get("target")):
|
||||
reasons.append("no_target")
|
||||
if clean(row.get("pass")):
|
||||
reasons.append("filter_rule_linked") # OPNexus sends pass "" (manual) and would cut the link
|
||||
if on(row.get("source.not")):
|
||||
reasons.append("negated_source")
|
||||
if on(row.get("destination.not")):
|
||||
reasons.append("negated_destination")
|
||||
if clean(row.get("natreflection")) not in ("", "purenat", "disable"):
|
||||
reasons.append("natreflection_unsupported")
|
||||
if not clean(row.get("descr")):
|
||||
reasons.append("no_description")
|
||||
if _too_long(PortForwardRule, _pf_build(row)):
|
||||
reasons.append("value_too_long")
|
||||
return reasons, {}
|
||||
|
||||
|
||||
def _pf_build(row: dict) -> dict:
|
||||
return {
|
||||
"description": clean(row.get("descr")),
|
||||
"interface": clean(row.get("interface")),
|
||||
"ipprotocol": clean(row.get("ipprotocol")),
|
||||
"protocol": clean(row.get("protocol")),
|
||||
"source_net": clean(row.get("source.network")) or "any",
|
||||
"source_port": _port(row.get("source.port")),
|
||||
"destination_net": clean(row.get("destination.network")) or "any",
|
||||
"destination_port": _port(row.get("destination.port")),
|
||||
"target": clean(row.get("target")),
|
||||
"local_port": _port(row.get("local-port")),
|
||||
"natreflection": clean(row.get("natreflection")),
|
||||
"enabled": not on(row.get("disabled")),
|
||||
}
|
||||
|
||||
|
||||
def _pf_info(row: dict) -> dict[str, str]:
|
||||
return {
|
||||
"interface_label": clean(row.get("%interface")) or clean(row.get("interface")),
|
||||
"protocol": clean(row.get("%protocol")) or clean(row.get("protocol")) or "any",
|
||||
"source": clean(row.get("source.network")) or "any",
|
||||
"source_port": clean(row.get("source.port")),
|
||||
"destination": clean(row.get("destination.network")) or "any",
|
||||
"destination_port": clean(row.get("destination.port")),
|
||||
"target": clean(row.get("target")),
|
||||
"target_port": clean(row.get("local-port")),
|
||||
}
|
||||
|
||||
|
||||
# ── 1:1 NAT ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _o2o_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
|
||||
reasons = []
|
||||
if not _single_interface(row):
|
||||
reasons.append("interface_not_single")
|
||||
if clean(row.get("type")) not in ("binat", "nat"):
|
||||
reasons.append("type_unsupported")
|
||||
if clean(row.get("natreflection")) not in ("", "enable", "disable"):
|
||||
reasons.append("natreflection_unsupported")
|
||||
if not clean(row.get("source_net")) or not clean(row.get("external")):
|
||||
reasons.append("incomplete")
|
||||
if not clean(row.get("description")):
|
||||
reasons.append("no_description")
|
||||
if _too_long(OneToOneRule, _o2o_build(row)):
|
||||
reasons.append("value_too_long")
|
||||
return reasons, {}
|
||||
|
||||
|
||||
def _o2o_build(row: dict) -> dict:
|
||||
return {
|
||||
"description": clean(row.get("description")),
|
||||
"interface": clean(row.get("interface")),
|
||||
"source_net": clean(row.get("source_net")),
|
||||
"destination_net": clean(row.get("destination_net")) or "any",
|
||||
"source_not": on(row.get("source_not")),
|
||||
"destination_not": on(row.get("destination_not")),
|
||||
"enabled": on(row.get("enabled")),
|
||||
"type": clean(row.get("type")),
|
||||
"external": clean(row.get("external")),
|
||||
"natreflection": clean(row.get("natreflection")),
|
||||
"log": on(row.get("log")),
|
||||
}
|
||||
|
||||
|
||||
def _o2o_info(row: dict) -> dict[str, str]:
|
||||
return {
|
||||
"interface_label": clean(row.get("%interface")) or clean(row.get("interface")),
|
||||
"type": clean(row.get("type")),
|
||||
"source": ("! " if on(row.get("source_not")) else "") + clean(row.get("source_net")),
|
||||
"destination": ("! " if on(row.get("destination_not")) else "") + (clean(row.get("destination_net")) or "any"),
|
||||
"external": clean(row.get("external")),
|
||||
}
|
||||
|
||||
|
||||
# ── outbound NAT ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _fetch_outbound(host: OpnsenseHost) -> list[dict]:
|
||||
# In automatic/disabled mode OPNsense does not list manual rules at all (see module docstring):
|
||||
# say so instead of showing an empty list.
|
||||
state = await host_mode(host)
|
||||
if not state["can_deploy"]:
|
||||
raise api_error(409, ErrorCode.OUTBOUND_MODE_BLOCKS_ADOPT, host=host.name, mode=state["mode"])
|
||||
return await _fetch_nat(opnsense_client.search_outbound_nat_rules)(host)
|
||||
|
||||
|
||||
def _snat_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
|
||||
reasons = []
|
||||
if not _single_interface(row):
|
||||
reasons.append("interface_not_single")
|
||||
if on(row.get("nonat")):
|
||||
reasons.append("no_nat_rule")
|
||||
if clean(row.get("ipprotocol")) not in ("inet", "inet6"):
|
||||
reasons.append("ipprotocol_unsupported")
|
||||
if not clean(row.get("protocol")):
|
||||
reasons.append("protocol_missing")
|
||||
if not clean(row.get("description")):
|
||||
reasons.append("no_description")
|
||||
if _too_long(OutboundNatRule, _snat_build(row)):
|
||||
reasons.append("value_too_long")
|
||||
return reasons, {}
|
||||
|
||||
|
||||
def _snat_build(row: dict) -> dict:
|
||||
return {
|
||||
"description": clean(row.get("description")),
|
||||
"interface": clean(row.get("interface")),
|
||||
"source_net": clean(row.get("source_net")) or "any",
|
||||
"destination_net": clean(row.get("destination_net")) or "any",
|
||||
"source_not": on(row.get("source_not")),
|
||||
"destination_not": on(row.get("destination_not")),
|
||||
"enabled": on(row.get("enabled")),
|
||||
"ipprotocol": clean(row.get("ipprotocol")),
|
||||
"protocol": clean(row.get("protocol")),
|
||||
"source_port": _port(row.get("source_port")),
|
||||
"destination_port": _port(row.get("destination_port")),
|
||||
"target": clean(row.get("target")),
|
||||
"target_port": _port(row.get("target_port")),
|
||||
}
|
||||
|
||||
|
||||
def _snat_info(row: dict) -> dict[str, str]:
|
||||
return {
|
||||
"interface_label": clean(row.get("%interface")) or clean(row.get("interface")),
|
||||
"protocol": clean(row.get("protocol")) or "any",
|
||||
"source": ("! " if on(row.get("source_not")) else "") + (clean(row.get("source_net")) or "any"),
|
||||
"source_port": clean(row.get("source_port")),
|
||||
"destination": ("! " if on(row.get("destination_not")) else "") + (clean(row.get("destination_net")) or "any"),
|
||||
"destination_port": clean(row.get("destination_port")),
|
||||
"target": clean(row.get("target")),
|
||||
"target_port": clean(row.get("target_port")),
|
||||
}
|
||||
|
||||
|
||||
KINDS: dict[str, Kind] = {
|
||||
"alias": Kind(
|
||||
key="alias", model=FirewallAlias, deployment=AliasDeployment, fk="alias_id",
|
||||
uuid_attr="opnsense_alias_uuid", fetch=_fetch_aliases, check=_alias_check,
|
||||
title=lambda row: clean(row.get("name")), info=_alias_info, build=_alias_build,
|
||||
payload=alias_deploy_service._build_payload, revision=alias_policy_service.revision,
|
||||
context=_alias_context, refs=_alias_refs, name=lambda row: clean(row.get("name")),
|
||||
),
|
||||
"port_forward": Kind(
|
||||
key="port_forward", model=PortForwardRule, deployment=PortForwardDeployment, fk="port_forward_id",
|
||||
uuid_attr="opnsense_port_forward_uuid", fetch=_fetch_nat(opnsense_client.search_port_forward_rules),
|
||||
check=_pf_check, title=lambda row: clean(row.get("descr")), info=_pf_info, build=_pf_build,
|
||||
payload=port_forward_deploy_service._build_payload, revision=port_forward_policy_service.revision,
|
||||
kept=lambda row: _kept(row, ("log", "poolopts", "tag", "tagged", "nosync", "categories")),
|
||||
),
|
||||
"one_to_one": Kind(
|
||||
key="one_to_one", model=OneToOneRule, deployment=OneToOneDeployment, fk="one_to_one_id",
|
||||
uuid_attr="opnsense_one_to_one_uuid", fetch=_fetch_nat(opnsense_client.search_one_to_one_rules),
|
||||
check=_o2o_check, title=lambda row: clean(row.get("description")), info=_o2o_info, build=_o2o_build,
|
||||
payload=one_to_one_deploy_service._build_payload, revision=one_to_one_policy_service.revision,
|
||||
kept=lambda row: _kept(row, ("categories",)),
|
||||
),
|
||||
"outbound_nat": Kind(
|
||||
key="outbound_nat", model=OutboundNatRule, deployment=OutboundNatDeployment, fk="outbound_nat_id",
|
||||
uuid_attr="opnsense_outbound_nat_uuid", fetch=_fetch_outbound,
|
||||
check=_snat_check, title=lambda row: clean(row.get("description")), info=_snat_info, build=_snat_build,
|
||||
payload=outbound_nat_deploy_service._build_payload, revision=outbound_nat_policy_service.revision,
|
||||
kept=lambda row: _kept(row, ("log", "staticnatport", "poolopts", "poolopts_sourcehashkey", "tag", "tagged",
|
||||
"nosync", "endpoint-independent", "categories")),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── engine ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def _managed_uuids(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> set[str]:
|
||||
column = getattr(kind.deployment, kind.uuid_attr)
|
||||
rows = await db.scalars(select(column).where(kind.deployment.host_id == host.id, column.is_not(None)))
|
||||
return set(rows.all())
|
||||
|
||||
|
||||
def _resolve_references(kind: Kind, entries: list[Entry], rows: list[dict], managed: set[str], ctx: dict) -> list[Entry]:
|
||||
"""Aliases may refer to other aliases by name. A reference to a live alias OPNexus does not manage
|
||||
(and that is not OPNsense's own) would be dangling in the central model, so the referring alias
|
||||
waits until its target is managed. Returns the entries in an order in which they can be adopted
|
||||
in ONE run (dependencies first); entries whose references can never be satisfied (target is a
|
||||
url alias, a user-defined external alias, ...) keep the reason `references_unmanaged_alias`."""
|
||||
live = {kind.name(r): r for r in rows}
|
||||
available = set(ctx["central_names"])
|
||||
available |= {n for n, r in live.items() if _alias_is_system(r) or clean(r.get("uuid")) in managed}
|
||||
|
||||
candidates = [e for e in entries if not e.reasons]
|
||||
for entry in candidates:
|
||||
missing = {ref for ref in kind.refs(entry.row) if ref in live and ref not in available}
|
||||
if missing:
|
||||
entry.reasons.append("references_unmanaged_alias")
|
||||
entry.details["references_unmanaged_alias"] = ", ".join(sorted(missing))
|
||||
|
||||
order: list[Entry] = []
|
||||
ready = set(available)
|
||||
progress = True
|
||||
while progress:
|
||||
progress = False
|
||||
for entry in candidates:
|
||||
if entry in order:
|
||||
continue
|
||||
if all(ref not in live or ref in ready for ref in kind.refs(entry.row)):
|
||||
order.append(entry)
|
||||
ready.add(kind.name(entry.row))
|
||||
progress = True
|
||||
for entry in order:
|
||||
entry.bulk = "references_unmanaged_alias" in entry.reasons
|
||||
return order
|
||||
|
||||
|
||||
async def _plan(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> tuple[list[Entry], list[Entry]]:
|
||||
"""(all unmanaged entries with their reasons, the ones adoptable in one run in a safe order)."""
|
||||
rows = await kind.fetch(host)
|
||||
managed = await _managed_uuids(db, kind, host)
|
||||
ctx = await kind.context(db, rows) if kind.context else {}
|
||||
entries = []
|
||||
for row in rows:
|
||||
if clean(row.get("uuid")) in managed:
|
||||
continue
|
||||
reasons, details = kind.check(row, ctx)
|
||||
entries.append(Entry(row, list(reasons), dict(details)))
|
||||
if kind.refs:
|
||||
order = _resolve_references(kind, entries, rows, managed, ctx)
|
||||
else:
|
||||
order = [e for e in entries if not e.reasons]
|
||||
return entries, order
|
||||
|
||||
|
||||
def _describe(kind: Kind, entry: Entry) -> dict:
|
||||
return {
|
||||
"uuid": entry.uuid,
|
||||
"title": kind.title(entry.row),
|
||||
"enabled": on(entry.row.get("enabled")) if "disabled" not in entry.row else not on(entry.row.get("disabled")),
|
||||
"adoptable": not entry.reasons,
|
||||
"bulk_adoptable": not entry.reasons or entry.bulk,
|
||||
"reasons": entry.reasons,
|
||||
"reason_details": entry.details,
|
||||
"kept": kind.kept(entry.row),
|
||||
"info": kind.info(entry.row),
|
||||
}
|
||||
|
||||
|
||||
async def list_unmanaged(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> list[dict]:
|
||||
kind = KINDS[kind_key]
|
||||
entries, _ = await _plan(db, kind, host)
|
||||
return [_describe(kind, e) for e in entries]
|
||||
|
||||
|
||||
def _create(db: AsyncSession, kind: Kind, host: OpnsenseHost, entry: Entry):
|
||||
obj = kind.model(**kind.build(entry.row), targets=[host], groups=[])
|
||||
db.add(obj)
|
||||
return obj
|
||||
|
||||
|
||||
async def _record(db: AsyncSession, kind: Kind, host: OpnsenseHost, obj, remote_uuid: str) -> None:
|
||||
await db.flush()
|
||||
kind.revision(db, obj, ADOPT_NOTE)
|
||||
db.add(kind.deployment(**{
|
||||
kind.fk: obj.id, "host_id": host.id, "status": "success", "message": ADOPT_MESSAGE,
|
||||
kind.uuid_attr: remote_uuid, "last_payload": kind.payload(obj), "previous_payload": None,
|
||||
}))
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid: str):
|
||||
kind = KINDS[kind_key]
|
||||
entries, _ = await _plan(db, kind, host)
|
||||
entry = next((e for e in entries if e.uuid == remote_uuid), None)
|
||||
if entry is None:
|
||||
# either gone from the host or already managed
|
||||
if remote_uuid in await _managed_uuids(db, kind, host):
|
||||
raise NotAdoptable(["already_managed"])
|
||||
raise NotFoundOnHost(remote_uuid)
|
||||
if entry.reasons:
|
||||
raise NotAdoptable(entry.reasons, entry.details)
|
||||
obj = _create(db, kind, host, entry)
|
||||
await _record(db, kind, host, obj, remote_uuid)
|
||||
return obj
|
||||
|
||||
|
||||
async def adopt_all(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> dict:
|
||||
"""Adopts everything adoptable in one transaction (the caller commits), nested aliases in
|
||||
dependency order. System objects are not reported; everything else that stays behind is, with reasons."""
|
||||
kind = KINDS[kind_key]
|
||||
entries, order = await _plan(db, kind, host)
|
||||
adopted = []
|
||||
for entry in order:
|
||||
obj = _create(db, kind, host, entry)
|
||||
await _record(db, kind, host, obj, entry.uuid)
|
||||
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id})
|
||||
done = {e.uuid for e in order}
|
||||
skipped = [
|
||||
{"uuid": e.uuid, "title": kind.title(e.row), "reasons": e.reasons, "reason_details": e.details}
|
||||
for e in entries if e.uuid not in done and e.reasons != ["system"]
|
||||
]
|
||||
return {"adopted": adopted, "skipped": skipped}
|
||||
@@ -50,7 +50,7 @@ async def get_settings(db: AsyncSession) -> OidcSettings:
|
||||
if row is None:
|
||||
row = OidcSettings(
|
||||
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
|
||||
scopes="openid profile email groups", groups_claim="groups",
|
||||
scopes="openid profile email", groups_claim="groups",
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
@@ -130,7 +130,7 @@ def _challenge(verifier: str) -> str:
|
||||
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
|
||||
|
||||
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
|
||||
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> tuple[str, str]:
|
||||
"""Remember a fresh login attempt and return the provider URL to send the browser to."""
|
||||
doc = await discovery(cfg.issuer)
|
||||
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(24), secrets.token_urlsafe(48)
|
||||
@@ -141,7 +141,7 @@ async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
|
||||
"response_type": "code", "client_id": cfg.client_id, "redirect_uri": redirect_uri, "scope": cfg.scopes,
|
||||
"state": state, "nonce": nonce, "code_challenge": _challenge(verifier), "code_challenge_method": "S256",
|
||||
})
|
||||
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}"
|
||||
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}", state
|
||||
|
||||
|
||||
async def _consume_state(db: AsyncSession, state: str) -> OidcLoginState:
|
||||
@@ -207,7 +207,8 @@ async def _verify_id_token(cfg: OidcSettings, doc: dict, id_token: str, nonce: s
|
||||
|
||||
def _as_groups(value) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [g for g in re.split(r"[,\s]+", value) if g]
|
||||
# Group names may contain spaces ("Network Admins"): a string claim is split on commas and line breaks only.
|
||||
return [g.strip() for g in re.split(r"[,\n]+", value) if g.strip()]
|
||||
if isinstance(value, list):
|
||||
return [str(g) for g in value]
|
||||
return []
|
||||
@@ -224,14 +225,31 @@ def role_for(cfg: OidcSettings, groups: list[str]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
||||
if cfg.groups_claim in claims:
|
||||
return _as_groups(claims[cfg.groups_claim])
|
||||
async def _userinfo(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||
"""The provider's userinfo answer for THIS identity ({} if there is none or it belongs to somebody else)."""
|
||||
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
|
||||
info = await _get_json(doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"})
|
||||
if info.get("sub") == claims.get("sub"): # a userinfo answer for someone else is worthless
|
||||
return _as_groups(info.get(cfg.groups_claim))
|
||||
return []
|
||||
return info
|
||||
return {}
|
||||
|
||||
|
||||
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
||||
if cfg.groups_claim in claims:
|
||||
return _as_groups(claims[cfg.groups_claim])
|
||||
return _as_groups((await _userinfo(doc, claims, access_token)).get(cfg.groups_claim))
|
||||
|
||||
|
||||
async def _with_display_name(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||
"""Some providers (Authelia) keep preferred_username/email out of the ID token: ask userinfo, else the user would be named
|
||||
after the opaque ``sub``. Best effort: a failing userinfo call must not block a login that already has its role."""
|
||||
if claims.get("preferred_username") or claims.get("email"):
|
||||
return claims
|
||||
try:
|
||||
info = await _userinfo(doc, claims, access_token)
|
||||
except OidcError:
|
||||
return claims
|
||||
return {**claims, **{k: info[k] for k in ("preferred_username", "email") if info.get(k)}}
|
||||
|
||||
|
||||
def _username(claims: dict) -> str:
|
||||
@@ -267,4 +285,4 @@ async def complete(db: AsyncSession, cfg: OidcSettings, code: str, state_value:
|
||||
role = role_for(cfg, await _groups(cfg, doc, claims, tokens.get("access_token")))
|
||||
if role is None:
|
||||
raise OidcError("no_role", "Kein zugeordnete Gruppe")
|
||||
return await provision(db, cfg, claims, role)
|
||||
return await provision(db, cfg, await _with_display_name(doc, claims, tokens.get("access_token")), role)
|
||||
@@ -145,6 +145,34 @@ async def get_interface_statistics(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/diagnostics/interface/getInterfaceStatistics")
|
||||
|
||||
|
||||
# Swap and CPU count, for the resource-threshold alert (1.24). Verified against core source
|
||||
# (Diagnostics/Api/SystemController::systemSwapAction -> configd "system show swapinfo") and live
|
||||
# (read-only GET, test-fw-3, OPNsense 26.7):
|
||||
# systemSwap -> {"swap": [{"device": "/dev/da0p3", "total": "8388608", "used": "0"}]} (KiB, strings;
|
||||
# an empty list when the firewall has no swap)
|
||||
# cpu_usage/getCPUType -> ["QEMU Virtual CPU version 2.5+ (6 cores, 6 threads)"]
|
||||
async def get_system_swap(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/diagnostics/system/systemSwap")
|
||||
|
||||
|
||||
async def get_cpu_type(host: OpnsenseHost) -> Any:
|
||||
return await _request(host, "GET", "/diagnostics/cpu_usage/getCPUType")
|
||||
|
||||
|
||||
# Gateway health (read-only). Routes\Api\GatewayController::statusAction runs configd "interface gateways status"
|
||||
# -> scripts/routes/gateway_status.php, which merges the configured gateways with dpinger's live numbers
|
||||
# (src/etc/inc/plugins.inc.d/dpinger.inc, dpinger_status()). Live-verified on test-fw-3 (26.7):
|
||||
# {"items": [{"name": "WAN_GW", "address": "203.0.113.1", "status": "none", "loss": "~", "delay": "~",
|
||||
# "stddev": "~", "monitor": "~", "status_translated": "Online"}, ...], "status": "ok"}
|
||||
# "status" is the machine-readable verdict: none (fine), down, force_down (switched off by the admin), delay,
|
||||
# loss, delay+loss (thresholds of the gateway itself), anything else = pending. loss/delay/stddev are "12.3 %" /
|
||||
# "4.5 ms" strings, or "~" while nothing is measured (e.g. monitoring disabled on that gateway). "status" is
|
||||
# "failed" with no items when the firewall has no gateways. searchGateway carries the same numbers but with a
|
||||
# translated status text ("Online"), so it is not used here.
|
||||
async def get_gateway_status(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/routes/gateway/status")
|
||||
|
||||
|
||||
async def toggle_carp_maintenance(host: OpnsenseHost) -> dict:
|
||||
"""Toggle persistent CARP maintenance mode.
|
||||
|
||||
@@ -391,6 +419,42 @@ async def get_pf_rule_statistics(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/diagnostics/firewall/pfStatistics/rules")
|
||||
|
||||
|
||||
# ── Rule analysis (read-only) ─────────────────────────────────────────────
|
||||
# Checked against opnsense/core master (2026-10-21) and live on OPNsense 26.7:
|
||||
# - Firewall/Api/FilterUtilController::ruleStatsAction runs configd `!filter rule stats` and
|
||||
# returns {"status": "ok", "stats": {"<label>": {pf_rules, evaluations, packets, bytes, states}}};
|
||||
# the label is the rule UUID (MVC), an md5 (automatic) or a 32-hex label (unmigrated legacy).
|
||||
# - Firewall/Api/AliasUtilController::findReferencesAction requires POST with "ip" (only hex,
|
||||
# ":", ".", "/", "_" accepted, else {"status": "Not an IP address!"}), runs configd
|
||||
# `filter find_table_references` (a pf table test over all aliases) and returns
|
||||
# {"status": "ok", "matches": ["<alias>", ...]}. It changes nothing; POST is only the
|
||||
# controller's calling convention.
|
||||
# - Firewall/Api/FilterController::getInterfaceListAction: {"floating"|"groups"|"interfaces"|"any":
|
||||
# {"label", "items": [{"value", "label", "type"}]}} (lo0 omitted).
|
||||
# - Firewall/Api/GroupController::searchItemAction -> searchBase("ifgroupentry"): rows with
|
||||
# ifname, members (comma separated identifiers), sequence; the built-in groups (enc0, openvpn,
|
||||
# wireguard) appear with their name as uuid and empty members.
|
||||
async def get_rule_stats(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/firewall/filter_util/ruleStats")
|
||||
|
||||
|
||||
async def find_alias_references(host: OpnsenseHost, ip: str) -> list[str] | None:
|
||||
"""Names of the aliases (pf tables) that contain ``ip`` right now; None when the firewall
|
||||
could not answer (callers then treat table membership as unknown)."""
|
||||
data = await _request(host, "POST", "/firewall/alias_util/findReferences", json={"ip": ip})
|
||||
if not isinstance(data, dict) or data.get("status") != "ok" or not isinstance(data.get("matches"), list):
|
||||
return None
|
||||
return [str(name) for name in data["matches"]]
|
||||
|
||||
|
||||
async def get_filter_interface_list(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/firewall/filter/get_interface_list")
|
||||
|
||||
|
||||
async def search_interface_groups(host: OpnsenseHost) -> dict:
|
||||
return await _request(host, "GET", "/firewall/group/searchItem", params={"rowCount": -1})
|
||||
|
||||
|
||||
async def get_firmware_status(host: OpnsenseHost) -> dict:
|
||||
# GET reads cached results. POST would trigger a synchronous probe.
|
||||
return await _request(host, "GET", "/core/firmware/status")
|
||||
|
||||
@@ -104,7 +104,8 @@ def parse_subnet(value: str) -> ipaddress.IPv4Network:
|
||||
net = ipaddress.ip_network(value.strip(), strict=True)
|
||||
except ValueError:
|
||||
raise OverlayError("subnet", "kein gültiges Netz") from None
|
||||
if not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29):
|
||||
if (not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29)
|
||||
or net.is_loopback or net.is_link_local or net.is_multicast or net.is_unspecified):
|
||||
raise OverlayError("subnet", "ein privates IPv4-Netz zwischen /16 und /29 ist nötig")
|
||||
return net
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ poll_all_hosts. v1 deliberately has no admin-editable poll interval (see
|
||||
config.py) — just a fixed constant, to keep this feature's first cut
|
||||
small.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
@@ -13,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.resource_sample import HostResourceSample
|
||||
from app.services import gateway_health_service
|
||||
from app.services import opnsense_client as client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
@@ -55,11 +58,59 @@ def _disk_used_pct(disk: dict) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _swap_used_pct(swap) -> float | None:
|
||||
"""systemSwap: {"swap": [{"device", "total", "used"}]} in KiB; None when there is no swap at all."""
|
||||
try:
|
||||
rows = swap.get("swap") or []
|
||||
total = sum(int(r["total"]) for r in rows)
|
||||
used = sum(int(r["used"]) for r in rows)
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return round(used / total * 100, 1) if total > 0 else None
|
||||
|
||||
|
||||
_CPU_THREADS = re.compile(r"(\d+)\s+threads?", re.IGNORECASE)
|
||||
_CPU_CORES = re.compile(r"(\d+)\s+cores?", re.IGNORECASE)
|
||||
|
||||
|
||||
def _cpu_threads(cpu_type) -> int | None:
|
||||
"""getCPUType: ["<model> (6 cores, 6 threads)"]."""
|
||||
text = " ".join(str(x) for x in cpu_type) if isinstance(cpu_type, list) else str(cpu_type or "")
|
||||
match = _CPU_THREADS.search(text) or _CPU_CORES.search(text)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _services_compact(listing) -> dict[str, int]:
|
||||
"""{"unbound": 1, "openvpn/<uuid>": 0}: 1 = running. Only services an admin can stop (not 'locked')."""
|
||||
result: dict[str, int] = {}
|
||||
for row in (listing.get("rows") if isinstance(listing, dict) else None) or []:
|
||||
if not isinstance(row, dict) or not row.get("name") or str(row.get("locked")) in ("1", "True", "true"):
|
||||
continue
|
||||
name, id_ = str(row["name"]), str(row.get("id") or row["name"])
|
||||
result[name if id_ == name else f"{name}/{id_}"] = 1 if str(row.get("running")) in ("1", "True", "true") else 0
|
||||
return result
|
||||
|
||||
|
||||
async def _optional(operation):
|
||||
"""The extra readings must never cost the base sample (older firmware, an endpoint that is missing)."""
|
||||
try:
|
||||
return await operation
|
||||
except Exception as exc:
|
||||
logger.debug("Optionale Messung fehlgeschlagen: %s", type(exc).__name__)
|
||||
return None
|
||||
|
||||
|
||||
async def sample_host(db: AsyncSession, host: OpnsenseHost) -> None:
|
||||
resources = await client.get_system_resources(host)
|
||||
system_time = await client.get_system_time(host)
|
||||
disk = await client.get_system_disk(host)
|
||||
interfaces = await client.get_interface_statistics(host)
|
||||
swap, cpu_type, gateways, services = await asyncio.gather(
|
||||
_optional(client.get_system_swap(host)),
|
||||
_optional(client.get_cpu_type(host)),
|
||||
_optional(gateway_health_service.get_gateway_health(host)),
|
||||
_optional(client.search_services(host)),
|
||||
)
|
||||
db.add(
|
||||
HostResourceSample(
|
||||
host_id=host.id,
|
||||
@@ -67,7 +118,14 @@ async def sample_host(db: AsyncSession, host: OpnsenseHost) -> None:
|
||||
mem_used_pct=_mem_used_pct(resources),
|
||||
load_avg_1m=_load_avg_1m(system_time),
|
||||
disk_used_pct=_disk_used_pct(disk),
|
||||
raw={"memory": resources.get("memory"), "disk": disk.get("devices"), "interfaces": interfaces.get("statistics")},
|
||||
raw={
|
||||
"memory": resources.get("memory"), "disk": disk.get("devices"), "interfaces": interfaces.get("statistics"),
|
||||
# None = not readable this round (the alert checks treat that as "unknown", not as "fine").
|
||||
"swap_used_pct": _swap_used_pct(swap) if swap is not None else None,
|
||||
"cpu_threads": _cpu_threads(cpu_type) if cpu_type is not None else None,
|
||||
"gateways": gateway_health_service.compact(gateways) if gateways is not None else None,
|
||||
"services": _services_compact(services) if services is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
"""The rollout run engine (1.25): moves runs forward, one small step per poll tick.
|
||||
|
||||
Ground rules (an unattended engine that triggers firmware updates is the riskiest thing in OPNexus):
|
||||
|
||||
* It never calls a firmware action itself. Each host step is an ordinary ScheduledMaintenance
|
||||
(run_at=now, origin="rollout", auto_verify=True) — locks, backup before the update, CARP handover,
|
||||
audit and restart safety all come from that existing path.
|
||||
* Never twice: a host gets a schedule only while its ``scheduled_maintenance_id`` is empty, written
|
||||
in the same transaction as the schedule itself, under the run's row lock.
|
||||
* Never in parallel: one host at a time per run, and only one active run at all; nodes of a cluster
|
||||
go BACKUP first, the MASTER only with the wave's CARP handover, and before every node of an
|
||||
already touched cluster the cluster is checked again.
|
||||
* When in doubt, stop: anything unexpected pauses the run (``pause_reason``) and notifies; nothing
|
||||
is retried on its own. A paused run only continues when a human resumes it, and resuming first
|
||||
checks the health of everything done so far.
|
||||
* Every step re-checks that the person responsible for the run may still roll out.
|
||||
* Hard limits: waves/hosts (rollout_service), soak time, preflight/host timeouts, run age.
|
||||
|
||||
Concurrency: a tick (1) does DB-only bookkeeping under the run's row lock, (2) decides with the
|
||||
lock released (remote reads may take seconds), and (3) applies the decision only if the run's
|
||||
``revision`` is still the one it decided on — a pause/abort in between, or a second backend, wins.
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.maintenance_window import MaintenanceWindow
|
||||
from app.models.rollout import RolloutPlan, RolloutRun, RolloutRunHost, RolloutRunWave
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.models.user import AdminUser
|
||||
from app.services import audit_service, rollout_health, rollout_service, window_service
|
||||
from app.services import opnsense_client as client
|
||||
from app.services.host_maintenance_service import ensure_no_maintenance, peers
|
||||
from app.services.host_status_service import get_statuses
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE = ("waiting_window", "preflight", "wave_running", "soaking", "gate", "awaiting_approval", "resuming", "paused")
|
||||
TERMINAL = ("succeeded", "cancelled")
|
||||
# Statuses in which the poll loop should tick quickly.
|
||||
BUSY = ("preflight", "wave_running", "gate", "resuming")
|
||||
HOST_OPEN = ("running", "verifying")
|
||||
|
||||
PREFLIGHT_CHECK_DELAY = timedelta(seconds=20) # after asking for an update check, before reading the result
|
||||
PREFLIGHT_TIMEOUT = timedelta(minutes=10)
|
||||
GATE_RECHECK = timedelta(minutes=2) # a failed gate is looked at once more (slow start after a reboot)
|
||||
BLOCKED_PATIENCE = timedelta(minutes=15) # someone else's maintenance in the cluster
|
||||
HOST_START_TIMEOUT = timedelta(minutes=10) # the schedule never fired
|
||||
HOST_MAX_DURATION = timedelta(hours=4) # backstop above the schedule's own timeouts
|
||||
MAX_RUN_AGE = timedelta(days=14)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _ts(value: str | None) -> datetime | None:
|
||||
return datetime.fromisoformat(value) if value else None
|
||||
|
||||
|
||||
def _note(code: str, **params) -> dict:
|
||||
return {"code": code, "params": params}
|
||||
|
||||
|
||||
# ── small helpers on a loaded run ─────────────────────────────────────────────
|
||||
|
||||
def wave_row(run: RolloutRun, index: int) -> RolloutRunWave:
|
||||
return next(w for w in run.waves if w.index == index)
|
||||
|
||||
|
||||
def wave_spec(run: RolloutRun, index: int) -> dict:
|
||||
return run.plan_snapshot["waves"][index]
|
||||
|
||||
|
||||
def hosts_of(run: RolloutRun, index: int) -> list[RolloutRunHost]:
|
||||
return sorted((h for h in run.hosts if h.wave_index == index), key=lambda h: (h.order, h.host_name))
|
||||
|
||||
|
||||
def _named(run: RolloutRun, failed: list[str]) -> list[str]:
|
||||
""""<host-id>:<check>" -> "<host name>:<check>" for pause details people read."""
|
||||
names = {str(h.host_id): h.host_name for h in run.hosts if h.host_id}
|
||||
return [f"{names.get(item.split(':', 1)[0], item.split(':', 1)[0])}:{item.split(':', 1)[1]}" for item in failed]
|
||||
|
||||
|
||||
def _progress(run: RolloutRun, **changes) -> None:
|
||||
run.progress = {**(run.progress or {}), **changes}
|
||||
|
||||
|
||||
async def _audit(run: RolloutRun, action: str, status: str, message: str | None = None, **details) -> None:
|
||||
token = audit_service.context.set({"actor": f"{run.started_by} (Rollout)"[:150], "id": str(run.id)})
|
||||
try:
|
||||
await audit_service.persist(kind="rollout", action=action, status=status, entity_id=str(run.id),
|
||||
entity_name=run.plan_name, message=message,
|
||||
details={"wave": run.current_wave, **details})
|
||||
finally:
|
||||
audit_service.context.reset(token)
|
||||
|
||||
|
||||
def _requeue(host: RolloutRunHost, code: str) -> None:
|
||||
"""Back to the queue: only for a host whose schedule provably never fired (cancelled under its row lock,
|
||||
or refused by fire_due as overdue). Clearing the schedule id lets a resumed run plan it once more."""
|
||||
host.status, host.scheduled_maintenance_id, host.started_at, host.detail = "queued", None, None, _note(code)
|
||||
|
||||
|
||||
async def requeue_if_pending(db: AsyncSession, host: RolloutRunHost) -> bool:
|
||||
"""Cancel a host's schedule that has not fired yet and put the host back in the queue. Locked like the
|
||||
router's abort: fire_due holds the row while firing, and a running action is never touched."""
|
||||
if host.status not in HOST_OPEN or host.scheduled_maintenance_id is None:
|
||||
return False
|
||||
sched = await db.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == host.scheduled_maintenance_id)
|
||||
.with_for_update().execution_options(populate_existing=True))
|
||||
if sched is None or sched.status != "pending":
|
||||
return False
|
||||
sched.status = "cancelled"
|
||||
sched.result = {"error": "Rollout angehalten, nicht ausgeführt", "not_fired": True}
|
||||
_requeue(host, "not_fired")
|
||||
return True
|
||||
|
||||
|
||||
def pause(run: RolloutRun, reason: str, **detail) -> None:
|
||||
if run.status != "paused":
|
||||
run.paused_from = run.status
|
||||
run.status = "paused"
|
||||
run.pause_reason = reason
|
||||
run.pause_detail = detail
|
||||
_progress(run, paused_at=_iso(_now()))
|
||||
|
||||
|
||||
async def may_roll_out(db: AsyncSession, username: str) -> bool:
|
||||
"""Does this account (still) have the right to roll out? Same rule as AuthMiddleware's is_rollout."""
|
||||
user = await db.scalar(select(AdminUser).where(AdminUser.username == username))
|
||||
if user is None:
|
||||
return False
|
||||
if user.role == "admin":
|
||||
return True
|
||||
if user.role == "operator":
|
||||
from app.routers import governance
|
||||
return (await governance.get_settings(db)).operators_may_deploy
|
||||
return False
|
||||
|
||||
|
||||
async def _window(db: AsyncSession, run: RolloutRun) -> tuple[bool, MaintenanceWindow | None]:
|
||||
"""(window configured, window). Configured but gone means: stop, never 'no time limit'."""
|
||||
window_id = run.plan_snapshot.get("window_id")
|
||||
if not window_id:
|
||||
return False, None
|
||||
return True, await db.get(MaintenanceWindow, window_id)
|
||||
|
||||
|
||||
async def _may_start_now(db: AsyncSession, run: RolloutRun) -> str | None:
|
||||
"""None if new host work may start now, else the reason ('window_closed' / 'window_missing')."""
|
||||
configured, window = await _window(db, run)
|
||||
if not configured:
|
||||
return None
|
||||
if window is None:
|
||||
return "window_missing"
|
||||
return None if window_service.startable(window, _now()) else "window_closed"
|
||||
|
||||
|
||||
async def _lock(db: AsyncSession, run_id, revision: int | None = None) -> RolloutRun | None:
|
||||
stmt = select(RolloutRun).where(RolloutRun.id == run_id)
|
||||
if revision is not None:
|
||||
stmt = stmt.where(RolloutRun.revision == revision)
|
||||
return await db.scalar(stmt.with_for_update().execution_options(populate_existing=True))
|
||||
|
||||
|
||||
# ── creating a run ────────────────────────────────────────────────────────────
|
||||
|
||||
async def active_run(db: AsyncSession) -> RolloutRun | None:
|
||||
return await db.scalar(select(RolloutRun).where(RolloutRun.status.in_(ACTIVE)).order_by(RolloutRun.created_at).limit(1))
|
||||
|
||||
|
||||
async def create_run(db: AsyncSession, plan: RolloutPlan, resolved: dict, *, started_by: str, trigger: str,
|
||||
occurrence: datetime | None = None) -> RolloutRun:
|
||||
"""Freeze the plan and its resolved hosts. The caller holds policy_lock and has checked the token."""
|
||||
now = _now()
|
||||
snapshot = {
|
||||
"plan_id": str(plan.id), "name": plan.name, "action": plan.action,
|
||||
"window_id": str(plan.window_id) if plan.window_id else None,
|
||||
"gate_checks": rollout_service.effective_checks(plan.gate_checks), "compliance_mode": plan.compliance_mode,
|
||||
"waves": [{k: w[k] for k in ("label", "soak_minutes", "carp_handover", "require_approval", "groups")} for w in resolved["waves"]],
|
||||
}
|
||||
run = RolloutRun(plan_id=plan.id, plan_name=plan.name, plan_snapshot=snapshot, action=plan.action, status="preflight",
|
||||
current_wave=0, started_by=started_by, trigger=trigger, window_occurrence=occurrence,
|
||||
progress={}, created_at=now, step_at=now, revision=0)
|
||||
run.waves = [RolloutRunWave(index=w["index"], label=w["label"], status="preflight" if w["index"] == 0 else "pending")
|
||||
for w in resolved["waves"]]
|
||||
run.hosts = [RolloutRunHost(wave_index=w["index"], host_id=uuid.UUID(h["id"]), host_name=h["name"], cluster_name=h["cluster_name"],
|
||||
order=i, status="queued")
|
||||
for w in resolved["waves"] for i, h in enumerate(w["hosts"])]
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
return run
|
||||
|
||||
|
||||
# ── the tick ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def advance_runs(db: AsyncSession) -> int:
|
||||
"""One step for every run. Returns how many runs want a quick next tick."""
|
||||
try:
|
||||
await auto_start(db)
|
||||
except Exception:
|
||||
logger.exception("Rollout: automatischer Start fehlgeschlagen")
|
||||
await db.rollback()
|
||||
ids = list((await db.scalars(select(RolloutRun.id).where(
|
||||
(RolloutRun.status.in_(ACTIVE)) | RolloutRun.hosts.any(RolloutRunHost.status.in_(HOST_OPEN))))).all())
|
||||
await db.commit()
|
||||
busy = 0
|
||||
for run_id in ids:
|
||||
try:
|
||||
await tick(run_id)
|
||||
except Exception as exc:
|
||||
logger.exception("Rollout %s: Schritt fehlgeschlagen", run_id)
|
||||
async with SessionLocal() as s:
|
||||
run = await _lock(s, run_id)
|
||||
if run is not None and run.status in ACTIVE and run.status != "paused":
|
||||
pause(run, "internal_error", error=type(exc).__name__)
|
||||
run.revision += 1
|
||||
await s.commit()
|
||||
await _audit(run, "pause", "paused", "Interner Fehler: Rollout angehalten", reason="internal_error")
|
||||
async with SessionLocal() as s:
|
||||
status = await s.scalar(select(RolloutRun.status).where(RolloutRun.id == run_id))
|
||||
busy += status in BUSY
|
||||
return busy
|
||||
|
||||
|
||||
async def tick(run_id) -> None:
|
||||
async with SessionLocal() as db:
|
||||
# (1) bookkeeping under the lock: what did the schedules do?
|
||||
run = await _lock(db, run_id)
|
||||
if run is None:
|
||||
return
|
||||
changed = await _sync_hosts(db, run)
|
||||
if (run.status in ACTIVE and run.status != "paused" and _now() - run.created_at > MAX_RUN_AGE
|
||||
and not any(h.status in HOST_OPEN for h in run.hosts)):
|
||||
pause(run, "run_too_old", days=MAX_RUN_AGE.days)
|
||||
changed = True
|
||||
if changed:
|
||||
run.revision += 1
|
||||
await db.commit()
|
||||
if changed and run.status == "paused" and run.pause_reason in ("host_failed", "host_timeout", "host_not_started", "run_too_old"):
|
||||
await _audit(run, "pause", "paused", reason=run.pause_reason, detail=run.pause_detail)
|
||||
if run.status not in ACTIVE or run.status in ("paused", "awaiting_approval"):
|
||||
return
|
||||
revision = run.revision
|
||||
# (2) decide, without holding the lock
|
||||
decision = await _HANDLERS[run.status](db, run)
|
||||
await db.commit()
|
||||
if decision is None:
|
||||
return
|
||||
# (3) apply only on the revision it was decided on
|
||||
run = await _lock(db, run_id, revision)
|
||||
if run is None:
|
||||
await db.commit()
|
||||
return
|
||||
events = await decision(db, run)
|
||||
run.revision += 1
|
||||
run.step_at = _now()
|
||||
await db.commit()
|
||||
for event in events or []:
|
||||
await _audit(run, *event[:2], **(event[2] if len(event) > 2 else {}))
|
||||
|
||||
|
||||
Decision = Callable[[AsyncSession, RolloutRun], Awaitable[list | None]]
|
||||
|
||||
|
||||
async def _sync_hosts(db: AsyncSession, run: RolloutRun) -> bool:
|
||||
"""Carry the outcome of each host's schedule over to the run. Returns True if anything changed."""
|
||||
changed = False
|
||||
now = _now()
|
||||
for host in run.hosts:
|
||||
if host.status not in HOST_OPEN:
|
||||
continue
|
||||
sched = await db.get(ScheduledMaintenance, host.scheduled_maintenance_id, populate_existing=True) \
|
||||
if host.scheduled_maintenance_id else None
|
||||
problem = None
|
||||
if sched is None:
|
||||
host.status, host.detail, host.finished_at = "failed", _note("schedule_missing"), now
|
||||
problem = ("host_failed", {"host": host.host_name, "error": "schedule_missing"})
|
||||
elif sched.status == "pending":
|
||||
if host.started_at and now - host.started_at > HOST_START_TIMEOUT:
|
||||
# Never leave it pending behind a paused run: it would fire whenever the poll loop comes back.
|
||||
if await requeue_if_pending(db, host):
|
||||
problem = ("host_not_started", {"host": host.host_name})
|
||||
changed = True
|
||||
elif (sched.result or {}).get("not_fired"):
|
||||
_requeue(host, "not_fired") # fire_due refused it as overdue: nothing happened on the firewall
|
||||
problem = ("host_not_started", {"host": host.host_name})
|
||||
changed = True
|
||||
elif sched.status == "running":
|
||||
want = "verifying" if sched.phase == "verify" else "running"
|
||||
if host.status != want:
|
||||
host.status = want
|
||||
changed = True
|
||||
if host.started_at and now - host.started_at > HOST_MAX_DURATION:
|
||||
problem = ("host_timeout", {"host": host.host_name})
|
||||
elif sched.status == "succeeded":
|
||||
result = sched.result or {}
|
||||
host.status, host.finished_at = "succeeded", now
|
||||
host.version_after = result.get("version_after")
|
||||
host.detail = _note("verified", version=result.get("version_after"), rebooted=bool(result.get("rebooted")))
|
||||
changed = True
|
||||
else: # failed / cancelled
|
||||
error = (sched.result or {}).get("error") or sched.status
|
||||
host.status, host.finished_at = "failed", now
|
||||
host.detail = _note("host_failed", error=error[:300])
|
||||
problem = ("host_failed", {"host": host.host_name, "error": error[:300]})
|
||||
if problem and run.status in ACTIVE and run.status != "paused":
|
||||
pause(run, problem[0], **problem[1])
|
||||
changed = True
|
||||
elif problem and host.status == "failed":
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
# ── handlers: one per run status; each returns an apply-function or None ──────
|
||||
|
||||
async def _actor_problem(db: AsyncSession, run: RolloutRun) -> Decision | None:
|
||||
if await may_roll_out(db, run.started_by):
|
||||
return None
|
||||
|
||||
async def apply(db, run):
|
||||
pause(run, "actor_invalid", user=run.started_by)
|
||||
return [("pause", "paused", {"reason": "actor_invalid"})]
|
||||
return apply
|
||||
|
||||
|
||||
def _to_waiting(reason: str) -> Decision:
|
||||
async def apply(db, run):
|
||||
if reason == "window_missing":
|
||||
pause(run, "window_missing")
|
||||
return [("pause", "paused", {"reason": reason})]
|
||||
run.paused_from = run.status
|
||||
run.status = "waiting_window"
|
||||
return [("waiting_window", "waiting")]
|
||||
return apply
|
||||
|
||||
|
||||
async def _handle_waiting_window(db: AsyncSession, run: RolloutRun) -> Decision | None:
|
||||
reason = await _may_start_now(db, run)
|
||||
if reason == "window_missing":
|
||||
return _to_waiting(reason)
|
||||
if reason:
|
||||
return None
|
||||
|
||||
async def apply(db, run):
|
||||
_progress(run, resume_recheck_at=None, resume_attempts=0)
|
||||
run.status = "resuming" # the gate of everything done so far is checked again first
|
||||
return [("window_open", "resuming")]
|
||||
return apply
|
||||
|
||||
|
||||
async def _handle_resuming(db: AsyncSession, run: RolloutRun) -> Decision | None:
|
||||
recheck = _ts((run.progress or {}).get("resume_recheck_at"))
|
||||
if recheck and _now() < recheck:
|
||||
return None
|
||||
done = [h for h in run.hosts if h.status == "succeeded" and h.host_id]
|
||||
gate = {"hosts": {}, "verdict": "ok"}
|
||||
if done:
|
||||
hosts = [h for h in [await db.get(OpnsenseHost, d.host_id) for d in done] if h is not None]
|
||||
baselines = {}
|
||||
for w in run.waves:
|
||||
baselines.update((w.baseline or {}))
|
||||
gate = await rollout_health.evaluate(db, hosts, baselines, target_version=run.target_version,
|
||||
checks=run.plan_snapshot.get("gate_checks"),
|
||||
compliance_mode=run.plan_snapshot.get("compliance_mode", "warn"))
|
||||
|
||||
async def apply(db, run):
|
||||
if gate["verdict"] == "fail":
|
||||
attempts = (run.progress or {}).get("resume_attempts", 0) + 1
|
||||
_progress(run, resume_gate=gate, resume_attempts=attempts)
|
||||
if attempts < 2:
|
||||
_progress(run, resume_recheck_at=_iso(_now() + GATE_RECHECK))
|
||||
return [("resume_gate", "recheck", {"failed": rollout_health.failed_checks(gate)})]
|
||||
run.paused_from = run.paused_from or "preflight"
|
||||
_progress(run, resume_recheck_at=None)
|
||||
run.status = "paused"
|
||||
run.pause_reason, run.pause_detail = "gate_failed", {"failed": _named(run, rollout_health.failed_checks(gate)), "on_resume": True}
|
||||
return [("resume_gate", "failed", {"failed": rollout_health.failed_checks(gate)})]
|
||||
_progress(run, resume_gate=gate, resume_recheck_at=None, resume_attempts=0)
|
||||
target = run.paused_from if run.paused_from in ("preflight", "wave_running", "soaking", "gate") else "preflight"
|
||||
if target == "gate":
|
||||
wave_row(run, run.current_wave).gate_attempts = 0
|
||||
_progress(run, gate_recheck_at=None)
|
||||
if target == "preflight":
|
||||
_progress(run, preflight_started=None, preflight_requested_at=None)
|
||||
run.status, run.paused_from, run.pause_reason, run.pause_detail = target, None, None, None
|
||||
return [("resume_gate", "passed", {"continue_with": target})]
|
||||
return apply
|
||||
|
||||
|
||||
def target_of(status: dict, action: str) -> str | None:
|
||||
"""The version a firmware action will install, from core/firmware/status (read after a fresh check).
|
||||
|
||||
Live finding (test-fw-3, 26.7.1_1, 2026-10-21): ``product.product_latest`` said 26.7.2 while the
|
||||
``opnsense`` core package was offered as 26.7.7 — and the package is what gets installed. So the
|
||||
core package's new version comes first; product_latest is only a fallback."""
|
||||
if action == "major_update":
|
||||
return status.get("upgrade_major_version") or None
|
||||
packages = status.get("all_packages") if isinstance(status.get("all_packages"), dict) else {}
|
||||
core = packages.get("opnsense") if isinstance(packages.get("opnsense"), dict) else None
|
||||
if core and core.get("new") and core.get("new") != "N/A":
|
||||
return str(core["new"])
|
||||
for row in status.get("upgrade_packages") or []:
|
||||
if isinstance(row, dict) and row.get("name") == "opnsense" and row.get("new_version"):
|
||||
return str(row["new_version"])
|
||||
product = status.get("product") if isinstance(status.get("product"), dict) else {}
|
||||
return product.get("product_latest") or status.get("product_latest") or None
|
||||
|
||||
|
||||
async def _handle_preflight(db: AsyncSession, run: RolloutRun) -> Decision | None:
|
||||
if problem := await _actor_problem(db, run):
|
||||
return problem
|
||||
reason = await _may_start_now(db, run)
|
||||
if reason:
|
||||
return _to_waiting(reason)
|
||||
index = run.current_wave
|
||||
prog = run.progress or {}
|
||||
started = _ts(prog.get("preflight_started")) or _now()
|
||||
requested = _ts(prog.get("preflight_requested_at"))
|
||||
candidates = [h for h in hosts_of(run, index) if h.status in ("queued", "blocked")]
|
||||
now = _now()
|
||||
|
||||
# Which hosts are usable at all?
|
||||
usable: dict = {}
|
||||
skips: dict = {}
|
||||
for rh in candidates:
|
||||
host = await db.get(OpnsenseHost, rh.host_id) if rh.host_id else None
|
||||
if host is None:
|
||||
skips[rh.id] = _note("host_deleted")
|
||||
elif not host.enabled:
|
||||
skips[rh.id] = _note("host_disabled")
|
||||
else:
|
||||
usable[rh.id] = host
|
||||
|
||||
if requested is None:
|
||||
# Step 1: ask every usable host to look for updates (a firmware *check*, changes nothing installed).
|
||||
asked = []
|
||||
for rh_id, host in usable.items():
|
||||
try:
|
||||
await ensure_no_maintenance(db, [host])
|
||||
if (await client.get_firmware_running(host)).get("status") == "ready":
|
||||
await client.check_firmware_updates(host)
|
||||
asked.append(str(rh_id))
|
||||
except (client.OpnsenseAPIError, HTTPException):
|
||||
pass # judged in step 2 (and blocked after the timeout)
|
||||
|
||||
async def apply(db, run):
|
||||
_progress(run, preflight_started=_iso(started), preflight_requested_at=_iso(_now()), preflight_asked=asked)
|
||||
w = wave_row(run, index)
|
||||
w.status = "preflight"
|
||||
w.started_at = w.started_at or _now()
|
||||
return [("preflight", "started", {"hosts": len(usable)})]
|
||||
return apply
|
||||
if now - requested < PREFLIGHT_CHECK_DELAY:
|
||||
return None
|
||||
|
||||
# Step 2: read what each host offers.
|
||||
offers: dict = {}
|
||||
waiting: dict = {}
|
||||
for rh_id, host in usable.items():
|
||||
try:
|
||||
await ensure_no_maintenance(db, [host])
|
||||
except HTTPException:
|
||||
waiting[rh_id] = _note("locked")
|
||||
continue
|
||||
try:
|
||||
running = (await client.get_firmware_running(host)).get("status")
|
||||
if running != "ready":
|
||||
waiting[rh_id] = _note("firmware_busy")
|
||||
continue
|
||||
status = await client.get_firmware_status(host)
|
||||
except client.OpnsenseAPIError:
|
||||
waiting[rh_id] = _note("offline")
|
||||
continue
|
||||
fw = status.get("status")
|
||||
wanted = "upgrade" if run.action == "major_update" else "update"
|
||||
if fw == wanted:
|
||||
offers[rh_id] = target_of(status, run.action)
|
||||
elif fw in ("none", "update", "upgrade"):
|
||||
skips[rh_id] = _note("no_update_offered", status=fw)
|
||||
else:
|
||||
waiting[rh_id] = _note("firmware_status", status=str(fw)[:40])
|
||||
|
||||
if waiting:
|
||||
if now - started < PREFLIGHT_TIMEOUT:
|
||||
return None
|
||||
|
||||
async def apply_blocked(db, run):
|
||||
names = []
|
||||
for rh in hosts_of(run, index):
|
||||
if rh.id in waiting:
|
||||
rh.status, rh.detail = "blocked", waiting[rh.id]
|
||||
names.append(rh.host_name)
|
||||
pause(run, "preflight_blocked", hosts=", ".join(names))
|
||||
_progress(run, preflight_started=None, preflight_requested_at=None)
|
||||
return [("pause", "paused", {"reason": "preflight_blocked", "hosts": names})]
|
||||
return apply_blocked
|
||||
|
||||
targets = {t for t in offers.values()}
|
||||
expected = run.target_version
|
||||
mismatch = None
|
||||
if None in targets:
|
||||
mismatch = {"offered": "?"}
|
||||
elif expected is None and len(targets) > 1:
|
||||
mismatch = {"offered": ", ".join(sorted(targets))}
|
||||
elif expected is not None and targets - {expected}:
|
||||
mismatch = {"expected": expected, "offered": ", ".join(sorted(targets - {expected}))}
|
||||
if mismatch:
|
||||
async def apply_mismatch(db, run):
|
||||
# OPNsense always installs the newest offered version; a later ring must not get one the earlier ring never ran.
|
||||
pause(run, "target_version_changed", **mismatch)
|
||||
_progress(run, preflight_started=None, preflight_requested_at=None)
|
||||
return [("pause", "paused", {"reason": "target_version_changed", **mismatch})]
|
||||
return apply_mismatch
|
||||
|
||||
# Baselines and order (BACKUP before MASTER inside a cluster).
|
||||
baselines = {}
|
||||
versions = {}
|
||||
for rh_id in offers:
|
||||
host = usable[rh_id]
|
||||
baselines[str(host.id)] = await rollout_health.baseline(db, host)
|
||||
versions[rh_id] = baselines[str(host.id)].get("version")
|
||||
roles = await get_statuses([usable[i] for i in offers])
|
||||
rank = {"BACKUP": 0, None: 1, "MASTER": 2}
|
||||
|
||||
def order_key(rh_id):
|
||||
host = usable[rh_id]
|
||||
role = roles.get(host.id, {}).get("carp_status") if host.cluster_name else None
|
||||
return (1 if host.cluster_name else 0, host.cluster_name or "", rank.get(role, 1), host.name)
|
||||
ordered = sorted(offers, key=order_key)
|
||||
target = next(iter(targets)) if targets else expected
|
||||
|
||||
async def apply_ready(db, run):
|
||||
for rh in hosts_of(run, index):
|
||||
if rh.id in skips:
|
||||
rh.status, rh.detail = "skipped", skips[rh.id]
|
||||
elif rh.id in offers:
|
||||
rh.status, rh.detail = "queued", None
|
||||
rh.order = ordered.index(rh.id)
|
||||
rh.version_before = versions.get(rh.id)
|
||||
w = wave_row(run, index)
|
||||
w.baseline = baselines
|
||||
if run.target_version is None and target:
|
||||
run.target_version = target
|
||||
_progress(run, preflight_started=None, preflight_requested_at=None, blocked_since=None, cluster_recheck_at=None)
|
||||
if not offers:
|
||||
w.status, w.finished_at = "skipped", _now()
|
||||
events = [("wave", "skipped", {"wave": index})]
|
||||
events += _next_wave(run)
|
||||
return events
|
||||
w.status = "running"
|
||||
run.status = "wave_running"
|
||||
return [("wave", "started", {"wave": index, "target_version": run.target_version,
|
||||
"hosts": [usable[i].name for i in ordered], "skipped": len(skips)})]
|
||||
return apply_ready
|
||||
|
||||
|
||||
def _next_wave(run: RolloutRun) -> list:
|
||||
index = run.current_wave + 1
|
||||
if index >= len(run.waves):
|
||||
run.status, run.finished_at = "succeeded", _now()
|
||||
return [("finish", "succeeded")]
|
||||
run.current_wave = index
|
||||
w = wave_row(run, index)
|
||||
if wave_spec(run, index).get("require_approval"):
|
||||
w.status = run.status = "awaiting_approval"
|
||||
return [("wave", "awaiting_approval", {"wave": index})]
|
||||
w.status, run.status = "preflight", "preflight"
|
||||
return []
|
||||
|
||||
|
||||
async def _handle_wave_running(db: AsyncSession, run: RolloutRun) -> Decision | None:
|
||||
if any(h.status in HOST_OPEN for h in run.hosts):
|
||||
return None # strictly one host at a time
|
||||
index = run.current_wave
|
||||
queued = [h for h in hosts_of(run, index) if h.status == "queued"]
|
||||
if not queued:
|
||||
soak = int(wave_spec(run, index).get("soak_minutes") or 0)
|
||||
|
||||
async def apply_soak(db, run):
|
||||
w = wave_row(run, index)
|
||||
w.status, w.soak_until = "soaking", _now() + timedelta(minutes=soak)
|
||||
run.status = "soaking"
|
||||
return [("wave", "soaking", {"wave": index, "minutes": soak})]
|
||||
return apply_soak
|
||||
if problem := await _actor_problem(db, run):
|
||||
return problem
|
||||
reason = await _may_start_now(db, run)
|
||||
if reason:
|
||||
return _to_waiting(reason)
|
||||
nxt = queued[0]
|
||||
host = await db.get(OpnsenseHost, nxt.host_id) if nxt.host_id else None
|
||||
if host is None or not host.enabled:
|
||||
async def apply_skip(db, run):
|
||||
rh = next(h for h in run.hosts if h.id == nxt.id)
|
||||
rh.status, rh.detail = "skipped", _note("host_deleted" if host is None else "host_disabled")
|
||||
return [("host", "skipped", {"host": nxt.host_name})]
|
||||
return apply_skip
|
||||
spec = wave_spec(run, index)
|
||||
prog = run.progress or {}
|
||||
|
||||
if host.cluster_name:
|
||||
fleet = await peers(db, host)
|
||||
statuses = await get_statuses(fleet)
|
||||
done_ids = {h.host_id for h in run.hosts if h.status == "succeeded"}
|
||||
done_peers = [p for p in fleet if p.id in done_ids and p.id != host.id]
|
||||
baselines = {}
|
||||
for w in run.waves:
|
||||
baselines.update(w.baseline or {})
|
||||
# The cluster as a whole must be healthy before a node is touched; nodes done earlier in this run
|
||||
# must pass their checks again (BACKUP updated -> now it may carry the traffic for the MASTER).
|
||||
gate = await rollout_health.evaluate(
|
||||
db, done_peers + [host], baselines, target_version=run.target_version,
|
||||
checks={**(run.plan_snapshot.get("gate_checks") or {}), "carp": True, "compliance": False})
|
||||
# The node itself is not updated yet: only its reachability, the cluster and the locks count for it.
|
||||
failed = [f"{hid}:{c}" for hid, row in gate["hosts"].items() for c, r in row.items()
|
||||
if r["result"] == "fail" and not (hid == str(host.id) and c in ("version", "gateways", "services"))]
|
||||
if failed:
|
||||
recheck = _ts(prog.get("cluster_recheck_at"))
|
||||
if recheck is None:
|
||||
async def apply_recheck(db, run):
|
||||
_progress(run, cluster_recheck_at=_iso(_now() + GATE_RECHECK), cluster_gate=gate)
|
||||
return [("cluster_gate", "recheck", {"host": host.name, "failed": failed})]
|
||||
return apply_recheck
|
||||
if _now() < recheck:
|
||||
return None
|
||||
|
||||
async def apply_cluster_fail(db, run):
|
||||
pause(run, "cluster_gate_failed", cluster=host.cluster_name, host=host.name, failed=_named(run, failed))
|
||||
_progress(run, cluster_recheck_at=None, cluster_gate=gate)
|
||||
return [("cluster_gate", "failed", {"host": host.name, "failed": failed})]
|
||||
return apply_cluster_fail
|
||||
if statuses.get(host.id, {}).get("carp_status") == "MASTER" and not spec.get("carp_handover"):
|
||||
async def apply_master(db, run):
|
||||
pause(run, "master_needs_handover", host=host.name, cluster=host.cluster_name)
|
||||
return [("pause", "paused", {"reason": "master_needs_handover", "host": host.name})]
|
||||
return apply_master
|
||||
|
||||
try:
|
||||
await ensure_no_maintenance(db, [host])
|
||||
except HTTPException:
|
||||
since = _ts(prog.get("blocked_since"))
|
||||
if since is None:
|
||||
async def apply_blocked_start(db, run):
|
||||
_progress(run, blocked_since=_iso(_now()))
|
||||
return [("host", "waiting_for_lock", {"host": host.name})]
|
||||
return apply_blocked_start
|
||||
if _now() - since < BLOCKED_PATIENCE:
|
||||
return None
|
||||
|
||||
async def apply_blocked(db, run):
|
||||
pause(run, "blocked_by_maintenance", host=host.name)
|
||||
_progress(run, blocked_since=None)
|
||||
return [("pause", "paused", {"reason": "blocked_by_maintenance", "host": host.name})]
|
||||
return apply_blocked
|
||||
|
||||
async def apply_start(db, run):
|
||||
await policy_lock(db)
|
||||
rh = next(h for h in run.hosts if h.id == nxt.id)
|
||||
# Idempotency guard: a host that already has a schedule never gets a second one.
|
||||
if rh.status != "queued" or rh.scheduled_maintenance_id is not None:
|
||||
return []
|
||||
now = _now()
|
||||
sched = ScheduledMaintenance(
|
||||
host_id=host.id, host_name=host.name, action=run.action, run_at=now, status="pending",
|
||||
actor=run.started_by, created_at=now, carp_handover=bool(spec.get("carp_handover") and host.cluster_name),
|
||||
auto_verify=True, origin="rollout",
|
||||
progress={"rollout": {"run_id": str(run.id), "wave": index, "plan": run.plan_name}},
|
||||
)
|
||||
db.add(sched)
|
||||
await db.flush()
|
||||
rh.scheduled_maintenance_id, rh.status, rh.started_at = sched.id, "running", now
|
||||
_progress(run, blocked_since=None, cluster_recheck_at=None)
|
||||
return [("host", "started", {"host": host.name, "schedule_id": str(sched.id), "handover": sched.carp_handover})]
|
||||
return apply_start
|
||||
|
||||
|
||||
async def _handle_soaking(db: AsyncSession, run: RolloutRun) -> Decision | None:
|
||||
w = wave_row(run, run.current_wave)
|
||||
if w.soak_until and _now() < w.soak_until:
|
||||
return None
|
||||
|
||||
async def apply(db, run):
|
||||
w = wave_row(run, run.current_wave)
|
||||
w.status, w.gate_attempts = "gate", 0
|
||||
run.status = "gate"
|
||||
_progress(run, gate_recheck_at=None)
|
||||
return []
|
||||
return apply
|
||||
|
||||
|
||||
async def _handle_gate(db: AsyncSession, run: RolloutRun) -> Decision | None:
|
||||
recheck = _ts((run.progress or {}).get("gate_recheck_at"))
|
||||
if recheck and _now() < recheck:
|
||||
return None
|
||||
index = run.current_wave
|
||||
w = wave_row(run, index)
|
||||
done = [h for h in hosts_of(run, index) if h.status == "succeeded" and h.host_id]
|
||||
hosts = [h for h in [await db.get(OpnsenseHost, d.host_id) for d in done] if h is not None]
|
||||
gate = await rollout_health.evaluate(db, hosts, w.baseline or {}, target_version=run.target_version,
|
||||
checks=run.plan_snapshot.get("gate_checks"),
|
||||
compliance_mode=run.plan_snapshot.get("compliance_mode", "warn"))
|
||||
|
||||
async def apply(db, run):
|
||||
w = wave_row(run, index)
|
||||
w.gate_result = gate
|
||||
w.gate_attempts = (w.gate_attempts or 0) + 1
|
||||
failed = rollout_health.failed_checks(gate)
|
||||
events = [("gate", gate["verdict"], {"wave": index, "attempt": w.gate_attempts, "failed": failed, "result": gate})]
|
||||
if gate["verdict"] == "fail":
|
||||
if w.gate_attempts < 2:
|
||||
_progress(run, gate_recheck_at=_iso(_now() + GATE_RECHECK))
|
||||
return events
|
||||
_progress(run, gate_recheck_at=None)
|
||||
pause(run, "gate_failed", wave=w.label, failed=_named(run, failed))
|
||||
return events + [("pause", "paused", {"reason": "gate_failed"})]
|
||||
w.status, w.finished_at = "passed", _now()
|
||||
_progress(run, gate_recheck_at=None)
|
||||
return events + _next_wave(run)
|
||||
return apply
|
||||
|
||||
|
||||
_HANDLERS = {
|
||||
"waiting_window": _handle_waiting_window,
|
||||
"resuming": _handle_resuming,
|
||||
"preflight": _handle_preflight,
|
||||
"wave_running": _handle_wave_running,
|
||||
"soaking": _handle_soaking,
|
||||
"gate": _handle_gate,
|
||||
}
|
||||
|
||||
|
||||
# ── automatic start at a window occurrence ────────────────────────────────────
|
||||
|
||||
async def auto_start(db: AsyncSession) -> int:
|
||||
started = 0
|
||||
plans = (await db.scalars(select(RolloutPlan).where(
|
||||
RolloutPlan.auto_start.is_(True), RolloutPlan.activated_at.is_not(None), RolloutPlan.window_id.is_not(None)))).all()
|
||||
for plan in plans:
|
||||
window = await db.get(MaintenanceWindow, plan.window_id)
|
||||
occ = window_service.startable(window, _now()) if window else None
|
||||
if occ is None:
|
||||
continue
|
||||
if await db.scalar(select(RolloutRun.id).where(RolloutRun.plan_id == plan.id, RolloutRun.window_occurrence == occ.start)):
|
||||
continue
|
||||
if (plan.auto_start_error or {}).get("occurrence") == _iso(occ.start):
|
||||
continue # already reported for this occurrence
|
||||
await policy_lock(db)
|
||||
plan = await rollout_service.load_plan(db, plan.id)
|
||||
if await db.scalar(select(RolloutRun.id).where(RolloutRun.plan_id == plan.id, RolloutRun.window_occurrence == occ.start)):
|
||||
continue # another backend started it while this one waited for the lock (not a refusal)
|
||||
resolved = await rollout_service.resolve(db, plan)
|
||||
problem = None
|
||||
if plan.action != "update":
|
||||
problem = _note("auto_start_update_only")
|
||||
elif plan.activated_fingerprint != rollout_service.fingerprint(plan, resolved):
|
||||
problem = _note("plan_changed") # someone changed the plan, its groups or its window since activation
|
||||
elif not await may_roll_out(db, plan.activated_by or ""):
|
||||
problem = _note("actor_invalid", user=plan.activated_by)
|
||||
elif resolved["errors"]:
|
||||
problem = _note("plan_invalid", reason=resolved["errors"][0]["code"])
|
||||
elif (other := await active_run(db)) is not None:
|
||||
problem = _note("other_run_active", plan=other.plan_name)
|
||||
if problem:
|
||||
plan.auto_start_error = {"occurrence": _iso(occ.start), "at": _iso(_now()), **problem}
|
||||
await db.commit()
|
||||
token = audit_service.context.set({"actor": "system:rollout", "id": str(plan.id)})
|
||||
try:
|
||||
await audit_service.persist(kind="rollout", action="auto_start", status="refused", entity_id=str(plan.id),
|
||||
entity_name=plan.name, details=plan.auto_start_error)
|
||||
finally:
|
||||
audit_service.context.reset(token)
|
||||
continue
|
||||
try:
|
||||
run = await create_run(db, plan, resolved, started_by=plan.activated_by, trigger="window", occurrence=occ.start)
|
||||
plan.auto_start_error = None
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback() # another backend started it for this occurrence
|
||||
continue
|
||||
started += 1
|
||||
await _audit(run, "start", "started", trigger="window", occurrence=_iso(occ.start),
|
||||
hosts=sum(len(w["hosts"]) for w in resolved["waves"]))
|
||||
await db.commit()
|
||||
return started
|
||||
|
||||
|
||||
# ── human actions (called by the router under policy_lock) ────────────────────
|
||||
|
||||
async def count_active(db: AsyncSession) -> int:
|
||||
return await db.scalar(select(func.count()).select_from(RolloutRun).where(RolloutRun.status.in_(ACTIVE))) or 0
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Health gate of a firmware rollout (1.25): is a host, after its update, as healthy as before?
|
||||
|
||||
``baseline(db, host)`` is taken right before a wave starts, ``evaluate(...)`` after the soak time
|
||||
(and before each node of an already touched cluster, and on resume). Every check of every host
|
||||
yields ``{"result": ok|warn|fail|skip, "code": <machine code>, "params": {...}}``; the UI translates
|
||||
the code (rollouts.gate.code.*), so no German prose travels through here.
|
||||
|
||||
What the checks look at (all read-only on the firewall, except that the compliance check downloads
|
||||
the configuration like the 6-hourly security check does):
|
||||
|
||||
* online — enabled, the API answers, firmware job idle (``running == ready``).
|
||||
* version — the installed version is the rollout's target version.
|
||||
* carp — per cluster: every node online, exactly one MASTER per VHID, no node in CARP
|
||||
maintenance mode, no CARP auto-revert timer running.
|
||||
* gateways — gateways that were up in the baseline are up again; a gateway that was already bad
|
||||
before only warns (1.24 ``get_gateway_health``).
|
||||
* services — services that ran in the baseline run again.
|
||||
* locks — no open maintenance operation, restore or unconfirmed deployment in the host/cluster.
|
||||
* compliance — security score not lower than before: warn (default) or fail. A weak signal: an
|
||||
update may legitimately change defaults.
|
||||
|
||||
A reading that cannot be taken is never "ok": unreadable now but readable in the baseline fails,
|
||||
unreadable in both is "skip" (nothing to compare; e.g. an API key without that permission).
|
||||
"""
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.carp_maintenance_window import CarpMaintenanceWindow
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import compliance_service, gateway_health_service, opnsense_client as client
|
||||
from app.services.host_maintenance_service import _shared_change_blockers, peers
|
||||
from app.services.host_status_service import get_statuses
|
||||
from app.services.resource_monitor_service import _services_compact
|
||||
|
||||
CHECKS = ("online", "version", "carp", "gateways", "services", "locks", "compliance")
|
||||
DEFAULT_CHECKS = {"online": True, "version": True, "carp": True, "gateways": True, "services": True, "locks": True,
|
||||
"compliance": False}
|
||||
ALWAYS_ON = ("online", "version", "locks") # a rollout without these would be blind
|
||||
_RANK = {"skip": 0, "ok": 0, "warn": 1, "fail": 2}
|
||||
|
||||
|
||||
def _r(result: str, code: str, **params) -> dict:
|
||||
return {"result": result, "code": code, "params": params}
|
||||
|
||||
|
||||
async def _optional(coro):
|
||||
try:
|
||||
return await coro
|
||||
except client.OpnsenseAPIError:
|
||||
return None
|
||||
|
||||
|
||||
async def _gateways(host) -> dict | None:
|
||||
gws = await _optional(gateway_health_service.get_gateway_health(host))
|
||||
return None if gws is None else {g.name: g.state for g in gws}
|
||||
|
||||
|
||||
async def _services(host) -> dict | None:
|
||||
listing = await _optional(client.search_services(host))
|
||||
return None if listing is None else _services_compact(listing)
|
||||
|
||||
|
||||
async def baseline(db: AsyncSession, host: OpnsenseHost) -> dict:
|
||||
info, gateways, services = await asyncio.gather(
|
||||
_optional(client.get_firmware_info(host)), _gateways(host), _services(host))
|
||||
score = await db.scalar(select(ComplianceResult.score).where(ComplianceResult.host_id == host.id))
|
||||
return {
|
||||
"version": (info or {}).get("product_version"),
|
||||
"gateways": gateways,
|
||||
"services": services,
|
||||
"compliance_score": score,
|
||||
}
|
||||
|
||||
|
||||
def _check_gateways(before: dict | None, now: dict | None) -> dict:
|
||||
if now is None:
|
||||
return _r("skip", "unreadable_both") if before is None else _r("fail", "unreadable")
|
||||
if before is None:
|
||||
bad = sorted(n for n, s in now.items() if s == "offline")
|
||||
return _r("warn", "gateway_down", names=", ".join(bad)) if bad else _r("ok", "no_baseline")
|
||||
down = sorted(n for n, s in before.items() if s in ("online", "unknown") and now.get(n) == "offline")
|
||||
gone = sorted(n for n in before if n not in now)
|
||||
degraded = sorted(n for n, s in now.items() if s == "degraded" and before.get(n) != "degraded")
|
||||
still_bad = sorted(n for n, s in before.items() if s in ("offline", "degraded") and now.get(n) in ("offline", "degraded"))
|
||||
# "unknown" (not monitored) going to "offline" is a real change too: monitoring just started and reports down.
|
||||
if down:
|
||||
return _r("fail", "gateway_down", names=", ".join(down))
|
||||
if gone:
|
||||
return _r("warn", "gateway_missing", names=", ".join(gone))
|
||||
if degraded:
|
||||
return _r("warn", "gateway_degraded", names=", ".join(degraded))
|
||||
if still_bad:
|
||||
return _r("warn", "gateway_bad_before", names=", ".join(still_bad))
|
||||
return _r("ok", "ok")
|
||||
|
||||
|
||||
def _check_services(before: dict | None, now: dict | None) -> dict:
|
||||
if now is None:
|
||||
return _r("skip", "unreadable_both") if before is None else _r("fail", "unreadable")
|
||||
if before is None:
|
||||
return _r("ok", "no_baseline")
|
||||
stopped = sorted(name.split("/")[0] for name, running in before.items() if running == 1 and now.get(name) != 1)
|
||||
if stopped:
|
||||
return _r("fail", "service_stopped", names=", ".join(dict.fromkeys(stopped)))
|
||||
return _r("ok", "ok")
|
||||
|
||||
|
||||
async def _check_carp(db: AsyncSession, fleet: list[OpnsenseHost], statuses: dict) -> dict:
|
||||
offline = sorted(h.name for h in fleet if h.enabled and statuses.get(h.id, {}).get("status") != "online")
|
||||
if offline:
|
||||
return _r("fail", "node_offline", names=", ".join(offline))
|
||||
online = [h for h in fleet if statuses.get(h.id, {}).get("status") == "online"]
|
||||
demoted = sorted(h.name for h in online if statuses[h.id].get("carp_maintenance_mode"))
|
||||
if demoted:
|
||||
return _r("fail", "maintenance_mode", names=", ".join(demoted))
|
||||
timers = (await db.scalars(select(CarpMaintenanceWindow.host_name).where(
|
||||
CarpMaintenanceWindow.host_id.in_([h.id for h in fleet]), CarpMaintenanceWindow.status == "active"))).all()
|
||||
if timers:
|
||||
return _r("fail", "auto_revert_active", names=", ".join(sorted(timers)))
|
||||
masters: dict[str, int] = defaultdict(int)
|
||||
broken: set[str] = set()
|
||||
for h in online:
|
||||
for vhid, state in (statuses[h.id].get("carp_vips") or {}).items():
|
||||
masters[vhid] += state == "MASTER"
|
||||
if state not in ("MASTER", "BACKUP"):
|
||||
broken.add(vhid)
|
||||
wrong = sorted(set(v for v, n in masters.items() if n != 1) | broken, key=lambda v: (len(v), v))
|
||||
if wrong:
|
||||
return _r("fail", "vhid_roles", vhids=", ".join(wrong))
|
||||
if not masters and not any(statuses[h.id].get("carp_status") == "MASTER" for h in online):
|
||||
return _r("fail", "no_master")
|
||||
return _r("ok", "ok")
|
||||
|
||||
|
||||
async def evaluate(db: AsyncSession, hosts: list[OpnsenseHost], baselines: dict, *, target_version: str | None,
|
||||
checks: dict | None = None, compliance_mode: str = "warn") -> dict:
|
||||
"""{"hosts": {host_id: {check: result}}, "verdict": ok|warn|fail}. ``baselines`` is keyed by str(host.id)."""
|
||||
enabled = {**DEFAULT_CHECKS, **(checks or {})}
|
||||
for name in ALWAYS_ON:
|
||||
enabled[name] = True
|
||||
results: dict[str, dict] = {}
|
||||
fleets: dict[str, list[OpnsenseHost]] = {}
|
||||
for host in hosts:
|
||||
fleets[str(host.id)] = await peers(db, host) if host.cluster_name else [host]
|
||||
every = {h.id: h for fleet in fleets.values() for h in fleet}
|
||||
statuses = await get_statuses(list(every.values()))
|
||||
carp_cache: dict[str, dict] = {}
|
||||
for host in hosts:
|
||||
hid = str(host.id)
|
||||
before = baselines.get(hid) or {}
|
||||
row: dict[str, dict] = {}
|
||||
st = statuses.get(host.id, {})
|
||||
running = await _optional(client.get_firmware_running(host)) if st.get("status") == "online" else None
|
||||
if not host.enabled:
|
||||
row["online"] = _r("fail", "disabled")
|
||||
elif st.get("status") != "online":
|
||||
row["online"] = _r("fail", "offline")
|
||||
elif (running or {}).get("status") != "ready":
|
||||
row["online"] = _r("fail", "firmware_busy")
|
||||
else:
|
||||
row["online"] = _r("ok", "ok")
|
||||
reachable = row["online"]["result"] == "ok"
|
||||
|
||||
if enabled["version"]:
|
||||
info = await _optional(client.get_firmware_info(host)) if reachable else None
|
||||
version = (info or {}).get("product_version")
|
||||
if not target_version:
|
||||
row["version"] = _r("skip", "no_target")
|
||||
elif version is None:
|
||||
row["version"] = _r("fail", "unreadable")
|
||||
elif version != target_version:
|
||||
row["version"] = _r("fail", "version_mismatch", expected=target_version, actual=version)
|
||||
else:
|
||||
row["version"] = _r("ok", "ok", actual=version)
|
||||
|
||||
if enabled["carp"]:
|
||||
if not host.cluster_name:
|
||||
row["carp"] = _r("skip", "no_cluster")
|
||||
else:
|
||||
if host.cluster_name not in carp_cache:
|
||||
carp_cache[host.cluster_name] = await _check_carp(db, fleets[hid], statuses)
|
||||
row["carp"] = carp_cache[host.cluster_name]
|
||||
|
||||
if enabled["gateways"]:
|
||||
row["gateways"] = _check_gateways(before.get("gateways"), await _gateways(host) if reachable else None)
|
||||
if enabled["services"]:
|
||||
row["services"] = _check_services(before.get("services"), await _services(host) if reachable else None)
|
||||
|
||||
blockers = await _shared_change_blockers(db, fleets[hid])
|
||||
row["locks"] = _r("fail", "open_locks", count=len(blockers)) if blockers else _r("ok", "ok")
|
||||
|
||||
if enabled["compliance"]:
|
||||
base_score = before.get("compliance_score")
|
||||
if base_score is None:
|
||||
row["compliance"] = _r("skip", "no_baseline")
|
||||
elif not reachable:
|
||||
row["compliance"] = _r("fail", "unreadable")
|
||||
else:
|
||||
fresh = await compliance_service.check_host(db, host)
|
||||
if fresh.score is None:
|
||||
row["compliance"] = _r("warn", "unreadable")
|
||||
elif fresh.score < base_score:
|
||||
row["compliance"] = _r("fail" if compliance_mode == "fail" else "warn", "score_lower",
|
||||
before=base_score, after=fresh.score)
|
||||
else:
|
||||
row["compliance"] = _r("ok", "ok", before=base_score, after=fresh.score)
|
||||
results[hid] = row
|
||||
worst = max((_RANK[c["result"]] for row in results.values() for c in row.values()), default=0)
|
||||
return {"hosts": results, "verdict": ("ok", "warn", "fail")[worst]}
|
||||
|
||||
|
||||
def failed_checks(gate: dict) -> list[str]:
|
||||
"""Flat "host-id:check" list of everything that failed (for notifications and the audit trail)."""
|
||||
return [f"{hid}:{name}" for hid, row in (gate.get("hosts") or {}).items() for name, c in row.items() if c["result"] == "fail"]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Rollout plans: resolving waves to hosts, validation, preview token / activation fingerprint.
|
||||
|
||||
The run engine (rollout_engine.py) builds on ``resolve``: what a preview shows is exactly what a run
|
||||
freezes when it starts.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.maintenance_window import MaintenanceWindow
|
||||
from app.models.policy import DeviceGroup
|
||||
from app.models.rollout import RolloutPlan
|
||||
from app.services import rollout_health, window_service
|
||||
|
||||
# Hard limits: an unattended engine that fires firmware updates must stay small and explainable.
|
||||
MAX_WAVES = 10
|
||||
MAX_HOSTS = 100
|
||||
MAX_SOAK_MINUTES = 24 * 60
|
||||
|
||||
|
||||
def _issue(code: str, **params) -> dict:
|
||||
return {"code": code, "params": params}
|
||||
|
||||
|
||||
async def resolve(db: AsyncSession, plan: RolloutPlan) -> dict:
|
||||
"""Waves with their hosts (a host in several waves belongs to the earliest), errors that block a
|
||||
start/activation, and warnings."""
|
||||
errors: list[dict] = []
|
||||
warnings: list[dict] = []
|
||||
seen: dict = {}
|
||||
duplicates: set[str] = set()
|
||||
waves = []
|
||||
group_ids = {g.id for w in plan.waves for g in w.groups}
|
||||
groups = {g.id: g for g in (await db.scalars(
|
||||
select(DeviceGroup).where(DeviceGroup.id.in_(group_ids)).options(selectinload(DeviceGroup.hosts)))).all()} if group_ids else {}
|
||||
for index, wave in enumerate(plan.waves):
|
||||
hosts: list[OpnsenseHost] = []
|
||||
for group in sorted(wave.groups, key=lambda g: g.name):
|
||||
for host in sorted(groups[group.id].hosts, key=lambda h: h.name):
|
||||
if host.id in seen:
|
||||
if seen[host.id] != index:
|
||||
duplicates.add(host.name)
|
||||
continue
|
||||
seen[host.id] = index
|
||||
hosts.append(host)
|
||||
if not hosts:
|
||||
warnings.append(_issue("empty_wave", wave=wave.label))
|
||||
clusters = sorted({h.cluster_name for h in hosts if h.cluster_name})
|
||||
if clusters and not wave.carp_handover:
|
||||
warnings.append(_issue("master_needs_handover", wave=wave.label, clusters=", ".join(clusters)))
|
||||
waves.append({
|
||||
"index": index, "label": wave.label, "soak_minutes": wave.soak_minutes, "carp_handover": wave.carp_handover,
|
||||
"require_approval": wave.require_approval, "groups": sorted(g.name for g in wave.groups),
|
||||
"hosts": [{"id": str(h.id), "name": h.name, "cluster_name": h.cluster_name, "enabled": h.enabled} for h in hosts],
|
||||
})
|
||||
if duplicates:
|
||||
warnings.append(_issue("duplicate_hosts", names=", ".join(sorted(duplicates))))
|
||||
all_hosts = [h for w in waves for h in w["hosts"]]
|
||||
disabled = sorted(h["name"] for h in all_hosts if not h["enabled"])
|
||||
if disabled:
|
||||
warnings.append(_issue("disabled_hosts", names=", ".join(disabled)))
|
||||
if not plan.waves:
|
||||
errors.append(_issue("no_waves"))
|
||||
elif not all_hosts:
|
||||
errors.append(_issue("no_hosts"))
|
||||
if len(plan.waves) > MAX_WAVES:
|
||||
errors.append(_issue("too_many_waves", limit=MAX_WAVES))
|
||||
if len(all_hosts) > MAX_HOSTS:
|
||||
errors.append(_issue("too_many_hosts", limit=MAX_HOSTS))
|
||||
|
||||
# Clusters: where are their nodes?
|
||||
cluster_names = sorted({h["cluster_name"] for h in all_hosts if h["cluster_name"]})
|
||||
for cluster in cluster_names:
|
||||
members = (await db.scalars(select(OpnsenseHost.id).where(OpnsenseHost.cluster_name == cluster))).all()
|
||||
in_plan = {h["id"]: w["index"] for w in waves for h in w["hosts"] if h["cluster_name"] == cluster}
|
||||
partial = len(in_plan) < len(members)
|
||||
split = len(set(in_plan.values())) > 1
|
||||
if plan.action == "major_update" and (partial or split):
|
||||
# Never two major releases side by side in one cluster longer than one wave.
|
||||
errors.append(_issue("cluster_split", cluster=cluster))
|
||||
elif partial:
|
||||
warnings.append(_issue("cluster_partial", cluster=cluster))
|
||||
elif split:
|
||||
warnings.append(_issue("mixed_versions", cluster=cluster, wave=waves[max(in_plan.values())]["label"]))
|
||||
|
||||
window = await db.get(MaintenanceWindow, plan.window_id) if plan.window_id else None
|
||||
if plan.auto_start and plan.action != "update":
|
||||
errors.append(_issue("auto_start_update_only"))
|
||||
if plan.auto_start and window is None:
|
||||
errors.append(_issue("auto_start_needs_window"))
|
||||
if window is None:
|
||||
warnings.append(_issue("no_window"))
|
||||
elif not window.enabled:
|
||||
warnings.append(_issue("window_disabled"))
|
||||
now = datetime.now(timezone.utc)
|
||||
return {
|
||||
"waves": waves, "errors": errors, "warnings": warnings,
|
||||
"window": {"id": str(window.id), "name": window.name, "definition": window_service.definition(window),
|
||||
"next": [o.as_dict() for o in window_service.occurrences(window, now, 3)] if window.enabled else []}
|
||||
if window else None,
|
||||
}
|
||||
|
||||
|
||||
def definition(plan: RolloutPlan) -> dict:
|
||||
return {
|
||||
"id": str(plan.id), "name": plan.name, "action": plan.action, "window_id": str(plan.window_id) if plan.window_id else None,
|
||||
"auto_start": plan.auto_start, "gate_checks": effective_checks(plan.gate_checks), "compliance_mode": plan.compliance_mode,
|
||||
"waves": [{"position": w.position, "label": w.label, "soak_minutes": w.soak_minutes, "carp_handover": w.carp_handover,
|
||||
"require_approval": w.require_approval, "group_ids": sorted(str(g.id) for g in w.groups)} for w in plan.waves],
|
||||
}
|
||||
|
||||
|
||||
def effective_checks(checks: dict | None) -> dict:
|
||||
merged = {**rollout_health.DEFAULT_CHECKS, **{k: bool(v) for k, v in (checks or {}).items() if k in rollout_health.CHECKS}}
|
||||
for name in rollout_health.ALWAYS_ON:
|
||||
merged[name] = True
|
||||
return merged
|
||||
|
||||
|
||||
def fingerprint(plan: RolloutPlan, resolved: dict) -> str:
|
||||
"""Covers the plan, every resolved host (id, name, cluster, enabled) and the window definition: a change
|
||||
to any of them invalidates a preview and stops an activated automatic start."""
|
||||
state = {
|
||||
"plan": definition(plan),
|
||||
"hosts": [[w["index"], h["id"], h["name"], h["cluster_name"], h["enabled"]] for w in resolved["waves"] for h in w["hosts"]],
|
||||
"window": (resolved.get("window") or {}).get("definition"),
|
||||
}
|
||||
return hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
||||
|
||||
|
||||
async def load_plan(db: AsyncSession, plan_id) -> RolloutPlan | None:
|
||||
return await db.scalar(
|
||||
select(RolloutPlan).where(RolloutPlan.id == plan_id).execution_options(populate_existing=True))
|
||||
@@ -20,26 +20,17 @@ 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.adopt_common import NotAdoptable, NotFoundOnHost, clean, on # noqa: F401 (NotAdoptable is re-exported for routers/rules.py)
|
||||
from app.services.policy_service import revision
|
||||
from app.services.rule_deploy_service import _build_payload
|
||||
|
||||
|
||||
class RuleNotFoundOnHost(Exception):
|
||||
class RuleNotFoundOnHost(NotFoundOnHost):
|
||||
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()
|
||||
_on = on
|
||||
_clean = clean
|
||||
|
||||
|
||||
def reasons_not_adoptable(row: dict, managed_uuids: set[str]) -> list[str]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Rule analysis (1.26): which firewall rule decides a packet, with honest confidence."""
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Everything about one firewall the analysis needs, fetched read-only and cached briefly.
|
||||
|
||||
Sources (see the evidence block in opnsense_client.py): ``firewall/filter/searchRule`` (all
|
||||
rules incl. automatic ones, with ``sort_order``), ``diagnostics/firewall/pfStatistics/rules``
|
||||
(what pf has loaded), ``interfaces/overview/interfacesInfo`` (identifier, device, addresses
|
||||
incl. CARP VIPs, gateways), ``firewall/group/searchItem`` (interface groups),
|
||||
``firewall/alias/searchItem`` (alias definitions), ``firewall/d_nat/searchRule`` (port
|
||||
forwards, only to warn about NAT) and, per queried IP, ``firewall/alias_util/findReferences``.
|
||||
Optional sources may fail; the analysis then lowers its confidence instead of guessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import opnsense_client
|
||||
from app.services.rule_analysis import pf_ruleset
|
||||
from app.services.rule_analysis.model import IPAddr, IPNet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_SECONDS = 60
|
||||
# Built-in interface groups whose members are the matching dynamic devices.
|
||||
BUILTIN_GROUP_DEVICES = {"wireguard": ("wg",), "openvpn": ("ovpns", "ovpnc"), "enc0": ("enc0",)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class IfInfo:
|
||||
identifier: str
|
||||
device: str
|
||||
description: str
|
||||
nets: list[IPNet] = field(default_factory=list)
|
||||
addrs: list[IPAddr] = field(default_factory=list) # primary addresses
|
||||
vips: list[IPAddr] = field(default_factory=list) # CARP / alias VIPs on this interface
|
||||
has_gateway: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
rows: list[dict]
|
||||
pf: pf_ruleset.PfRuleset | None
|
||||
interfaces: dict[str, IfInfo]
|
||||
groups: dict[str, set[str]] | None # name -> member identifiers; None = unknown
|
||||
aliases: dict[str, dict]
|
||||
port_forwards: list[dict] | None
|
||||
refs: dict[str, list[str] | None] = field(default_factory=dict)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
loaded_at: float = 0.0
|
||||
|
||||
@property
|
||||
def self_addrs(self) -> list[IPAddr]:
|
||||
addrs = [a for i in self.interfaces.values() for a in (*i.addrs, *i.vips)]
|
||||
return addrs + [ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")]
|
||||
|
||||
def by_device(self, device: str) -> IfInfo | None:
|
||||
return next((i for i in self.interfaces.values() if i.device == device), None)
|
||||
|
||||
def by_label(self, label: str) -> IfInfo | None:
|
||||
lowered = label.strip().lower()
|
||||
return self.interfaces.get(lowered) or next(
|
||||
(i for i in self.interfaces.values() if i.description.lower() == lowered), None)
|
||||
|
||||
|
||||
def _interfaces(info: dict) -> dict[str, IfInfo]:
|
||||
result: dict[str, IfInfo] = {}
|
||||
for row in (info or {}).get("rows", []) or []:
|
||||
identifier = str(row.get("identifier") or "").strip()
|
||||
if not identifier:
|
||||
continue
|
||||
item = IfInfo(identifier=identifier, device=str(row.get("device") or ""),
|
||||
description=str(row.get("description") or identifier),
|
||||
has_gateway=bool(row.get("gateways")) or "default" in (row.get("routes") or []))
|
||||
for entry in [*(row.get("ipv4") or []), *(row.get("ipv6") or [])]:
|
||||
try:
|
||||
iface = ipaddress.ip_interface(str(entry.get("ipaddr")).split("%")[0])
|
||||
except ValueError:
|
||||
continue
|
||||
(item.vips if entry.get("vhid") or entry.get("vip") else item.addrs).append(iface.ip)
|
||||
if iface.network not in item.nets:
|
||||
item.nets.append(iface.network)
|
||||
result[identifier] = item
|
||||
return result
|
||||
|
||||
|
||||
def _groups(data: dict | None, interfaces: dict[str, IfInfo]) -> dict[str, set[str]] | None:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("rows"), list):
|
||||
return None
|
||||
groups: dict[str, set[str]] = {}
|
||||
for row in data["rows"]:
|
||||
name = str(row.get("ifname") or "")
|
||||
members = {m.strip() for m in str(row.get("members") or "").split(",") if m.strip()}
|
||||
prefixes = BUILTIN_GROUP_DEVICES.get(name)
|
||||
if prefixes:
|
||||
members |= {i.identifier for i in interfaces.values() if i.device.startswith(prefixes)}
|
||||
if name:
|
||||
groups[name] = members
|
||||
return groups
|
||||
|
||||
|
||||
async def _optional(coro, note: str, notes: list[str]):
|
||||
try:
|
||||
return await coro
|
||||
except Exception as exc: # noqa: BLE001 - optional source: degrade, never fail the analysis
|
||||
logger.info("rule analysis: optional source failed (%s): %s", note, exc)
|
||||
notes.append(note)
|
||||
return None
|
||||
|
||||
|
||||
_CACHE: dict[str, Context] = {}
|
||||
|
||||
|
||||
async def load_context(host: OpnsenseHost, *, use_cache: bool = True) -> Context:
|
||||
key = str(host.id)
|
||||
cached = _CACHE.get(key)
|
||||
if use_cache and cached and time.monotonic() - cached.loaded_at < CACHE_SECONDS:
|
||||
return cached
|
||||
notes: list[str] = []
|
||||
rules, info = await asyncio.gather(
|
||||
opnsense_client.search_rules(host, all_rows=True), opnsense_client.get_interfaces_info(host))
|
||||
stats, groups, aliases, forwards = await asyncio.gather(
|
||||
_optional(opnsense_client.get_pf_rule_statistics(host), "PF_RULESET_UNAVAILABLE", notes),
|
||||
_optional(opnsense_client.search_interface_groups(host), "GROUPS_UNAVAILABLE", notes),
|
||||
_optional(opnsense_client._request(host, "GET", "/firewall/alias/searchItem", params={"rowCount": -1}),
|
||||
"ALIASES_UNAVAILABLE", notes),
|
||||
_optional(opnsense_client.search_port_forward_rules(host), "NAT_UNAVAILABLE", notes),
|
||||
)
|
||||
interfaces = _interfaces(info)
|
||||
pf = pf_ruleset.parse(stats)
|
||||
if stats is not None and pf is None:
|
||||
notes.append("PF_RULESET_UNAVAILABLE")
|
||||
ctx = Context(
|
||||
rows=list((rules or {}).get("rows") or []), pf=pf, interfaces=interfaces,
|
||||
groups=_groups(groups, interfaces),
|
||||
aliases={str(a.get("name")): a for a in ((aliases or {}).get("rows") or []) if a.get("name")},
|
||||
port_forwards=None if forwards is None else list(forwards.get("rows") or []),
|
||||
notes=notes, loaded_at=time.monotonic(),
|
||||
)
|
||||
_CACHE[key] = ctx
|
||||
return ctx
|
||||
|
||||
|
||||
async def ensure_refs(host: OpnsenseHost, ctx: Context, ips: list[str]) -> None:
|
||||
"""Ask pf which tables contain each IP (cached in the context)."""
|
||||
for ip in ips:
|
||||
if ip in ctx.refs:
|
||||
continue
|
||||
try:
|
||||
ctx.refs[ip] = await opnsense_client.find_alias_references(host, ip)
|
||||
except Exception as exc: # noqa: BLE001 - table membership becomes "unknown"
|
||||
logger.info("rule analysis: findReferences failed: %s", exc)
|
||||
ctx.refs[ip] = None
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
_CACHE.clear()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Which rule decides a packet, following pf semantics.
|
||||
|
||||
pf walks the loaded ruleset top to bottom; the *last* matching rule wins unless a matching
|
||||
rule is ``quick``, which ends the walk. OPNsense orders rules by ``sort_order`` (system,
|
||||
floating, groups, interfaces). The analysis looks at the first packet of a new connection.
|
||||
|
||||
Confidence:
|
||||
- ``rule_confidence`` is "sure" only when no rule that could still change the outcome
|
||||
matched with an unknown result;
|
||||
- ``confidence`` (the verdict) is "sure" when every such unknown rule has the same action as
|
||||
the deciding rule (then the verdict holds either way), "unknown" otherwise;
|
||||
- notes about guesses (interface guessed from the address, NAT nearby, pf ruleset missing,
|
||||
TCP flags, rate limits) lower a "sure" to "probable".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.services.rule_analysis import resolve
|
||||
from app.services.rule_analysis.context import Context
|
||||
from app.services.rule_analysis.model import IPAddr, NormRule, Outcome, t_and, tri_str
|
||||
from app.services.rule_analysis.normalize import norm_proto, normalize
|
||||
|
||||
SURE, PROBABLE, UNKNOWN = "sure", "probable", "unknown"
|
||||
_RANK = {SURE: 2, PROBABLE: 1, UNKNOWN: 0}
|
||||
DOWNGRADING_NOTES = {
|
||||
"IFACE_GUESSED", "EGRESS_GUESSED", "NAT_NEARBY", "TCP_FLAGS", "RATE_LIMIT", "ORDER_GUESSED",
|
||||
"ORDER_MISMATCH", "PF_RULESET_UNAVAILABLE", "NAT_UNAVAILABLE", "QUICK_UNKNOWN",
|
||||
}
|
||||
_PORT_PROTOS = {"6", "17"}
|
||||
_ICMP_PROTOS = {"1", "58"}
|
||||
ICMP_NAMES = {
|
||||
4: {"0": "echorep", "3": "unreach", "4": "squench", "5": "redir", "8": "echoreq", "9": "routeradv",
|
||||
"10": "routersol", "11": "timex", "12": "paramprob", "13": "timereq", "14": "timerep"},
|
||||
6: {"1": "unreach", "2": "toobig", "3": "timex", "4": "paramprob", "128": "echoreq", "129": "echorep",
|
||||
"133": "routersol", "134": "routeradv", "135": "neighbrsol", "136": "neighbradv", "137": "redir"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Query:
|
||||
src: IPAddr
|
||||
dst: IPAddr
|
||||
proto: str # normalized ("6", "17", "1", "58", other number or name)
|
||||
proto_label: str
|
||||
sport: int | None = None
|
||||
dport: int | None = None
|
||||
icmptype: str | None = None
|
||||
iface: str | None = None # None = guess from the source address
|
||||
direction: str = "in"
|
||||
egress: str | None = "none" # "none" = only the first leg, None = guess, else identifier
|
||||
|
||||
@property
|
||||
def af(self) -> int:
|
||||
return self.src.version
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegResult:
|
||||
direction: str
|
||||
iface: str | None
|
||||
iface_source: str
|
||||
action: str | None
|
||||
decisive: NormRule | None
|
||||
confidence: str
|
||||
rule_confidence: str
|
||||
notes: list[str] = field(default_factory=list)
|
||||
unknowns: list[str] = field(default_factory=list)
|
||||
steps: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _min_conf(*values: str) -> str:
|
||||
return min(values, key=lambda v: _RANK[v])
|
||||
|
||||
|
||||
def _max_conf(*values: str) -> str:
|
||||
return max(values, key=lambda v: _RANK[v])
|
||||
|
||||
|
||||
def _icmp_name(q: Query) -> str | None:
|
||||
if q.icmptype is None:
|
||||
return None
|
||||
return ICMP_NAMES[q.af].get(q.icmptype, q.icmptype)
|
||||
|
||||
|
||||
def rule_fields(r: NormRule, q: Query, direction: str, iface: str, ctx: Context,
|
||||
tags: set[str], first_leg_iface: str | None) -> dict[str, Outcome]:
|
||||
fields: dict[str, Outcome] = {
|
||||
"interface": resolve.iface_match(r.interfaces, r.if_not, iface, ctx),
|
||||
"direction": Outcome(r.direction in ("any", direction)),
|
||||
"family": Outcome(q.af in r.af),
|
||||
"protocol": Outcome(True if r.protos is None else q.proto in r.protos),
|
||||
"source": resolve.endpoint_match(r.src, q.src, ctx),
|
||||
"destination": resolve.endpoint_match(r.dst, q.dst, ctx),
|
||||
}
|
||||
if q.proto in _PORT_PROTOS:
|
||||
fields["source_port"] = resolve.port_match(r.sport, q.sport, ctx, ephemeral_default=True)
|
||||
fields["destination_port"] = resolve.port_match(r.dport, q.dport, ctx)
|
||||
elif r.sport or r.dport:
|
||||
fields["ports"] = Outcome(False) # pf only generates port rules for tcp/udp
|
||||
if r.icmptypes is not None and q.proto in _ICMP_PROTOS:
|
||||
name = _icmp_name(q)
|
||||
fields["icmp_type"] = Outcome(None, ["ICMP_TYPE_NOT_GIVEN"]) if name is None else \
|
||||
Outcome(name in r.icmptypes or q.icmptype in r.icmptypes)
|
||||
if r.tagged:
|
||||
fields["tagged"] = Outcome(True) if r.tagged in tags else Outcome(None, ["TAGGED"])
|
||||
if r.received_on:
|
||||
if direction == "in":
|
||||
fields["received_on"] = Outcome(r.received_on == iface)
|
||||
elif first_leg_iface:
|
||||
fields["received_on"] = Outcome(r.received_on == first_leg_iface)
|
||||
else:
|
||||
fields["received_on"] = Outcome(None, ["RECEIVED_ON"])
|
||||
if r.action is None:
|
||||
fields["action"] = Outcome(None, ["ACTION_UNKNOWN"])
|
||||
return fields
|
||||
|
||||
|
||||
def _step(r: NormRule, fields: dict[str, Outcome], result, effect: str) -> dict:
|
||||
return {
|
||||
"rule": r.summary(), "result": tri_str(result), "effect": effect,
|
||||
"fields": {k: tri_str(v.value) for k, v in fields.items()},
|
||||
"reasons": sorted({reason for v in fields.values() if v.value is None for reason in v.reasons}),
|
||||
}
|
||||
|
||||
|
||||
def eval_leg(rules: list[NormRule], q: Query, ctx: Context, direction: str, iface: str,
|
||||
first_leg_iface: str | None = None) -> LegResult:
|
||||
steps: list[dict] = []
|
||||
candidate: NormRule | None = None
|
||||
possible: list[tuple[NormRule, bool]] = [] # (rule, came after the current candidate)
|
||||
tags: set[str] = set()
|
||||
final: NormRule | None = None
|
||||
for r in rules:
|
||||
if not r.enabled:
|
||||
steps.append({"rule": r.summary(), "result": "false", "effect": "disabled", "fields": {}, "reasons": []})
|
||||
continue
|
||||
if r.loaded is False:
|
||||
steps.append({"rule": r.summary(), "result": "false", "effect": "not_loaded", "fields": {}, "reasons": []})
|
||||
continue
|
||||
fields = rule_fields(r, q, direction, iface, ctx, tags, first_leg_iface)
|
||||
result = t_and(*(f.value for f in fields.values()))
|
||||
if result is False:
|
||||
steps.append(_step(r, fields, result, "no_match"))
|
||||
continue
|
||||
if result is None or r.quick is None:
|
||||
possible.append((r, True))
|
||||
steps.append(_step(r, fields, result, "possible"))
|
||||
continue
|
||||
if r.quick:
|
||||
final = r
|
||||
for s in steps:
|
||||
if s["effect"] == "candidate":
|
||||
s["effect"] = "overridden"
|
||||
steps.append(_step(r, fields, result, "final"))
|
||||
break
|
||||
if candidate is not None:
|
||||
for s in steps:
|
||||
if s["effect"] == "candidate":
|
||||
s["effect"] = "overridden"
|
||||
candidate = r
|
||||
possible = [(p, False) for p, _ in possible]
|
||||
if r.tag:
|
||||
tags.add(r.tag)
|
||||
steps.append(_step(r, fields, result, "candidate"))
|
||||
decisive = final or candidate
|
||||
if final is None:
|
||||
for s in steps:
|
||||
if s["effect"] == "candidate":
|
||||
s["effect"] = "decisive"
|
||||
else:
|
||||
steps[-1]["effect"] = "decisive"
|
||||
action = decisive.action if decisive else "pass" # pf without any matching rule passes
|
||||
# Unknown matches that could still change the outcome: quick (or quick-unknown) ones always,
|
||||
# non-quick ones only after the deciding candidate (an earlier one is overridden anyway).
|
||||
relevant = [p for p, after in possible if p.quick is not False or (final is None and after)]
|
||||
rule_conf = SURE if not relevant else PROBABLE
|
||||
conf = SURE if all(p.action == action for p in relevant) else UNKNOWN
|
||||
unknowns = sorted({reason for s in steps if s["effect"] == "possible" for reason in s["reasons"]})
|
||||
if any(p.quick is None for p, _ in possible):
|
||||
unknowns = sorted({*unknowns, "QUICK_UNKNOWN"})
|
||||
notes: list[str] = []
|
||||
if decisive is None:
|
||||
notes.append("PF_DEFAULT_PASS")
|
||||
else:
|
||||
notes += decisive.extra_notes
|
||||
if decisive.gateway and action == "pass":
|
||||
notes.append("POLICY_ROUTE")
|
||||
if decisive.statetype.lower() == "none" or "no state" in getattr(decisive.pf_line, "text", ""):
|
||||
notes.append("STATELESS")
|
||||
if decisive.sched:
|
||||
notes.append("SCHEDULE_NOW")
|
||||
if any(s["effect"] == "not_loaded" and s["rule"]["loaded"] is False for s in steps):
|
||||
notes.append("RULES_NOT_LOADED")
|
||||
return LegResult(direction=direction, iface=iface, iface_source="given", action=action, decisive=decisive,
|
||||
confidence=conf, rule_confidence=rule_conf, notes=notes, unknowns=unknowns, steps=steps)
|
||||
|
||||
|
||||
def guess_iface(ip: IPAddr, ctx: Context) -> str | None:
|
||||
"""The interface whose connected network holds ``ip`` (longest prefix), else the one with
|
||||
the default gateway."""
|
||||
best, best_len = None, -1
|
||||
for info in ctx.interfaces.values():
|
||||
if info.identifier == "lo0":
|
||||
continue
|
||||
for net in info.nets:
|
||||
if net.version == ip.version and ip in net and net.prefixlen > best_len and not net.is_link_local:
|
||||
best, best_len = info.identifier, net.prefixlen
|
||||
if best:
|
||||
return best
|
||||
gateways = [i.identifier for i in ctx.interfaces.values() if i.has_gateway]
|
||||
return gateways[0] if len(gateways) == 1 else None
|
||||
|
||||
|
||||
def _nat_nearby(q: Query, iface: str, ctx: Context) -> bool:
|
||||
for row in ctx.port_forwards or []:
|
||||
if str(row.get("disabled") or "0") == "1" or str(row.get("enabled", "1")) == "0":
|
||||
continue
|
||||
if str(row.get("nordr") or "0") == "1": # "no rdr" exclusions (anti-lockout) redirect nothing
|
||||
continue
|
||||
if q.dport is not None and resolve.port_match(str(row.get("destination.port") or ""), q.dport, ctx).value is False:
|
||||
continue
|
||||
ifaces = {i.strip().lower() for i in str(row.get("interface") or "").split(",") if i.strip()}
|
||||
if ifaces and iface not in ifaces:
|
||||
continue
|
||||
proto = str(row.get("protocol") or "").lower()
|
||||
if proto and proto not in ("any", "tcp/udp") and norm_proto(proto) != q.proto:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def evaluate(ctx: Context, q: Query) -> dict:
|
||||
rules, order_notes = normalize(ctx)
|
||||
context_notes = [*ctx.notes, *order_notes]
|
||||
legs: list[LegResult] = []
|
||||
iface, iface_source = q.iface, "given"
|
||||
if iface is None:
|
||||
iface, iface_source = guess_iface(q.src if q.direction == "in" else q.dst, ctx), "guessed"
|
||||
if iface is None:
|
||||
return _result(q, legs, context_notes + ["IFACE_UNRESOLVED"], unresolved=True)
|
||||
first = eval_leg(rules, q, ctx, q.direction, iface)
|
||||
first.iface_source = iface_source
|
||||
if iface_source == "guessed":
|
||||
first.notes.append("IFACE_GUESSED")
|
||||
if q.direction == "in" and (ctx.port_forwards is None or _nat_nearby(q, iface, ctx)):
|
||||
first.notes.append("NAT_NEARBY" if ctx.port_forwards is not None else "NAT_UNAVAILABLE")
|
||||
legs.append(first)
|
||||
if q.direction == "in" and q.egress != "none":
|
||||
egress, source = q.egress, "given"
|
||||
if egress is None:
|
||||
egress, source = guess_iface(q.dst, ctx), "guessed"
|
||||
if egress is None:
|
||||
context_notes.append("EGRESS_UNRESOLVED")
|
||||
else:
|
||||
out = eval_leg(rules, q, ctx, "out", egress, first_leg_iface=iface)
|
||||
out.iface_source = source
|
||||
if source == "guessed":
|
||||
out.notes.append("EGRESS_GUESSED")
|
||||
legs.append(out)
|
||||
return _result(q, legs, context_notes)
|
||||
|
||||
|
||||
def _leg_conf(leg: LegResult, context_notes: list[str]) -> tuple[str, str]:
|
||||
downgrade = any(n in DOWNGRADING_NOTES for n in (*leg.notes, *context_notes))
|
||||
conf, rule_conf = leg.confidence, leg.rule_confidence
|
||||
if downgrade:
|
||||
conf = _min_conf(conf, PROBABLE)
|
||||
rule_conf = _min_conf(rule_conf, PROBABLE)
|
||||
return conf, rule_conf
|
||||
|
||||
|
||||
def _result(q: Query, legs: list[LegResult], context_notes: list[str], unresolved: bool = False) -> dict:
|
||||
leg_dicts, confs = [], []
|
||||
for leg in legs:
|
||||
conf, rule_conf = _leg_conf(leg, context_notes)
|
||||
confs.append((leg, conf, rule_conf))
|
||||
leg_dicts.append({
|
||||
"direction": leg.direction, "interface": leg.iface, "interface_source": leg.iface_source,
|
||||
"action": leg.action, "confidence": conf, "rule_confidence": rule_conf,
|
||||
"decisive_rule": leg.decisive.summary() if leg.decisive else None,
|
||||
"notes": sorted(set(leg.notes)), "unknowns": leg.unknowns,
|
||||
})
|
||||
if unresolved or not legs:
|
||||
action, conf, rule_conf, decisive = None, UNKNOWN, UNKNOWN, None
|
||||
else:
|
||||
blocking = [c for c in confs if c[0].action != "pass"]
|
||||
if blocking:
|
||||
leg, _, rule_conf = blocking[0]
|
||||
action, decisive = leg.action, leg.decisive
|
||||
# Blocked if any blocking leg is right: the best of their confidences counts.
|
||||
conf = _max_conf(*(c[1] for c in blocking))
|
||||
if conf == SURE and any(c[0].action != action for c in blocking):
|
||||
conf = PROBABLE
|
||||
else:
|
||||
leg, _, rule_conf = confs[0]
|
||||
action, decisive = "pass", leg.decisive
|
||||
conf = _min_conf(*(c[1] for c in confs))
|
||||
return {
|
||||
"query": {
|
||||
"src": str(q.src), "dst": str(q.dst), "proto": q.proto_label, "sport": q.sport, "dport": q.dport,
|
||||
"icmptype": q.icmptype, "direction": q.direction,
|
||||
"assumptions": ["FIRST_PACKET"] + (["SRC_PORT_EPHEMERAL"] if q.sport is None and q.proto in _PORT_PROTOS else []),
|
||||
},
|
||||
"action": action, "confidence": conf, "rule_confidence": rule_conf,
|
||||
"decisive_rule": decisive.summary() if decisive else None,
|
||||
"notes": sorted({*context_notes, *(n for leg in legs for n in leg.notes)}),
|
||||
"unknowns": sorted({u for leg in legs for u in leg.unknowns}),
|
||||
"legs": leg_dicts,
|
||||
"trace": [{"leg": i, "direction": leg.direction, "interface": leg.iface, "steps": leg.steps}
|
||||
for i, leg in enumerate(legs)],
|
||||
}
|
||||
|
||||
|
||||
def parse_ip(value: str) -> IPAddr:
|
||||
return ipaddress.ip_address(value.strip())
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Shared types of the rule analysis: three-valued logic and the normalized rule.
|
||||
|
||||
``Tri`` is Kleene logic with ``True``/``False``/``None`` (unknown). An unknown never turns
|
||||
into a definite answer by negation, and an AND is only False when one side is definitely
|
||||
False. This is what keeps the engine from claiming "sure" for something it cannot prove.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
Tri = Optional[bool]
|
||||
|
||||
IPAddr = ipaddress.IPv4Address | ipaddress.IPv6Address
|
||||
IPNet = ipaddress.IPv4Network | ipaddress.IPv6Network
|
||||
|
||||
|
||||
def t_and(*values: Tri) -> Tri:
|
||||
if any(v is False for v in values):
|
||||
return False
|
||||
if any(v is None for v in values):
|
||||
return None
|
||||
return True
|
||||
|
||||
|
||||
def t_or(*values: Tri) -> Tri:
|
||||
if any(v is True for v in values):
|
||||
return True
|
||||
if any(v is None for v in values):
|
||||
return None
|
||||
return False
|
||||
|
||||
|
||||
def t_not(value: Tri) -> Tri:
|
||||
return None if value is None else not value
|
||||
|
||||
|
||||
def tri_str(value: Tri) -> str:
|
||||
return "unknown" if value is None else ("true" if value else "false")
|
||||
|
||||
|
||||
class Outcome:
|
||||
"""A Tri plus the reason codes that made it unknown (or noteworthy)."""
|
||||
__slots__ = ("value", "reasons")
|
||||
|
||||
def __init__(self, value: Tri, reasons: tuple[str, ...] | list[str] = ()):
|
||||
self.value = value
|
||||
self.reasons = list(reasons)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"Outcome({self.value!r}, {self.reasons!r})"
|
||||
|
||||
|
||||
def o_or(outcomes: list[Outcome]) -> Outcome:
|
||||
value = t_or(*(o.value for o in outcomes)) if outcomes else False
|
||||
reasons = [] if value is True else [r for o in outcomes if o.value is None for r in o.reasons]
|
||||
return Outcome(value, reasons)
|
||||
|
||||
|
||||
SECTION_SYSTEM, SECTION_FLOATING, SECTION_GROUP, SECTION_INTERFACE = "system", "floating", "group", "interface"
|
||||
SECTION_NAT_AUTO, SECTION_INVALID, SECTION_ANTISPOOF, SECTION_OTHER = "nat_auto", "invalid", "antispoof", "other"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Endpoint:
|
||||
raw: str
|
||||
negated: bool = False
|
||||
items: list[str] = field(default_factory=list) # empty = any
|
||||
|
||||
@property
|
||||
def is_any(self) -> bool:
|
||||
return not self.items
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormRule:
|
||||
uuid: str
|
||||
label: str
|
||||
description: str
|
||||
order: tuple[int, int]
|
||||
order_known: bool
|
||||
section: str
|
||||
enabled: bool
|
||||
action: str | None # pass | block | reject | None (unknown)
|
||||
quick: bool | None # None = unknown (automatic rule without pf ruleset)
|
||||
interfaces: set[str] # identifiers or group names; empty = every interface
|
||||
if_not: bool
|
||||
direction: str # in | out | any
|
||||
af: set[int] # {4}, {6} or {4, 6}
|
||||
protos: set[str] | None # None = any; normalized names/numbers as strings
|
||||
src: Endpoint
|
||||
dst: Endpoint
|
||||
sport: str
|
||||
dport: str
|
||||
icmptypes: set[str] | None = None # None = no restriction
|
||||
tag: str = ""
|
||||
tagged: str = ""
|
||||
received_on: str = ""
|
||||
gateway: str = ""
|
||||
statetype: str = ""
|
||||
sched: str = ""
|
||||
automatic: bool = False
|
||||
legacy: bool = False
|
||||
loaded: bool | None = None # None = pf ruleset not available
|
||||
pf_numbers: list[int] = field(default_factory=list)
|
||||
extra_notes: list[str] = field(default_factory=list) # e.g. TCP_FLAGS, RATE_LIMIT from pf text
|
||||
pf_line: object = None # set for unlabeled pf lines (antispoof)
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"uuid": self.uuid, "label": self.label, "description": self.description,
|
||||
"section": self.section, "sort_order": f"{self.order[0]:06d}.{self.order[1]:07d}" if self.order_known else None,
|
||||
"pf_numbers": self.pf_numbers, "action": self.action, "quick": self.quick,
|
||||
"interfaces": sorted(self.interfaces), "interface_not": self.if_not, "direction": self.direction,
|
||||
"automatic": self.automatic, "legacy": self.legacy, "loaded": self.loaded,
|
||||
"editable": not self.automatic and not self.legacy and self.section != SECTION_ANTISPOOF,
|
||||
"source": ("!" if self.src.negated else "") + (self.src.raw or "any"),
|
||||
"destination": ("!" if self.dst.negated else "") + (self.dst.raw or "any"),
|
||||
"source_port": self.sport or None, "destination_port": self.dport or None,
|
||||
"gateway": self.gateway or None, "schedule": self.sched or None,
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Turn ``searchRule`` rows (+ the loaded pf ruleset) into ``NormRule`` objects.
|
||||
|
||||
Format pitfalls seen live on OPNsense 26.7:
|
||||
- automatic rows carry the interface *description* (``"LAN"``), MVC rows the identifier;
|
||||
- automatic rows often have ``quick``/``action`` empty; the pf lines with the same label
|
||||
know the truth (``pass in quick on vtnet1_vlan10 ...``);
|
||||
- empty source/destination means any; address lists are comma separated;
|
||||
- unmigrated legacy rules have a 32-hex label instead of a UUID.
|
||||
|
||||
Order is ``sort_order`` (``prio.0seq`` from FilterRuleField::getPriority: 1/5 system,
|
||||
10000 force gateway, 200000 floating, 300000+n groups, 400000 interface, 500000 automatic
|
||||
NAT, 600000 = interface missing, never loaded).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.rule_analysis.context import Context
|
||||
from app.services.rule_analysis.model import (
|
||||
SECTION_ANTISPOOF, SECTION_FLOATING, SECTION_GROUP, SECTION_INTERFACE, SECTION_INVALID,
|
||||
SECTION_NAT_AUTO, SECTION_OTHER, SECTION_SYSTEM, Endpoint, NormRule,
|
||||
)
|
||||
from app.services.rule_analysis.pf_ruleset import PfLine
|
||||
|
||||
PROTO_NUMBERS = {
|
||||
"icmp": "1", "igmp": "2", "ggp": "3", "ipencap": "4", "tcp": "6", "egp": "8", "udp": "17",
|
||||
"ipv6": "41", "gre": "47", "esp": "50", "ah": "51", "ipv6-icmp": "58", "icmp6": "58",
|
||||
"icmpv6": "58", "ospf": "89", "pim": "103", "vrrp": "112", "carp": "112", "sctp": "132", "pfsync": "240",
|
||||
}
|
||||
|
||||
|
||||
def norm_proto(value: str) -> str:
|
||||
value = str(value).strip().lower()
|
||||
return PROTO_NUMBERS.get(value, value)
|
||||
|
||||
|
||||
def _truthy(value) -> bool:
|
||||
return value is True or str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _section(prio: int) -> str:
|
||||
if prio < 10001:
|
||||
return SECTION_SYSTEM
|
||||
if prio == 200000:
|
||||
return SECTION_FLOATING
|
||||
if 300000 <= prio < 400000:
|
||||
return SECTION_GROUP
|
||||
if prio == 400000:
|
||||
return SECTION_INTERFACE
|
||||
if prio == 500000:
|
||||
return SECTION_NAT_AUTO
|
||||
if prio >= 600000:
|
||||
return SECTION_INVALID
|
||||
return SECTION_OTHER
|
||||
|
||||
|
||||
def _order(row: dict) -> tuple[tuple[int, int], bool]:
|
||||
raw = str(row.get("sort_order") or "")
|
||||
if "." in raw:
|
||||
head, _, tail = raw.partition(".")
|
||||
if head.isdigit() and tail.isdigit():
|
||||
return (int(head), int(tail)), True
|
||||
# Older releases without sort_order: best effort from prio_group/sequence (confidence drops).
|
||||
prio = str(row.get("prio_group") or row.get("#priority") or "")
|
||||
seq = str(row.get("sequence") or "")
|
||||
return (int(prio) if prio.isdigit() else 400000, int(seq) if seq.isdigit() else 0), False
|
||||
|
||||
|
||||
def _endpoint(row: dict, side: str) -> Endpoint:
|
||||
raw = str(row.get(f"{side}_net") or "").strip()
|
||||
items = [] if raw.lower() in ("", "any") else [i.strip() for i in raw.split(",") if i.strip()]
|
||||
return Endpoint(raw=raw, negated=_truthy(row.get(f"{side}_not")), items=items)
|
||||
|
||||
|
||||
def _af(value: str) -> set[int]:
|
||||
value = str(value or "").strip().lower()
|
||||
return {4} if value == "inet" else ({6} if value == "inet6" else {4, 6})
|
||||
|
||||
|
||||
def _protos(value: str) -> set[str] | None:
|
||||
value = str(value or "").strip().lower()
|
||||
if value in ("", "any"):
|
||||
return None
|
||||
return {norm_proto(p) for p in value.split("/") if p}
|
||||
|
||||
|
||||
def _pf_interfaces(lines: list[PfLine], ctx: Context) -> tuple[set[str], bool] | None:
|
||||
devices = {(line.on_device, line.on_negated) for line in lines}
|
||||
if any(device is None for device, _ in devices):
|
||||
return set(), False
|
||||
if len({neg for _, neg in devices}) != 1:
|
||||
return None
|
||||
names: set[str] = set()
|
||||
for device, _ in devices:
|
||||
info = ctx.by_device(device)
|
||||
if info is not None:
|
||||
names.add(info.identifier)
|
||||
elif ctx.groups is not None and device in ctx.groups:
|
||||
names.add(device)
|
||||
else:
|
||||
return None
|
||||
return names, next(iter(devices))[1]
|
||||
|
||||
|
||||
def normalize_row(row: dict, ctx: Context) -> NormRule:
|
||||
order, order_known = _order(row)
|
||||
label = str(row.get("uuid") or "")
|
||||
automatic = _truthy(row.get("is_automatic"))
|
||||
lines = ctx.pf.by_label.get(label, []) if ctx.pf else []
|
||||
rule = NormRule(
|
||||
uuid=label, label=label, description=str(row.get("description") or ""),
|
||||
order=order, order_known=order_known, section=_section(order[0]),
|
||||
enabled=_truthy(row.get("enabled", "1")),
|
||||
action=(str(row.get("action") or "").lower() or None),
|
||||
quick=None, interfaces=set(), if_not=_truthy(row.get("interfacenot")),
|
||||
direction=(str(row.get("direction") or "in").lower() or "in"),
|
||||
af=_af(row.get("ipprotocol")), protos=_protos(row.get("protocol")),
|
||||
src=_endpoint(row, "source"), dst=_endpoint(row, "destination"),
|
||||
sport=str(row.get("source_port") or "").strip(), dport=str(row.get("destination_port") or "").strip(),
|
||||
tag=str(row.get("tag") or ""), tagged=str(row.get("tagged") or ""),
|
||||
received_on=str(row.get("received-on") or row.get("received_on") or ""),
|
||||
gateway=str(row.get("gateway") or ""), statetype=str(row.get("statetype") or ""),
|
||||
sched=str(row.get("sched") or ""), automatic=automatic, legacy=_truthy(row.get("legacy")),
|
||||
pf_numbers=[line.number for line in lines],
|
||||
)
|
||||
if rule.action not in ("pass", "block", "reject"):
|
||||
rule.action = None
|
||||
if row.get("quick") not in (None, ""):
|
||||
rule.quick = _truthy(row.get("quick"))
|
||||
raw_if = str(row.get("interface") or "").strip()
|
||||
names = {n.strip() for n in raw_if.split(",") if n.strip()}
|
||||
# Automatic and unmigrated legacy rows carry descriptions ("LAN"): map them to identifiers.
|
||||
# Identifiers and group names stay as they are; the pf "on" clause decides when available.
|
||||
mapped = set()
|
||||
for name in names:
|
||||
info = None if name in ctx.interfaces or name in (ctx.groups or {}) else ctx.by_label(name)
|
||||
mapped.add(info.identifier if info else name)
|
||||
names = mapped
|
||||
rule.interfaces = names
|
||||
if ctx.pf is not None:
|
||||
rule.loaded = bool(lines) and rule.enabled
|
||||
if lines:
|
||||
actions = {line.action for line in lines}
|
||||
if len(actions) == 1 and next(iter(actions)) in ("pass", "block", "reject"):
|
||||
pf_action = next(iter(actions))
|
||||
if rule.action is None or automatic:
|
||||
rule.action = pf_action
|
||||
quick = {line.quick for line in lines}
|
||||
rule.quick = next(iter(quick)) if len(quick) == 1 else None
|
||||
if automatic or rule.legacy:
|
||||
on = _pf_interfaces(lines, ctx)
|
||||
if on is not None:
|
||||
rule.interfaces, rule.if_not = on
|
||||
directions = {line.direction for line in lines}
|
||||
if len(directions) == 1:
|
||||
rule.direction = next(iter(directions))
|
||||
families = {line.af for line in lines}
|
||||
if None not in families:
|
||||
rule.af &= families
|
||||
types = {t for line in lines for t in line.icmptypes}
|
||||
if types:
|
||||
rule.icmptypes = types
|
||||
if any(line.flags not in (None, "S/SA", "any") for line in lines):
|
||||
rule.extra_notes.append("TCP_FLAGS")
|
||||
if any(word in line.text for line in lines for word in ("max-src-", "overload", "max-pkt-rate")):
|
||||
rule.extra_notes.append("RATE_LIMIT")
|
||||
elif rule.section == SECTION_INVALID:
|
||||
rule.loaded = False
|
||||
if rule.icmptypes is None and str(row.get("icmptype") or "").strip():
|
||||
rule.icmptypes = {t.strip() for t in str(row["icmptype"]).split(",") if t.strip()}
|
||||
return rule
|
||||
|
||||
|
||||
def antispoof_rule(line: PfLine, order: tuple[int, int]) -> NormRule:
|
||||
"""An unlabeled pf line OPNsense generated itself (antispoof / own-address block)."""
|
||||
return NormRule(
|
||||
uuid=f"pf@{line.number}", label="", description=line.text, order=order, order_known=True,
|
||||
section=SECTION_ANTISPOOF, enabled=True, action=line.action if line.action != "other" else None,
|
||||
quick=line.quick, interfaces=set(), if_not=False, direction=line.direction,
|
||||
af={line.af} if line.af else {4, 6}, protos=None,
|
||||
src=Endpoint(raw="" if line.src == "any" else line.src, items=[] if line.src == "any" else [line.src]),
|
||||
dst=Endpoint(raw="" if line.dst == "any" else line.dst, items=[] if line.dst == "any" else [line.dst]),
|
||||
sport="", dport="", automatic=True, loaded=True, pf_numbers=[line.number], pf_line=line,
|
||||
)
|
||||
|
||||
|
||||
def normalize(ctx: Context) -> tuple[list[NormRule], list[str]]:
|
||||
"""All rules in evaluation order plus context notes (order problems)."""
|
||||
notes: list[str] = []
|
||||
rules = [normalize_row(row, ctx) for row in ctx.rows]
|
||||
if any(not r.order_known for r in rules):
|
||||
notes.append("ORDER_GUESSED")
|
||||
rules.sort(key=lambda r: (r.order, r.uuid))
|
||||
if ctx.pf is not None:
|
||||
loaded = [r for r in rules if r.loaded and r.pf_numbers]
|
||||
firsts = [min(r.pf_numbers) for r in loaded]
|
||||
if firsts != sorted(firsts):
|
||||
notes.append("ORDER_MISMATCH")
|
||||
# Unlabeled pf lines: placed before the first configured rule that pf evaluates later.
|
||||
extra: list[tuple[tuple, NormRule]] = []
|
||||
for line in ctx.pf.unlabeled:
|
||||
after = next((r for r in loaded if min(r.pf_numbers) > line.number), None)
|
||||
key = after.order if after else (999999, 9999999)
|
||||
spoof = antispoof_rule(line, key)
|
||||
if line.on_device:
|
||||
info = ctx.by_device(line.on_device)
|
||||
spoof.interfaces = {info.identifier if info else line.on_device}
|
||||
spoof.if_not = line.on_negated
|
||||
extra.append(((key, 0, line.number), spoof))
|
||||
keyed = [((r.order, 1, 0), r) for r in rules] + extra
|
||||
keyed.sort(key=lambda item: item[0])
|
||||
rules = [r for _, r in keyed]
|
||||
return rules, notes
|
||||
@@ -0,0 +1,93 @@
|
||||
"""The ruleset pf has actually loaded (``diagnostics/firewall/pfStatistics/rules``).
|
||||
|
||||
Each key looks like ``@76 pass in quick on vtnet1_vlan10 inet from (...) to any flags S/SA
|
||||
keep state label "<uuid>"``. One configured rule can expand to several pf lines (address
|
||||
families, ICMP types, port lists); they share one label. Lines without a label are generated
|
||||
by OPNsense itself (antispoof: ``block drop in log on ! vtnet1_vlan10 inet from 192.0.2.0/24
|
||||
to any``). Only what the analysis needs is parsed: position, action, quick, direction, the
|
||||
``on`` interface, address family, ICMP types and a few pf options that make the answer
|
||||
uncertain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_HEAD = re.compile(r"^@(\d+)\s+(.*)$")
|
||||
_LABEL = re.compile(r'label "([^"]+)"')
|
||||
_ON = re.compile(r"\bon (!\s*)?([A-Za-z0-9_.]+)")
|
||||
_ICMP = re.compile(r"\bicmp6?-type (\S+)")
|
||||
_FROM = re.compile(r"\bfrom (\S+) to (\S+)")
|
||||
_FLAGS = re.compile(r"\bflags (\S+)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PfLine:
|
||||
number: int
|
||||
text: str
|
||||
action: str # pass | block | reject | match | scrub | other
|
||||
quick: bool
|
||||
direction: str # in | out | any
|
||||
on_negated: bool
|
||||
on_device: str | None
|
||||
af: int | None # 4, 6 or None (both)
|
||||
label: str | None
|
||||
icmptypes: list[str] = field(default_factory=list)
|
||||
src: str = "any"
|
||||
dst: str = "any"
|
||||
flags: str | None = None
|
||||
|
||||
|
||||
def parse_line(number: int, text: str) -> PfLine:
|
||||
words = text.split()
|
||||
first = words[0] if words else ""
|
||||
action = first if first in ("pass", "match", "scrub") else "other"
|
||||
if first == "block":
|
||||
action = "reject" if len(words) > 1 and words[1].startswith("return") else "block"
|
||||
direction = "in" if " in " in f" {text} " else ("out" if " out " in f" {text} " else "any")
|
||||
on = _ON.search(text)
|
||||
af = 4 if re.search(r"\binet\b", text) else (6 if re.search(r"\binet6\b", text) else None)
|
||||
label = _LABEL.search(text)
|
||||
endpoints = _FROM.search(text)
|
||||
flags = _FLAGS.search(text)
|
||||
return PfLine(
|
||||
number=number, text=text, action=action, quick=" quick " in f" {text} ",
|
||||
direction=direction, on_negated=bool(on and on.group(1)), on_device=on.group(2) if on else None,
|
||||
af=af, label=label.group(1) if label else None, icmptypes=_ICMP.findall(text),
|
||||
src=endpoints.group(1) if endpoints else ("any" if " all" in f" {text}" else "?"),
|
||||
dst=endpoints.group(2) if endpoints else ("any" if " all" in f" {text}" else "?"),
|
||||
flags=flags.group(1) if flags else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PfRuleset:
|
||||
lines: list[PfLine]
|
||||
by_label: dict[str, list[PfLine]]
|
||||
|
||||
@property
|
||||
def unlabeled(self) -> list[PfLine]:
|
||||
return [line for line in self.lines if line.label is None and line.action in ("pass", "block", "reject")]
|
||||
|
||||
|
||||
def parse(stats: dict | None) -> PfRuleset | None:
|
||||
"""None when the answer does not look like a pf ruleset at all."""
|
||||
rules = (stats or {}).get("rules") if isinstance(stats, dict) else None
|
||||
filter_rules = rules.get("filter rules") if isinstance(rules, dict) else None
|
||||
if not isinstance(filter_rules, dict):
|
||||
return None
|
||||
lines: list[PfLine] = []
|
||||
for key in filter_rules:
|
||||
head = _HEAD.match(str(key).strip())
|
||||
if not head:
|
||||
continue
|
||||
line = parse_line(int(head.group(1)), head.group(2))
|
||||
if line.action == "scrub":
|
||||
continue
|
||||
lines.append(line)
|
||||
lines.sort(key=lambda line: line.number)
|
||||
by_label: dict[str, list[PfLine]] = {}
|
||||
for line in lines:
|
||||
if line.label:
|
||||
by_label.setdefault(line.label, []).append(line)
|
||||
return PfRuleset(lines=lines, by_label=by_label)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Does a rule field match the queried packet? Addresses, interfaces, aliases, ports.
|
||||
|
||||
Every function answers with an ``Outcome`` (True/False/None + reason codes). Whatever cannot
|
||||
be proven from the firewall's own data is ``None`` (unknown), never a guess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from app.services.rule_analysis.context import Context
|
||||
from app.services.rule_analysis.model import IPAddr, Outcome, o_or, t_not
|
||||
|
||||
MAX_ALIAS_DEPTH = 10
|
||||
TABLE_ONLY_TYPES = {"url", "urltable", "geoip", "asn", "mac", "dynipv6host", "authgroup", "external", "internal"}
|
||||
ADDRESS_TYPES = {"host", "network", "networkgroup"}
|
||||
|
||||
SERVICES = {
|
||||
"ftp-data": 20, "ftp": 21, "ssh": 22, "telnet": 23, "smtp": 25, "domain": 53, "dns": 53, "bootps": 67,
|
||||
"bootpc": 68, "tftp": 69, "http": 80, "www": 80, "kerberos": 88, "pop3": 110, "ntp": 123, "imap": 143,
|
||||
"snmp": 161, "snmptrap": 162, "ldap": 389, "https": 443, "smb": 445, "microsoft-ds": 445, "isakmp": 500,
|
||||
"syslog": 514, "submission": 587, "ldaps": 636, "imaps": 993, "pop3s": 995, "openvpn": 1194,
|
||||
"ms-sql-s": 1433, "radius": 1812, "radius-acct": 1813, "nfs": 2049, "mysql": 3306, "rdp": 3389,
|
||||
"ipsec-nat-t": 4500, "sip": 5060, "postgresql": 5432, "dhcpv6-client": 546, "dhcpv6-server": 547,
|
||||
"http-alt": 8080, "wireguard": 51820,
|
||||
}
|
||||
EPHEMERAL = (1024, 65535)
|
||||
|
||||
|
||||
def _ip_in(token: str, ip: IPAddr) -> bool | None:
|
||||
"""Literal IP / CIDR / range; None when the token is not a literal."""
|
||||
token = token.strip()
|
||||
if "-" in token and not token.startswith("-"):
|
||||
low, _, high = token.partition("-")
|
||||
try:
|
||||
low_ip, high_ip = ipaddress.ip_address(low.strip()), ipaddress.ip_address(high.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return low_ip.version == ip.version and low_ip <= ip <= high_ip
|
||||
try:
|
||||
net = ipaddress.ip_network(token, strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
return net.version == ip.version and ip in net
|
||||
|
||||
|
||||
def _interface_ref(token: str, ip: IPAddr, ctx: Context) -> Outcome | None:
|
||||
name = token.strip()
|
||||
lower = name.lower()
|
||||
m = re.fullmatch(r"__(.+)_network", lower)
|
||||
if m and m.group(1) in ctx.interfaces:
|
||||
lower = m.group(1)
|
||||
info = ctx.interfaces.get(lower)
|
||||
if info is not None: # "lan" = the interface's networks (pf: (dev:network), incl. VIP subnets)
|
||||
return Outcome(any(ip in net for net in info.nets))
|
||||
if lower.endswith("ip") and lower[:-2] in ctx.interfaces: # "lanip" = the interface address
|
||||
info = ctx.interfaces[lower[:-2]]
|
||||
if ip in info.addrs:
|
||||
return Outcome(True)
|
||||
if ip in info.vips:
|
||||
return Outcome(None, ["VIP_AS_INTERFACE_ADDRESS"])
|
||||
return Outcome(False)
|
||||
return None
|
||||
|
||||
|
||||
def addr_item(token: str, ip: IPAddr, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> Outcome:
|
||||
token = token.strip()
|
||||
if token.lower() in ("any", ""):
|
||||
return Outcome(True)
|
||||
if token.lower() in ("(self)", "self", "this firewall"):
|
||||
return Outcome(ip in ctx.self_addrs)
|
||||
literal = _ip_in(token, ip)
|
||||
if literal is not None:
|
||||
return Outcome(literal)
|
||||
ref = _interface_ref(token, ip, ctx)
|
||||
if ref is not None:
|
||||
return ref
|
||||
return alias_contains(token, ip, ctx, depth=depth, seen=seen)
|
||||
|
||||
|
||||
def alias_contains(name: str, ip: IPAddr, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> Outcome:
|
||||
if name in seen:
|
||||
return Outcome(None, ["ALIAS_CYCLE"])
|
||||
if depth > MAX_ALIAS_DEPTH:
|
||||
return Outcome(None, ["ALIAS_TOO_DEEP"])
|
||||
alias = ctx.aliases.get(name)
|
||||
refs = ctx.refs.get(str(ip))
|
||||
if alias is None:
|
||||
# pf knows tables the alias list may not show (e.g. internal ones): trust a positive hit.
|
||||
if refs is not None and name in refs:
|
||||
return Outcome(True)
|
||||
return Outcome(None, ["ALIAS_MISSING"])
|
||||
kind = str(alias.get("type") or "").lower()
|
||||
if depth == 0 and refs is not None:
|
||||
# pf's own table test: authoritative for every alias type (nesting, "!" entries, FQDNs).
|
||||
return Outcome(name in refs)
|
||||
if kind in ADDRESS_TYPES:
|
||||
return _alias_content(alias, ip, ctx, depth, (*seen, name))
|
||||
if refs is not None:
|
||||
return Outcome(name in refs)
|
||||
return Outcome(None, ["ALIAS_TABLE_UNAVAILABLE"])
|
||||
|
||||
|
||||
def _alias_content(alias: dict, ip: IPAddr, ctx: Context, depth: int, seen: tuple) -> Outcome:
|
||||
entries = [e.strip() for e in re.split(r"[\n,]", str(alias.get("content") or "")) if e.strip()]
|
||||
if any(e.startswith("!") for e in entries):
|
||||
return Outcome(None, ["ALIAS_EXCLUSION"])
|
||||
results = []
|
||||
for entry in entries:
|
||||
literal = _ip_in(entry, ip)
|
||||
if literal is not None:
|
||||
results.append(Outcome(literal))
|
||||
elif entry in ctx.aliases or entry in seen:
|
||||
results.append(alias_contains(entry, ip, ctx, depth=depth + 1, seen=seen))
|
||||
elif "." in entry or ":" in entry:
|
||||
results.append(Outcome(None, ["FQDN_UNRESOLVED"]))
|
||||
else:
|
||||
results.append(Outcome(None, ["ALIAS_MISSING"]))
|
||||
return o_or(results)
|
||||
|
||||
|
||||
def endpoint_match(endpoint, ip: IPAddr, ctx: Context) -> Outcome:
|
||||
if endpoint.is_any:
|
||||
return Outcome(True)
|
||||
result = o_or([addr_item(item, ip, ctx) for item in endpoint.items])
|
||||
if endpoint.negated:
|
||||
return Outcome(t_not(result.value), result.reasons)
|
||||
return result
|
||||
|
||||
|
||||
# ── Ports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def port_ranges(spec: str, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> tuple[list[tuple[int, int]] | None, list[str]]:
|
||||
"""The port ranges a spec stands for; (None, reasons) when that cannot be known."""
|
||||
ranges: list[tuple[int, int]] = []
|
||||
reasons: list[str] = []
|
||||
for part in [p.strip() for p in re.split(r"[\n,]", spec) if p.strip()]:
|
||||
m = re.fullmatch(r"(\w[\w-]*?)\s*[-:]\s*(\w[\w-]*)", part) if not part.isdigit() and part not in ctx.aliases else None
|
||||
if part.isdigit():
|
||||
ranges.append((int(part), int(part)))
|
||||
elif m and _port_number(m.group(1)) is not None and _port_number(m.group(2)) is not None:
|
||||
ranges.append((_port_number(m.group(1)), _port_number(m.group(2))))
|
||||
elif part.lower() in SERVICES:
|
||||
ranges.append((SERVICES[part.lower()],) * 2)
|
||||
elif part in ctx.aliases:
|
||||
if part in seen or depth >= MAX_ALIAS_DEPTH:
|
||||
return None, ["ALIAS_CYCLE" if part in seen else "ALIAS_TOO_DEEP"]
|
||||
alias = ctx.aliases[part]
|
||||
if str(alias.get("type") or "").lower() != "port":
|
||||
return None, ["PORT_ALIAS_UNRESOLVED"]
|
||||
sub, why = port_ranges(str(alias.get("content") or ""), ctx, depth=depth + 1, seen=(*seen, part))
|
||||
if sub is None:
|
||||
return None, why
|
||||
ranges += sub
|
||||
else:
|
||||
return None, ["PORT_ALIAS_UNRESOLVED" if part.isidentifier() else "PORT_UNPARSEABLE"]
|
||||
return ranges, reasons
|
||||
|
||||
|
||||
def _port_number(token: str) -> int | None:
|
||||
if token.isdigit():
|
||||
return int(token)
|
||||
return SERVICES.get(token.lower())
|
||||
|
||||
|
||||
def port_match(spec: str, port: int | None, ctx: Context, *, ephemeral_default: bool = False) -> Outcome:
|
||||
spec = (spec or "").strip()
|
||||
if spec.lower() in ("", "any"):
|
||||
return Outcome(True)
|
||||
ranges, reasons = port_ranges(spec, ctx)
|
||||
if ranges is None:
|
||||
return Outcome(None, reasons)
|
||||
if port is not None:
|
||||
return Outcome(any(low <= port <= high for low, high in ranges))
|
||||
if not ephemeral_default:
|
||||
return Outcome(None, ["PORT_NOT_GIVEN"])
|
||||
# No source port given: the query stands for an ephemeral client port (1024-65535).
|
||||
low_e, high_e = EPHEMERAL
|
||||
overlap = [(max(low, low_e), min(high, high_e)) for low, high in ranges if high >= low_e and low <= high_e]
|
||||
if not overlap:
|
||||
return Outcome(False)
|
||||
covered, cursor = True, low_e
|
||||
for low, high in sorted(overlap):
|
||||
if low > cursor:
|
||||
covered = False
|
||||
break
|
||||
cursor = max(cursor, high + 1)
|
||||
if covered and cursor > high_e:
|
||||
return Outcome(True)
|
||||
return Outcome(None, ["SRC_PORT_NOT_GIVEN"])
|
||||
|
||||
|
||||
# ── Interfaces ────────────────────────────────────────────────────────────
|
||||
|
||||
def iface_match(interfaces: set[str], if_not: bool, iface: str, ctx: Context) -> Outcome:
|
||||
if not interfaces:
|
||||
return Outcome(True)
|
||||
results = []
|
||||
for name in interfaces:
|
||||
if name == iface:
|
||||
results.append(Outcome(True))
|
||||
elif name in ctx.interfaces:
|
||||
results.append(Outcome(False))
|
||||
elif ctx.groups is not None and name in ctx.groups:
|
||||
results.append(Outcome(iface in ctx.groups[name]))
|
||||
else:
|
||||
results.append(Outcome(None, ["IFACE_UNRESOLVED" if ctx.groups is not None else "GROUPS_UNAVAILABLE"]))
|
||||
result = o_or(results)
|
||||
if if_not:
|
||||
return Outcome(t_not(result.value), result.reasons)
|
||||
return result
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Entry point used by the API: validate a query, load the firewall's context, evaluate."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services.rule_analysis import context as ctx_mod
|
||||
from app.services.rule_analysis.engine import Query, evaluate
|
||||
from app.services.rule_analysis.normalize import PROTO_NUMBERS, norm_proto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ip(value: str | None, field: str):
|
||||
try:
|
||||
return ipaddress.ip_address(str(value or "").strip())
|
||||
except ValueError:
|
||||
raise api_error(422, ErrorCode.ANALYSE_ADRESSE_UNGUELTIG, field=field, value=str(value or "")[:64]) from None
|
||||
|
||||
|
||||
def _port(value: str | int | None, field: str) -> int | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text.isdigit() or int(text) > 65535:
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field=field, value=text[:32])
|
||||
return int(text)
|
||||
|
||||
|
||||
def build_query(*, src: str, dst: str, proto: str = "tcp", sport=None, dport=None, icmptype: str | None = None,
|
||||
iface: str | None = None, direction: str = "in", egress: str | None = None) -> Query:
|
||||
source, destination = _ip(src, "src"), _ip(dst, "dst")
|
||||
if source.version != destination.version:
|
||||
raise api_error(422, ErrorCode.ANALYSE_FAMILIEN_MISCHUNG)
|
||||
label = str(proto or "tcp").strip().lower()
|
||||
if label == "icmp" and source.version == 6:
|
||||
label = "ipv6-icmp"
|
||||
number = norm_proto(label)
|
||||
if not (number.isdigit() and int(number) <= 255) and label not in PROTO_NUMBERS:
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="proto", value=label[:32])
|
||||
q = Query(src=source, dst=destination, proto=number, proto_label=label,
|
||||
sport=_port(sport, "sport"), dport=_port(dport, "dport"))
|
||||
if (q.sport is not None or q.dport is not None) and number not in ("6", "17"):
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="dport", value=str(dport or sport))
|
||||
if icmptype not in (None, ""):
|
||||
if number not in ("1", "58"):
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="icmptype", value=str(icmptype)[:32])
|
||||
q.icmptype = str(icmptype).strip().lower()
|
||||
if direction not in ("in", "out"):
|
||||
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="direction", value=str(direction)[:8])
|
||||
q.direction = direction
|
||||
q.iface = None if str(iface or "").strip().lower() in ("", "auto") else str(iface).strip()
|
||||
egress_value = str(egress or "").strip().lower()
|
||||
q.egress = "none" if egress_value in ("", "none") else (None if egress_value == "auto" else str(egress).strip())
|
||||
return q
|
||||
|
||||
|
||||
def check_interfaces(q: Query, ctx: ctx_mod.Context) -> None:
|
||||
for name in (q.iface, None if q.egress in (None, "none") else q.egress):
|
||||
if name is not None and name not in ctx.interfaces:
|
||||
raise api_error(422, ErrorCode.ANALYSE_INTERFACE_UNBEKANNT, interface=name[:64],
|
||||
known=", ".join(sorted(i for i in ctx.interfaces if i != "lo0")))
|
||||
|
||||
|
||||
async def analyze_host(host: OpnsenseHost, q: Query, *, refresh: bool = False) -> dict:
|
||||
"""Raises api_error for a bad interface; OpnsenseAPIError/httpx errors when the firewall
|
||||
cannot be read (callers map that)."""
|
||||
ctx = await ctx_mod.load_context(host, use_cache=not refresh)
|
||||
check_interfaces(q, ctx)
|
||||
await ctx_mod.ensure_refs(host, ctx, [str(q.src), str(q.dst)])
|
||||
result = evaluate(ctx, q)
|
||||
if any(ctx.refs.get(ip) is None for ip in (str(q.src), str(q.dst))):
|
||||
result["notes"] = sorted({*result["notes"], "ALIAS_REFERENCES_UNAVAILABLE"})
|
||||
return result
|
||||
@@ -58,7 +58,12 @@ class S3Client:
|
||||
if parts.scheme == "http" and not settings.allow_insecure_offsite:
|
||||
# Credentials and (encrypted, but still) backups must not travel in the clear.
|
||||
raise S3Error("Der Endpunkt muss https:// sein")
|
||||
self.scheme, self.netloc = parts.scheme, parts.netloc
|
||||
# httpx leaves a scheme's default port out of the Host header, so the signed host must not carry it either.
|
||||
port = parts.port
|
||||
default = {"https": 443, "http": 80}[parts.scheme]
|
||||
host = parts.hostname if ":" not in parts.hostname else f"[{parts.hostname}]"
|
||||
self.scheme = parts.scheme
|
||||
self.netloc = host if port in (None, default) else f"{host}:{port}"
|
||||
self.region, self.bucket, self.path_style = region or "us-east-1", bucket, path_style
|
||||
self.access_key, self.secret_key = access_key, secret_key
|
||||
|
||||
|
||||
@@ -15,18 +15,31 @@ the UI) but is NOT wired into the NotificationSettings webhook/email
|
||||
system — that system is built around persistent NotificationProblem rows
|
||||
with their own admin-configurable toggles, and adding a new problem kind
|
||||
for this is a bigger change than this feature's own scope. Flagged here,
|
||||
not silently dropped, in case it's wanted later.
|
||||
not silently dropped, in case it's wanted later. (Later: the failure is reported by
|
||||
notification_checks.check_scheduled_maintenance_failed since 1.17.)
|
||||
|
||||
auto_verify (1.25, set for every rollout step): instead of leaving the operation "submitted"
|
||||
for a human, the run waits for the job (phase job_wait) and then checks the result itself
|
||||
(phase verify: firmware idle for a while, a reboot really happened when one was due, version
|
||||
readable). Only then the operation is closed as "acknowledged"; anything unclear ends as
|
||||
"unknown" (stays locked, notification). Without auto_verify nothing changes.
|
||||
|
||||
Concurrency: due rows and running rows are each taken with SELECT ... FOR UPDATE SKIP LOCKED,
|
||||
one row per transaction, so a second backend (briefly possible during a web update) can never
|
||||
fire or step the same row at the same time.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, true
|
||||
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 contextlib import contextmanager
|
||||
|
||||
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
|
||||
@@ -51,40 +64,93 @@ 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)}
|
||||
# auto_verify: the firmware must report "ready" for this long in a row (a reboot at the end of an update
|
||||
# can follow a short "ready" moment), and the whole check gives up after VERIFY_TIMEOUT.
|
||||
VERIFY_SETTLE = timedelta(seconds=60)
|
||||
VERIFY_TIMEOUT = timedelta(minutes=20)
|
||||
# A rollout step is created with run_at=now and fires on the next poll. Found much later (OPNexus was down,
|
||||
# e.g. during an update), it no longer belongs to the decision that created it: the window may have closed
|
||||
# and the run may have been paused meanwhile. It is then not fired; the rollout engine re-queues the host.
|
||||
ROLLOUT_FIRE_GRACE = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _as_actor(sched: ScheduledMaintenance):
|
||||
"""Audit events of an unattended run name the person who planned it (not the rollback worker)."""
|
||||
token = audit_service.context.set({"actor": f"{sched.actor} (geplant)"[:150], "id": str(sched.id)})
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
audit_service.context.reset(token)
|
||||
|
||||
|
||||
def _rollout_details(sched: ScheduledMaintenance) -> dict:
|
||||
rollout = (sched.progress or {}).get("rollout")
|
||||
return {"rollout": rollout} if rollout else {}
|
||||
|
||||
|
||||
async def fire_due(db: AsyncSession) -> dict:
|
||||
due = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(ScheduledMaintenance).where(
|
||||
ScheduledMaintenance.status == "pending", ScheduledMaintenance.run_at <= _now()
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
fired = 0
|
||||
blocked = 0
|
||||
for sched in due:
|
||||
try:
|
||||
await _fire_one(db, sched)
|
||||
except Exception:
|
||||
logger.exception("Geplante Wartung %s fehlgeschlagen", sched.id)
|
||||
sched.status = "failed"
|
||||
sched.result = {"error": "Unerwarteter Fehler beim Ausführen"}
|
||||
due = fired = blocked = 0
|
||||
seen: set = set()
|
||||
while True:
|
||||
# One row per transaction, locked: a second backend skips it instead of firing it again.
|
||||
sched = await db.scalar(
|
||||
select(ScheduledMaintenance)
|
||||
.where(ScheduledMaintenance.status == "pending", ScheduledMaintenance.run_at <= _now(),
|
||||
ScheduledMaintenance.id.not_in(seen) if seen else true())
|
||||
.order_by(ScheduledMaintenance.run_at)
|
||||
.limit(1)
|
||||
.with_for_update(skip_locked=True)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
if sched is None:
|
||||
await db.commit()
|
||||
break
|
||||
seen.add(sched.id)
|
||||
due += 1
|
||||
with _as_actor(sched):
|
||||
try:
|
||||
await _fire_one(db, sched)
|
||||
except Exception:
|
||||
logger.exception("Geplante Wartung %s fehlgeschlagen", sched.id)
|
||||
await db.rollback()
|
||||
sched = await db.get(ScheduledMaintenance, sched.id, populate_existing=True)
|
||||
if sched.status in ("pending", "running") and sched.phase is None:
|
||||
sched.status = "failed"
|
||||
sched.result = {"error": "Unerwarteter Fehler beim Ausführen"}
|
||||
await db.commit()
|
||||
await db.commit() # never keep the row lock past this row
|
||||
if sched.status == "failed":
|
||||
blocked += 1
|
||||
else:
|
||||
fired += 1
|
||||
return {"due": len(due), "fired": fired, "blocked_or_failed": blocked}
|
||||
return {"due": due, "fired": fired, "blocked_or_failed": blocked}
|
||||
|
||||
|
||||
def _reboot_expected(action: str, state: dict) -> bool:
|
||||
return action in ("reboot", "major_update") or bool(state.get("reboot_required"))
|
||||
|
||||
|
||||
async def _boot_time(host: OpnsenseHost) -> str | None:
|
||||
"""systemTime's "boottime" (Diagnostics SystemController::systemTimeAction, read-only, already used by the
|
||||
resource sampler). A different value afterwards proves a reboot even if no poll caught the host offline."""
|
||||
try:
|
||||
return (await client.get_system_time(host)).get("boottime") or None
|
||||
except client.OpnsenseAPIError:
|
||||
return None
|
||||
|
||||
|
||||
async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
if sched.origin == "rollout" and _now() - sched.run_at > ROLLOUT_FIRE_GRACE:
|
||||
sched.status = "failed"
|
||||
sched.result = {"error": "Rollout-Schritt nicht rechtzeitig gestartet, nichts ausgeführt", "not_fired": True}
|
||||
await db.commit()
|
||||
logger.warning("Geplante Wartung %s: Rollout-Schritt überfällig, nicht ausgeführt", sched.id)
|
||||
return
|
||||
host = await db.get(OpnsenseHost, sched.host_id)
|
||||
if not host or not host.enabled:
|
||||
sched.status = "failed"
|
||||
@@ -137,6 +203,7 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
logger.warning("Geplante Wartung %s blockiert: %s", sched.id, sched.result["error"])
|
||||
return
|
||||
|
||||
boot_before = await _boot_time(host) if sched.auto_verify else None
|
||||
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(),
|
||||
@@ -147,14 +214,23 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
await audit_service.persist(
|
||||
kind="host", action=sched.action, status="requested",
|
||||
host_id=str(host.id), host_name=host.name, entity_id=str(op.id),
|
||||
details={"scheduled": True, "schedule_id": str(sched.id), "snapshot": state},
|
||||
details={"scheduled": True, "schedule_id": str(sched.id), "snapshot": state, **_rollout_details(sched)},
|
||||
)
|
||||
try:
|
||||
result = await _ACTION_FN[sched.action](host)
|
||||
op.status = "submitted"
|
||||
op.message = "Geplant übergeben. Abschluss und Erreichbarkeit prüfen; anschließend Wartung abschließen."
|
||||
sched.status = "succeeded"
|
||||
sched.result = {"response": result}
|
||||
if sched.auto_verify:
|
||||
# Not done yet: the poll loop waits for the job and checks the result (job_wait -> verify).
|
||||
op.message = "Geplant übergeben. Abschluss wird automatisch geprüft."
|
||||
sched.phase, sched.phase_since = "job_wait", _now()
|
||||
_progress(sched, handover=False, job_started_at=_now().isoformat(), seen_down=False, seen_running=False,
|
||||
version_before=state.get("version"), boot_before=boot_before,
|
||||
reboot_expected=_reboot_expected(sched.action, state))
|
||||
sched.result = {"response": result}
|
||||
else:
|
||||
op.message = "Geplant übergeben. Abschluss und Erreichbarkeit prüfen; anschließend Wartung abschließen."
|
||||
sched.status = "succeeded"
|
||||
sched.result = {"response": result}
|
||||
except client.OpnsenseAPIError as exc:
|
||||
op.status = "unknown"
|
||||
op.message = "Ausgang unklar: " + str(exc)[:800] + ". Nicht automatisch wiederholen."
|
||||
@@ -166,7 +242,7 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
await audit_service.persist(
|
||||
kind="host", action=sched.action, status=op.status,
|
||||
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},
|
||||
message=op.message, details={"scheduled": True, "schedule_id": str(sched.id), "response": result, **_rollout_details(sched)},
|
||||
)
|
||||
|
||||
|
||||
@@ -182,21 +258,24 @@ 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},
|
||||
details={"scheduled": True, "schedule_id": str(sched.id), "handover": bool((sched.progress or {}).get("handover")),
|
||||
**_rollout_details(sched), **details},
|
||||
)
|
||||
|
||||
|
||||
async def _end(db, sched, host, op, *, ok, message, op_status=None, **result):
|
||||
handover = bool((sched.progress or {}).get("handover"))
|
||||
sched.status = "succeeded" if ok else "failed"
|
||||
sched.phase = None
|
||||
sched.result = {"handover": True, **result} if ok else {"error": message[:900], **result}
|
||||
sched.result = ({"handover": handover, "auto_verified": bool(sched.auto_verify), **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)
|
||||
logger.warning("Geplante Wartung %s beendet: %s", sched.id, message)
|
||||
|
||||
|
||||
async def _toggle(host, expected: str) -> None:
|
||||
@@ -230,6 +309,7 @@ async def _start_handover(db: AsyncSession, sched: ScheduledMaintenance, host: O
|
||||
await fail(" ".join(blocked))
|
||||
return
|
||||
|
||||
boot_before = await _boot_time(host) if sched.auto_verify else None
|
||||
now = _now()
|
||||
op = HostOperation(
|
||||
host_id=host.id, host_name=host.name, actor=sched.actor, action=sched.action,
|
||||
@@ -237,6 +317,7 @@ async def _start_handover(db: AsyncSession, sched: ScheduledMaintenance, host: O
|
||||
)
|
||||
db.add(op)
|
||||
sched.status = "running"
|
||||
_progress(sched, handover=True, boot_before=boot_before, reboot_expected=_reboot_expected(sched.action, state))
|
||||
await db.commit()
|
||||
sched.host_operation_id = op.id
|
||||
await _audit(host, sched, op, "carp_enter", "requested", snapshot=state)
|
||||
@@ -255,32 +336,56 @@ async def _start_handover(db: AsyncSession, sched: ScheduledMaintenance, host: O
|
||||
|
||||
|
||||
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))
|
||||
"""One step for every run in progress (handover and/or auto_verify). Returns how many are still running."""
|
||||
ids = list((await db.scalars(
|
||||
select(ScheduledMaintenance.id).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")
|
||||
await db.commit()
|
||||
still_running = 0
|
||||
for sched_id in ids:
|
||||
# Locked for the whole step: a second backend skips the row instead of toggling or submitting twice.
|
||||
sched = await db.scalar(
|
||||
select(ScheduledMaintenance)
|
||||
.where(ScheduledMaintenance.id == sched_id, ScheduledMaintenance.status == "running",
|
||||
ScheduledMaintenance.phase.is_not(None))
|
||||
.with_for_update(skip_locked=True)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
if sched is None:
|
||||
await db.commit()
|
||||
still_running += 1 if await db.scalar(select(ScheduledMaintenance.status).where(ScheduledMaintenance.id == sched_id)) == "running" else 0
|
||||
await db.commit()
|
||||
continue
|
||||
with _as_actor(sched):
|
||||
await _advance_one(db, sched)
|
||||
await db.commit()
|
||||
if sched.status == "running":
|
||||
still_running += 1
|
||||
return still_running
|
||||
|
||||
|
||||
async def _advance_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
||||
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.
|
||||
hint = f" CARP-Rolle von {host.name} prüfen." if (sched.progress or {}).get("handover") else ""
|
||||
sched.status, sched.phase = "failed", None
|
||||
sched.result = {"error": f"Vorgang von Hand beendet, Ablauf gestoppt.{hint}"}
|
||||
await db.commit()
|
||||
return
|
||||
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:
|
||||
hint = (f"{host.name} kann im CARP-Wartungsmodus verblieben sein — Rollen prüfen."
|
||||
if (sched.progress or {}).get("handover") else f"Zustand von {host.name} prüfen.")
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"Unerwarteter Fehler während der geplanten Wartung ({type(exc).__name__}). {hint}")
|
||||
|
||||
def _since(sched: ScheduledMaintenance) -> timedelta:
|
||||
return _now() - (sched.phase_since or _now())
|
||||
|
||||
@@ -352,17 +457,75 @@ async def _step_job_wait(db, sched, host, op):
|
||||
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()
|
||||
sched.phase = "verify" if sched.auto_verify else "leave"
|
||||
sched.phase_since = _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.")
|
||||
if prog.get("handover"):
|
||||
text = (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.")
|
||||
else:
|
||||
text = f"Auftrag „{sched.action}“ auf {host.name} nicht rechtzeitig abgeschlossen. Bitte {host.name} prüfen."
|
||||
await _end(db, sched, host, op, ok=False, message=text)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _step_verify(db, sched, host, op):
|
||||
"""auto_verify: is the job really over and the host healthy? Read-only, never repeats the action."""
|
||||
prog = dict(sched.progress or {})
|
||||
handover = bool(prog.get("handover"))
|
||||
|
||||
async def wait_or_give_up(why: str):
|
||||
if _since(sched) >= VERIFY_TIMEOUT:
|
||||
tail = (f" {host.name} bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr." if handover else "")
|
||||
await _end(db, sched, host, op, ok=False,
|
||||
message=f"Automatische Prüfung nach „{sched.action}“ nicht bestanden: {why}.{tail} "
|
||||
f"Bitte {host.name} prüfen und die Wartung von Hand abschließen.")
|
||||
return
|
||||
_progress(sched, waiting_for=why)
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
running = (await client.get_firmware_running(host)).get("status")
|
||||
except client.OpnsenseAPIError:
|
||||
running = None
|
||||
_progress(sched, seen_down=True)
|
||||
if running != "ready":
|
||||
_progress(sched, ready_since=None)
|
||||
await wait_or_give_up("Firmware-Auftrag läuft noch oder Host nicht erreichbar")
|
||||
return
|
||||
if not prog.get("ready_since"):
|
||||
_progress(sched, ready_since=_now().isoformat())
|
||||
await db.commit()
|
||||
return
|
||||
if _now() - datetime.fromisoformat(prog["ready_since"]) < VERIFY_SETTLE:
|
||||
await db.commit()
|
||||
return
|
||||
boot_now = await _boot_time(host)
|
||||
rebooted = bool(prog.get("seen_down")) or bool(prog.get("boot_before") and boot_now and boot_now != prog["boot_before"])
|
||||
if prog.get("reboot_expected") and not rebooted:
|
||||
await wait_or_give_up("der erwartete Neustart wurde nicht beobachtet")
|
||||
return
|
||||
try:
|
||||
version = (await client.get_firmware_info(host)).get("product_version")
|
||||
except client.OpnsenseAPIError:
|
||||
version = None
|
||||
online = (await get_statuses([host])).get(host.id, {}).get("status") == "online"
|
||||
if not version or not online:
|
||||
await wait_or_give_up("Host nicht vollständig erreichbar (Version/Status nicht lesbar)")
|
||||
return
|
||||
_progress(sched, version_after=version, boot_after=boot_now, rebooted=rebooted, waiting_for=None)
|
||||
if handover:
|
||||
sched.phase, sched.phase_since = "leave", _now() # only now take the traffic back
|
||||
await db.commit()
|
||||
return
|
||||
await _end(db, sched, host, op, ok=True,
|
||||
message=f"Automatisch geprüft: {host.name} erreichbar, Firmware-Auftrag beendet, Version {version}.",
|
||||
version_before=prog.get("version_before"), version_after=version, rebooted=rebooted)
|
||||
|
||||
|
||||
async def _step_leave(db, sched, host, op):
|
||||
me, _partner = await _roles(db, host)
|
||||
if me.get("status") != "online":
|
||||
@@ -397,6 +560,8 @@ async def _step_leave_wait(db, sched, host, op):
|
||||
except client.OpnsenseAPIError:
|
||||
pass
|
||||
extra["version_before"] = (sched.progress or {}).get("version_before")
|
||||
if sched.auto_verify:
|
||||
extra["rebooted"] = bool((sched.progress or {}).get("rebooted")) # what verify saw before leaving
|
||||
text = "Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt."
|
||||
if extra.get("reboot_required"):
|
||||
text += " Ein Neustart ist noch erforderlich."
|
||||
@@ -410,6 +575,7 @@ async def _step_leave_wait(db, sched, host, op):
|
||||
_STEPS = {
|
||||
"handover_wait": _step_handover_wait,
|
||||
"job_wait": _step_job_wait,
|
||||
"verify": _step_verify,
|
||||
"leave": _step_leave,
|
||||
"leave_wait": _step_leave_wait,
|
||||
}
|
||||
@@ -17,8 +17,10 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.update_check_settings import DEFAULT_FEED_URL, UpdateCheckSettings
|
||||
from app.version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,6 +40,7 @@ class _State:
|
||||
latest: str | None = None
|
||||
release_url: str | None = None
|
||||
error: str | None = None
|
||||
url: str | None = None
|
||||
|
||||
|
||||
_state = _State()
|
||||
@@ -45,11 +48,41 @@ _lock = asyncio.Lock()
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Nur für Tests."""
|
||||
"""Verwirft den gemerkten Stand (Tests, geänderter Feed)."""
|
||||
global _state
|
||||
_state = _State()
|
||||
|
||||
|
||||
_SINGLETON_ID = 1
|
||||
_URL = re.compile(r"^https?://[^\s<>\"']+$")
|
||||
|
||||
|
||||
def valid_feed_url(value: str) -> bool:
|
||||
return bool(_URL.match(value)) and len(value) <= 500
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> UpdateCheckSettings:
|
||||
row = await db.get(UpdateCheckSettings, _SINGLETON_ID)
|
||||
if row is None:
|
||||
row = UpdateCheckSettings(id=_SINGLETON_ID, enabled=False, feed_url=DEFAULT_FEED_URL)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def managed_by_server() -> bool:
|
||||
"""UPDATE_CHECK_URL in the server's .env wins over the setting in the web UI."""
|
||||
return bool(settings.update_check_url.strip())
|
||||
|
||||
|
||||
async def feed_url(db: AsyncSession) -> str:
|
||||
"""The feed to ask, or "" while the check is off."""
|
||||
if managed_by_server():
|
||||
return settings.update_check_url.strip()
|
||||
row = await get_settings(db)
|
||||
return row.feed_url.strip() if row.enabled else ""
|
||||
|
||||
|
||||
def _parse(tag: str) -> tuple[int, int, int] | None:
|
||||
match = _STABLE_TAG.match(tag.strip())
|
||||
return tuple(int(part) for part in match.groups()) if match else None # type: ignore[return-value]
|
||||
@@ -62,10 +95,10 @@ def _safe_url(value: object) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch() -> tuple[str, str | None]:
|
||||
async def _fetch(url: str) -> tuple[str, str | None]:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, follow_redirects=True, max_redirects=3) as http:
|
||||
response = await http.get(
|
||||
settings.update_check_url,
|
||||
url,
|
||||
headers={"Accept": "application/json", "User-Agent": f"OPNexus/{APP_VERSION}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -78,9 +111,10 @@ async def _fetch() -> tuple[str, str | None]:
|
||||
return tag, _safe_url(data.get("html_url"))
|
||||
|
||||
|
||||
async def check() -> dict:
|
||||
async def check(db: AsyncSession) -> dict:
|
||||
"""Aktueller Stand für GET /api/system/version-check."""
|
||||
enabled = bool(settings.update_check_url.strip())
|
||||
url = await feed_url(db)
|
||||
enabled = bool(url)
|
||||
current = _parse(APP_VERSION)
|
||||
result = {
|
||||
"enabled": enabled,
|
||||
@@ -95,6 +129,9 @@ async def check() -> dict:
|
||||
return result
|
||||
|
||||
async with _lock:
|
||||
if _state.url != url: # Feed geändert: alter Stand gehört nicht mehr dazu
|
||||
reset_cache()
|
||||
_state.url = url
|
||||
now = datetime.now(timezone.utc)
|
||||
interval = timedelta(hours=max(1, settings.update_check_interval_hours))
|
||||
fresh = _state.checked_at is not None and now - _state.checked_at < interval
|
||||
@@ -102,12 +139,12 @@ async def check() -> dict:
|
||||
if not fresh and not backing_off:
|
||||
_state.attempted_at = now
|
||||
try:
|
||||
tag, url = await _fetch()
|
||||
tag, release_url = await _fetch(url)
|
||||
if _parse(tag) is None:
|
||||
# z. B. eine Vorabversion — kein Fehler, nur kein Update.
|
||||
_state.latest, _state.release_url = None, None
|
||||
else:
|
||||
_state.latest, _state.release_url = tag.lstrip("v"), url
|
||||
_state.latest, _state.release_url = tag.lstrip("v"), release_url
|
||||
_state.checked_at, _state.error = now, None
|
||||
except Exception as exc: # Netz, HTTP, JSON, Format: alles nur "geht gerade nicht"
|
||||
logger.warning("Update-Check fehlgeschlagen: %s", exc.__class__.__name__)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Destination check for URLs OPNexus is told to call (notification channels, webhook).
|
||||
|
||||
Admins legitimately point these at servers on their own network (a self-hosted ntfy, Gotify or
|
||||
Matrix), so private ranges are allowed. What is refused is what cannot be a notification target:
|
||||
anything but http(s), credentials inside the URL, and addresses that only ever mean "this machine"
|
||||
or "the cloud metadata service" (loopback, link-local such as 169.254.169.254, unspecified,
|
||||
multicast). Redirects are never followed by the callers, so a public host cannot bounce a request
|
||||
to such an address. The hostname is resolved at send time and every address is checked (a name that
|
||||
does not resolve is let through; the request then fails on its own).
|
||||
"""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import contextvars
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# httpx logs every request line ("HTTP Request: POST https://api.telegram.org/bot<token>/sendMessage"). For
|
||||
# targets whose URL is the credential that line must not reach the logs: callers wrap the request in muted().
|
||||
_muted = contextvars.ContextVar("url_guard_muted", default=False)
|
||||
|
||||
|
||||
class _MuteFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return not _muted.get()
|
||||
|
||||
|
||||
for _name in ("httpx", "httpcore"):
|
||||
logging.getLogger(_name).addFilter(_MuteFilter())
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def muted():
|
||||
token = _muted.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_muted.reset(token)
|
||||
|
||||
|
||||
def _forbidden(ip: ipaddress._BaseAddress) -> bool:
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
return ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast
|
||||
|
||||
|
||||
def validate_url(url: str, *, max_length: int = 500) -> str:
|
||||
"""Returns the cleaned URL or raises ValueError with a short reason (shown to the admin)."""
|
||||
value = (url or "").strip()
|
||||
if not value or len(value) > max_length or any(c.isspace() for c in value):
|
||||
raise ValueError("Die Adresse ist leer, zu lang oder enthält Leerzeichen")
|
||||
try:
|
||||
parts = urlsplit(value)
|
||||
host = parts.hostname
|
||||
parts.port # noqa: B018 - raises ValueError for an invalid port
|
||||
except ValueError:
|
||||
raise ValueError("Die Adresse ist ungültig") from None
|
||||
if parts.scheme not in ("http", "https") or not host:
|
||||
raise ValueError("Die Adresse muss mit http:// oder https:// beginnen")
|
||||
if parts.username or parts.password:
|
||||
raise ValueError("Zugangsdaten gehören nicht in die Adresse")
|
||||
try:
|
||||
literal = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
literal = None
|
||||
if literal is not None and _forbidden(literal):
|
||||
raise ValueError("Diese Adresse (lokal, Link-Local oder Metadaten-Dienst) ist als Ziel nicht erlaubt")
|
||||
if literal is None and host.lower() in ("localhost", "localhost.localdomain"):
|
||||
raise ValueError("Diese Adresse (lokal, Link-Local oder Metadaten-Dienst) ist als Ziel nicht erlaubt")
|
||||
return value
|
||||
|
||||
|
||||
async def ensure_resolved_safe(url: str) -> None:
|
||||
"""Send-time check against DNS answers pointing at forbidden addresses. Raises ValueError."""
|
||||
host = urlsplit(url).hostname
|
||||
if not host:
|
||||
raise ValueError("Die Adresse ist ungültig")
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return # a literal was already checked by validate_url
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
infos = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return
|
||||
for info in infos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(info[4][0].split("%")[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if _forbidden(ip):
|
||||
raise ValueError("Der Name zeigt auf eine nicht erlaubte Adresse (lokal, Link-Local oder Metadaten-Dienst)")
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Occurrences of recurring maintenance windows.
|
||||
|
||||
Rules (deliberately simple and written down, because a firmware rollout depends on them):
|
||||
|
||||
* A window is a wall-clock start time in its own IANA time zone plus a duration. "Sunday 02:00
|
||||
Europe/Berlin" stays 02:00 local time in summer and in winter.
|
||||
* Daylight saving: a local start time that does not exist on that day (spring forward, e.g. 02:30 on
|
||||
the last Sunday of March in Europe/Berlin) moves FORWARD by the gap (03:30). A local time that
|
||||
exists twice (fall back) means the FIRST of the two (fold=0, still summer time). Both follow from
|
||||
Python's zoneinfo with fold=0 and are covered by tests.
|
||||
* The duration is real elapsed time (a 3 h window over the fall-back night still lasts 3 h).
|
||||
* Exceptions are keyed by the occurrence's local date: "skip" drops it, "move" replaces its start
|
||||
with an absolute instant (same duration and cut-off).
|
||||
* New host actions may only START within the first ``start_cutoff_minutes`` of an occurrence
|
||||
(``startable``); actions already running finish even after the window has closed.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
|
||||
|
||||
from app.models.maintenance_window import MaintenanceWindow
|
||||
|
||||
# How far ahead occurrences are searched. A monthly "5th ..." does not exist; the longest real gap
|
||||
# between two occurrences is a month, so two years is plenty and bounds the work per call.
|
||||
_SEARCH_DAYS = 2 * 366
|
||||
MAX_DURATION_MINUTES = 24 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Occurrence:
|
||||
local_date: date
|
||||
start: datetime # UTC
|
||||
end: datetime # UTC
|
||||
cutoff: datetime # UTC: no new host action after this instant
|
||||
moved: bool = False
|
||||
skipped: bool = False
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"local_date": self.local_date.isoformat(), "start": self.start.isoformat(), "end": self.end.isoformat(),
|
||||
"cutoff": self.cutoff.isoformat(), "moved": self.moved, "skipped": self.skipped}
|
||||
|
||||
|
||||
def valid_timezone(name: str) -> bool:
|
||||
if not name or name not in available_timezones():
|
||||
return False
|
||||
try:
|
||||
ZoneInfo(name)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _matches(window: MaintenanceWindow, day: date) -> bool:
|
||||
if window.recurrence == "weekly":
|
||||
return day.weekday() in (window.weekdays or [])
|
||||
if window.recurrence == "monthly":
|
||||
if window.month_mode == "day":
|
||||
return day.day == window.month_day
|
||||
if window.month_mode == "nth_weekday":
|
||||
if day.weekday() != window.month_weekday:
|
||||
return False
|
||||
if window.month_nth == -1:
|
||||
return (day + timedelta(days=7)).month != day.month
|
||||
return (day.day - 1) // 7 + 1 == window.month_nth
|
||||
return False
|
||||
|
||||
|
||||
def local_start(window: MaintenanceWindow, day: date) -> datetime:
|
||||
"""The UTC instant of the window's start on local date ``day`` (DST rules see module docstring)."""
|
||||
tz = ZoneInfo(window.timezone)
|
||||
wall = datetime.combine(day, window.start_time, tzinfo=tz).replace(fold=0)
|
||||
return wall.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _occurrence(window: MaintenanceWindow, day: date, start: datetime, *, moved: bool = False, skipped: bool = False) -> Occurrence:
|
||||
duration = timedelta(minutes=window.duration_minutes)
|
||||
cutoff = timedelta(minutes=min(window.start_cutoff_minutes, window.duration_minutes))
|
||||
return Occurrence(local_date=day, start=start, end=start + duration, cutoff=start + cutoff, moved=moved, skipped=skipped)
|
||||
|
||||
|
||||
def occurrences(window: MaintenanceWindow, after: datetime, n: int = 10, *, with_skipped: bool = False) -> list[Occurrence]:
|
||||
"""The next ``n`` occurrences whose END lies after ``after`` (so one that is open right now is first).
|
||||
``with_skipped`` lists skipped ones too (flagged, for the UI); the engine never sees them."""
|
||||
if n <= 0:
|
||||
return []
|
||||
after = after.astimezone(timezone.utc)
|
||||
tz = ZoneInfo(window.timezone)
|
||||
exceptions = {e.occurrence_date: e for e in (window.exceptions or [])}
|
||||
duration = timedelta(minutes=window.duration_minutes)
|
||||
# Start one day early: an occurrence that began yesterday (local) may still be open.
|
||||
day = (after - duration).astimezone(tz).date() - timedelta(days=1)
|
||||
found: list[Occurrence] = []
|
||||
# Moved occurrences can land anywhere, so they are collected separately and merged at the end.
|
||||
for exc in exceptions.values():
|
||||
if exc.kind == "move" and exc.moved_start is not None and _matches(window, exc.occurrence_date):
|
||||
occ = _occurrence(window, exc.occurrence_date, exc.moved_start.astimezone(timezone.utc), moved=True)
|
||||
if occ.end > after:
|
||||
found.append(occ)
|
||||
regular = 0
|
||||
for _ in range(_SEARCH_DAYS):
|
||||
if regular >= n:
|
||||
break
|
||||
if _matches(window, day):
|
||||
exc = exceptions.get(day)
|
||||
if exc is None or (with_skipped and exc.kind == "skip"):
|
||||
occ = _occurrence(window, day, local_start(window, day), skipped=exc is not None)
|
||||
if occ.end > after:
|
||||
found.append(occ)
|
||||
regular += 1
|
||||
day += timedelta(days=1)
|
||||
found.sort(key=lambda o: o.start)
|
||||
return found[:n]
|
||||
|
||||
|
||||
def current(window: MaintenanceWindow, now: datetime) -> Occurrence | None:
|
||||
"""The occurrence open at ``now`` (start <= now < end), if any."""
|
||||
if not window.enabled:
|
||||
return None
|
||||
for occ in occurrences(window, now, 3):
|
||||
if occ.start <= now < occ.end:
|
||||
return occ
|
||||
return None
|
||||
|
||||
|
||||
def startable(window: MaintenanceWindow | None, now: datetime) -> Occurrence | None:
|
||||
"""The occurrence in which a NEW host action may start right now (start <= now < cutoff)."""
|
||||
if window is None or not window.enabled:
|
||||
return None
|
||||
occ = current(window, now)
|
||||
if occ is not None and occ.start <= now < occ.cutoff:
|
||||
return occ
|
||||
return None
|
||||
|
||||
|
||||
def is_occurrence_date(window: MaintenanceWindow, day: date) -> bool:
|
||||
return _matches(window, day)
|
||||
|
||||
|
||||
def definition(window: MaintenanceWindow) -> dict:
|
||||
"""What a rollout plan's fingerprint covers: changing any of this re-requires activation."""
|
||||
return {
|
||||
"id": str(window.id), "timezone": window.timezone, "recurrence": window.recurrence,
|
||||
"weekdays": sorted(window.weekdays or []), "month_mode": window.month_mode, "month_nth": window.month_nth,
|
||||
"month_weekday": window.month_weekday, "month_day": window.month_day,
|
||||
"start_time": window.start_time.strftime("%H:%M"), "duration_minutes": window.duration_minutes,
|
||||
"start_cutoff_minutes": window.start_cutoff_minutes, "enabled": window.enabled,
|
||||
}
|
||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
"""
|
||||
|
||||
APP_VERSION = "1.23.0"
|
||||
APP_CODENAME = "Harrier"
|
||||
APP_VERSION = "1.26.0"
|
||||
APP_CODENAME = "Kite"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""OIDC: Standard-Scopes ohne "groups" (Keycloak und Entra ID lehnen den Scope als ungültig ab).
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Only rows still carrying the old default; a value somebody typed on purpose stays.
|
||||
op.execute("UPDATE oidc_settings SET scopes = 'openid profile email' WHERE scopes = 'openid profile email groups'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Versionsprüfung im Webinterface einstellbar.
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0019"
|
||||
down_revision = "0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if sa.inspect(op.get_bind()).has_table("update_check_settings"):
|
||||
return
|
||||
op.create_table(
|
||||
"update_check_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("feed_url", sa.String(length=500), server_default=_DEFAULT_FEED_URL, nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS update_check_settings")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Alarme für Gateways, Ressourcen und Dienste; zusätzliche Benachrichtigungskanäle.
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
Create Date: 2026-10-21
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0020"
|
||||
down_revision = "0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_COLUMNS = [
|
||||
("gateway_down_enabled", "boolean NOT NULL DEFAULT true"),
|
||||
("resource_threshold_enabled", "boolean NOT NULL DEFAULT false"),
|
||||
("service_down_enabled", "boolean NOT NULL DEFAULT false"),
|
||||
("gateway_loss_threshold_pct", "integer NOT NULL DEFAULT 20"),
|
||||
("gateway_delay_threshold_ms", "integer NOT NULL DEFAULT 500"),
|
||||
("disk_threshold_pct", "integer NOT NULL DEFAULT 90"),
|
||||
("memory_threshold_pct", "integer NOT NULL DEFAULT 95"),
|
||||
("swap_threshold_pct", "integer NOT NULL DEFAULT 50"),
|
||||
("load_per_cpu_threshold", "double precision NOT NULL DEFAULT 2.0"),
|
||||
("alert_confirm_samples", "integer NOT NULL DEFAULT 3"),
|
||||
("resource_confirm_samples", "integer NOT NULL DEFAULT 5"),
|
||||
("service_watchlist", "json"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for name, ddl in _COLUMNS:
|
||||
op.execute(f"ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS {name} {ddl}")
|
||||
if sa.inspect(op.get_bind()).has_table("notification_channels"):
|
||||
return
|
||||
op.create_table(
|
||||
"notification_channels",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("channel_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("config", sa.JSON(), nullable=False),
|
||||
sa.Column("secret_set", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS notification_channels")
|
||||
for name, _ddl in reversed(_COLUMNS):
|
||||
op.execute(f"ALTER TABLE notification_settings DROP COLUMN IF EXISTS {name}")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Wiederkehrende Wartungsfenster mit Ausnahmen (1.25).
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-10-21
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0021"
|
||||
down_revision = "0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# A pre-Alembic install is bridged with create_all() first (app/migrate.py), which already made the tables.
|
||||
if sa.inspect(op.get_bind()).has_table("maintenance_windows"):
|
||||
return
|
||||
op.create_table(
|
||||
"maintenance_windows",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
sa.Column("timezone", sa.String(length=64), nullable=False),
|
||||
sa.Column("recurrence", sa.String(length=16), nullable=False),
|
||||
sa.Column("weekdays", sa.JSON(), nullable=False),
|
||||
sa.Column("month_mode", sa.String(length=16), nullable=True),
|
||||
sa.Column("month_nth", sa.Integer(), nullable=True),
|
||||
sa.Column("month_weekday", sa.Integer(), nullable=True),
|
||||
sa.Column("month_day", sa.Integer(), nullable=True),
|
||||
sa.Column("start_time", sa.Time(), nullable=False),
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=False),
|
||||
sa.Column("start_cutoff_minutes", sa.Integer(), server_default="60", nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("created_by", sa.String(length=150), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.create_table(
|
||||
"maintenance_window_exceptions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("window_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("occurrence_date", sa.Date(), nullable=False),
|
||||
sa.Column("kind", sa.String(length=8), nullable=False),
|
||||
sa.Column("moved_start", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("reason", sa.String(length=300), nullable=False),
|
||||
sa.Column("actor", sa.String(length=150), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["window_id"], ["maintenance_windows.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("window_id", "occurrence_date", name="uq_window_exception_date"),
|
||||
)
|
||||
op.create_index("ix_maintenance_window_exceptions_window_id", "maintenance_window_exceptions", ["window_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS maintenance_window_exceptions")
|
||||
op.execute("DROP TABLE IF EXISTS maintenance_windows")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Geplante Wartung: automatische Prüfung nach dem Auftrag (auto_verify) und Herkunft (manuell/Rollout).
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
Create Date: 2026-10-21
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0022"
|
||||
down_revision = "0021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS auto_verify boolean NOT NULL DEFAULT false")
|
||||
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS origin varchar(16) NOT NULL DEFAULT 'manual'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE scheduled_maintenance DROP COLUMN IF EXISTS origin")
|
||||
op.execute("ALTER TABLE scheduled_maintenance DROP COLUMN IF EXISTS auto_verify")
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Firmware-Rollouts in Wellen: Pläne, Läufe, Hosts je Lauf; Benachrichtigung bei Rollout-Problemen (1.25).
|
||||
|
||||
Revision ID: 0023
|
||||
Revises: 0022
|
||||
Create Date: 2026-10-21
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0023"
|
||||
down_revision = "0022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
UUID = postgresql.UUID(as_uuid=True)
|
||||
TS = sa.DateTime(timezone=True)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS rollout_problems_enabled boolean NOT NULL DEFAULT true")
|
||||
# A pre-Alembic install is bridged with create_all() first (app/migrate.py), which already made the tables.
|
||||
if sa.inspect(op.get_bind()).has_table("rollout_plans"):
|
||||
return
|
||||
op.create_table(
|
||||
"rollout_plans",
|
||||
sa.Column("id", UUID, nullable=False),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("action", sa.String(16), nullable=False),
|
||||
sa.Column("window_id", UUID, nullable=True),
|
||||
sa.Column("auto_start", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("gate_checks", sa.JSON(), nullable=False),
|
||||
sa.Column("compliance_mode", sa.String(8), server_default="warn", nullable=False),
|
||||
sa.Column("activated_by", sa.String(150), nullable=True),
|
||||
sa.Column("activated_at", TS, nullable=True),
|
||||
sa.Column("activated_fingerprint", sa.String(64), nullable=True),
|
||||
sa.Column("auto_start_error", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by", sa.String(150), nullable=False),
|
||||
sa.Column("created_at", TS, nullable=False),
|
||||
sa.Column("updated_at", TS, nullable=False),
|
||||
sa.ForeignKeyConstraint(["window_id"], ["maintenance_windows.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
op.create_index("ix_rollout_plans_window_id", "rollout_plans", ["window_id"])
|
||||
op.create_table(
|
||||
"rollout_plan_waves",
|
||||
sa.Column("id", UUID, nullable=False),
|
||||
sa.Column("plan_id", UUID, nullable=False),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
sa.Column("label", sa.String(60), nullable=False),
|
||||
sa.Column("soak_minutes", sa.Integer(), nullable=False),
|
||||
sa.Column("carp_handover", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("require_approval", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.ForeignKeyConstraint(["plan_id"], ["rollout_plans.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_rollout_plan_waves_plan_id", "rollout_plan_waves", ["plan_id"])
|
||||
op.create_table(
|
||||
"rollout_wave_groups",
|
||||
sa.Column("wave_id", UUID, nullable=False),
|
||||
sa.Column("group_id", UUID, nullable=False),
|
||||
sa.ForeignKeyConstraint(["wave_id"], ["rollout_plan_waves.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["group_id"], ["device_groups.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("wave_id", "group_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"rollout_runs",
|
||||
sa.Column("id", UUID, nullable=False),
|
||||
sa.Column("plan_id", UUID, nullable=True),
|
||||
sa.Column("plan_name", sa.String(120), nullable=False),
|
||||
sa.Column("plan_snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("action", sa.String(16), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("paused_from", sa.String(20), nullable=True),
|
||||
sa.Column("current_wave", sa.Integer(), nullable=False),
|
||||
sa.Column("target_version", sa.String(40), nullable=True),
|
||||
sa.Column("pause_reason", sa.String(40), nullable=True),
|
||||
sa.Column("pause_detail", sa.JSON(), nullable=True),
|
||||
sa.Column("started_by", sa.String(150), nullable=False),
|
||||
sa.Column("trigger", sa.String(8), nullable=False),
|
||||
sa.Column("window_occurrence", TS, nullable=True),
|
||||
sa.Column("revision", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("progress", sa.JSON(), nullable=False),
|
||||
sa.Column("step_at", TS, nullable=True),
|
||||
sa.Column("created_at", TS, nullable=False),
|
||||
sa.Column("finished_at", TS, nullable=True),
|
||||
sa.ForeignKeyConstraint(["plan_id"], ["rollout_plans.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("plan_id", "window_occurrence", name="uq_rollout_run_occurrence"),
|
||||
)
|
||||
op.create_index("ix_rollout_runs_plan_id", "rollout_runs", ["plan_id"])
|
||||
op.create_index("ix_rollout_runs_status", "rollout_runs", ["status"])
|
||||
op.create_table(
|
||||
"rollout_run_waves",
|
||||
sa.Column("id", UUID, nullable=False),
|
||||
sa.Column("run_id", UUID, nullable=False),
|
||||
sa.Column("index", sa.Integer(), nullable=False),
|
||||
sa.Column("label", sa.String(60), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("baseline", sa.JSON(), nullable=True),
|
||||
sa.Column("gate_result", sa.JSON(), nullable=True),
|
||||
sa.Column("soak_until", TS, nullable=True),
|
||||
sa.Column("gate_attempts", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("approved_by", sa.String(150), nullable=True),
|
||||
sa.Column("started_at", TS, nullable=True),
|
||||
sa.Column("finished_at", TS, nullable=True),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["rollout_runs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_rollout_run_waves_run_id", "rollout_run_waves", ["run_id"])
|
||||
op.create_table(
|
||||
"rollout_run_hosts",
|
||||
sa.Column("id", UUID, nullable=False),
|
||||
sa.Column("run_id", UUID, nullable=False),
|
||||
sa.Column("wave_index", sa.Integer(), nullable=False),
|
||||
sa.Column("host_id", UUID, nullable=True),
|
||||
sa.Column("host_name", sa.String(120), nullable=False),
|
||||
sa.Column("cluster_name", sa.String(120), nullable=True),
|
||||
sa.Column("order", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False),
|
||||
sa.Column("scheduled_maintenance_id", UUID, nullable=True),
|
||||
sa.Column("version_before", sa.String(40), nullable=True),
|
||||
sa.Column("version_after", sa.String(40), nullable=True),
|
||||
sa.Column("detail", sa.JSON(), nullable=True),
|
||||
sa.Column("started_at", TS, nullable=True),
|
||||
sa.Column("finished_at", TS, nullable=True),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["rollout_runs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["scheduled_maintenance_id"], ["scheduled_maintenance.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("run_id", "host_id", name="uq_rollout_run_host"),
|
||||
)
|
||||
op.create_index("ix_rollout_run_hosts_run_id", "rollout_run_hosts", ["run_id"])
|
||||
op.create_index("ix_rollout_run_hosts_host_id", "rollout_run_hosts", ["host_id"])
|
||||
op.create_index("ix_rollout_run_hosts_scheduled_maintenance_id", "rollout_run_hosts", ["scheduled_maintenance_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("rollout_run_hosts", "rollout_run_waves", "rollout_runs", "rollout_wave_groups", "rollout_plan_waves", "rollout_plans"):
|
||||
op.execute(f"DROP TABLE IF EXISTS {table}")
|
||||
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS rollout_problems_enabled")
|
||||
@@ -13,3 +13,4 @@ respx==0.23.1
|
||||
cryptography==50.0.2
|
||||
alembic==1.20.0
|
||||
PyJWT==2.10.1
|
||||
tzdata==2025.2
|
||||
+1003
File diff suppressed because it is too large.
Load diff
@@ -279,3 +279,18 @@ async def test_openapi_schema_is_served_behind_auth_with_bearer_scheme(client, a
|
||||
assert (await api.get("/api/openapi.json")).status_code == 200
|
||||
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
|
||||
assert (await client.get("/docs")).status_code != 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changing_your_own_password_revokes_your_api_tokens(client):
|
||||
assert (await client.post("/api/users", json={"username": "pwtok", "password": "first-password-12", "role": "operator"})).status_code == 201
|
||||
user = await _login("pwtok", "first-password-12")
|
||||
try:
|
||||
token = (await user.post("/api/tokens", json={"name": "ci", "scope": "read", "current_password": "first-password-12"})).json()["token"]
|
||||
async with _bearer(token) as api:
|
||||
assert (await api.get("/api/hosts")).status_code == 200
|
||||
resp = await user.post("/api/auth/change-password", json={"current_password": "first-password-12", "new_password": "second-password-34"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert (await api.get("/api/hosts")).status_code == 401
|
||||
finally:
|
||||
await user.aclose()
|
||||
@@ -374,9 +374,9 @@ async def test_settings_changes_are_audited_without_secrets(client):
|
||||
|
||||
# ── config diff ──────────────────────────────────────────────────────────
|
||||
|
||||
def _xml(*, hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||
def _xml(*, otp="OTPSEED1", hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||
return (f"<opnsense><revision><time>{revision}</time></revision><system><hostname>{hostname}</hostname>"
|
||||
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey></user>"
|
||||
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey><otp_seed>{otp}</otp_seed><pwd>PW-{password}</pwd></user>"
|
||||
f"<dnsserver>{dns}</dnsserver></system><cert><crt>{cert}</crt><keylength>{keylength}</keylength></cert></opnsense>").encode()
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ async def test_diff_shows_changes_and_hides_every_secret(client):
|
||||
assert "9.9.9.9" in text and "<keylength>2048</keylength>" in text # ordinary values stay readable
|
||||
for secret in ("SUPERSECRET2", "KEY-SUPERSECRET2", "p1", "A" * 50, "B" * 50):
|
||||
assert secret not in text, secret
|
||||
assert "‹sha256:" in text # a changed secret is still visible as a change
|
||||
assert "‹" in text # a changed secret is still visible as a change
|
||||
assert "r1" not in text and "r2" not in text # the volatile revision block is ignored
|
||||
|
||||
|
||||
@@ -433,3 +433,25 @@ async def test_diff_is_capped(client):
|
||||
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many.replace('1.1.1', '2.2.2')}</system></opnsense>".encode())
|
||||
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
assert body["truncated"] is True and len(body["lines"]) == 3000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_hides_otp_seeds_and_uses_a_salt_per_request(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
backup_store.save_snapshot(host["id"], _xml())
|
||||
backup_store.save_snapshot(host["id"], _xml(otp="OTPSEED2", password="p9", dns="9.9.9.9"))
|
||||
first = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
second = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
text = "\n".join(first["lines"])
|
||||
for secret in ("OTPSEED1", "OTPSEED2", "PW-p9", "PW-p1"):
|
||||
assert secret not in text, secret
|
||||
import hashlib
|
||||
assert hashlib.sha256(b"OTPSEED1").hexdigest()[:12] not in text # no unsalted hash to run a dictionary against
|
||||
assert first["lines"] != second["lines"] # a fresh salt per request: hashes cannot be matched across requests
|
||||
|
||||
|
||||
def test_signed_host_has_no_default_port_but_keeps_a_custom_one():
|
||||
plain = s3_client.S3Client(endpoint="https://s3.test:443", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
assert plain.netloc == "s3.test" and plain._target("k")[0] == "s3.test"
|
||||
custom = s3_client.S3Client(endpoint="https://s3.test:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
assert custom.netloc == "s3.test:9000"
|
||||
@@ -69,6 +69,43 @@ async def test_check_carp_health_healthy_reports_nothing(client, respx_mock):
|
||||
assert problems == {}
|
||||
|
||||
|
||||
def _vips(**states: str) -> dict:
|
||||
return {"rows": [{"vhid": v, "status": st} for v, st in states.items()],
|
||||
"carp": {"allow": "1", "maintenancemode": "0", "demotion": "0"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_carp_health_detects_a_vip_that_is_down_on_one_node(client, respx_mock):
|
||||
# 2026-10-20: VHID 130 was DISABLED on the primary and taken over by the secondary; per host it looked fine.
|
||||
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "DISABLED"})))
|
||||
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
|
||||
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
|
||||
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
|
||||
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_carp_health(db)
|
||||
|
||||
assert {p["condition"] for p in problems.values()} == {"vip_down"}
|
||||
assert next(iter(problems.values()))["vhids"] == ["130"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_carp_health_detects_a_partial_failover(client, respx_mock):
|
||||
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "BACKUP"})))
|
||||
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
|
||||
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
|
||||
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
|
||||
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
|
||||
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_carp_health(db)
|
||||
|
||||
assert [p["condition"] for p in problems.values()] == ["role_mismatch"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_carp_health_ignores_single_reachable_host(client, respx_mock):
|
||||
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(return_value=httpx.Response(200, json=_vip("MASTER")))
|
||||
|
||||
@@ -363,3 +363,19 @@ async def test_alerts_are_off_by_default_and_unreachable_hosts_raise_none(client
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_truncated_config_does_not_stop_the_others(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
bad = await _create_host(client, "a-bad", "https://bad.test")
|
||||
good = await _create_host(client, "b-good", "https://good.test")
|
||||
for base, body in (("bad", b"<opnsense><system><webgui>"), ("good", _config())):
|
||||
respx_mock.get(f"https://{base}.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=body))
|
||||
respx_mock.get(f"https://{base}.test/api/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": "none"}))
|
||||
respx_mock.get(f"https://{base}.test/api/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
|
||||
respx_mock.get(f"https://{base}.test/api/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
resp = await client.post("/api/compliance/check")
|
||||
assert resp.status_code == 200, resp.text
|
||||
by_name = {h["host_name"]: h for h in resp.json()}
|
||||
assert by_name["a-bad"]["error"] == "invalid_config" and by_name["b-good"]["score"] is not None
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Recurring maintenance windows: occurrence maths (weekly, monthly, DST, exceptions, cut-off)."""
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.maintenance_window import MaintenanceWindow, MaintenanceWindowException
|
||||
from app.services import window_service as ws
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def _w(**kw) -> MaintenanceWindow:
|
||||
base = dict(name="w", timezone="Europe/Berlin", recurrence="weekly", weekdays=[6], start_time=time(2, 0),
|
||||
duration_minutes=180, start_cutoff_minutes=60, enabled=True, created_by="admin")
|
||||
base.update(kw)
|
||||
w = MaintenanceWindow(**base)
|
||||
w.exceptions = kw.get("exceptions", [])
|
||||
return w
|
||||
|
||||
|
||||
def _starts(w, after, n=4, **kw):
|
||||
return [o.start for o in ws.occurrences(w, after, n, **kw)]
|
||||
|
||||
|
||||
def test_weekly_sunday_in_summer_and_winter_keeps_local_time():
|
||||
w = _w()
|
||||
# 2026-10-18 is a Sunday (CEST, +2), 2026-11-01 a Sunday (CET, +1)
|
||||
starts = _starts(w, datetime(2026, 10, 12, tzinfo=UTC), 4)
|
||||
assert starts[0] == datetime(2026, 10, 18, 0, 0, tzinfo=UTC)
|
||||
assert starts[2] == datetime(2026, 11, 1, 1, 0, tzinfo=UTC)
|
||||
assert all(s.astimezone(ws.ZoneInfo("Europe/Berlin")).hour == 2 for s in starts)
|
||||
|
||||
|
||||
def test_weekly_several_days_in_order():
|
||||
w = _w(weekdays=[1, 3], timezone="UTC", start_time=time(22, 0), duration_minutes=60)
|
||||
starts = _starts(w, datetime(2026, 10, 19, tzinfo=UTC), 3) # Monday
|
||||
assert starts == [datetime(2026, 10, 20, 22, tzinfo=UTC), datetime(2026, 10, 22, 22, tzinfo=UTC),
|
||||
datetime(2026, 10, 27, 22, tzinfo=UTC)]
|
||||
|
||||
|
||||
def test_monthly_nth_weekday_first_and_last():
|
||||
first_tue = _w(recurrence="monthly", weekdays=[], month_mode="nth_weekday", month_nth=1, month_weekday=1, timezone="UTC")
|
||||
assert [s.date() for s in _starts(first_tue, datetime(2026, 10, 1, tzinfo=UTC), 3)] == [
|
||||
date(2026, 10, 6), date(2026, 11, 3), date(2026, 12, 1)]
|
||||
last_sun = _w(recurrence="monthly", weekdays=[], month_mode="nth_weekday", month_nth=-1, month_weekday=6, timezone="UTC")
|
||||
assert [s.date() for s in _starts(last_sun, datetime(2026, 10, 1, tzinfo=UTC), 3)] == [
|
||||
date(2026, 10, 25), date(2026, 11, 29), date(2026, 12, 27)]
|
||||
|
||||
|
||||
def test_monthly_fixed_day():
|
||||
w = _w(recurrence="monthly", weekdays=[], month_mode="day", month_day=15, timezone="UTC")
|
||||
assert [s.date() for s in _starts(w, datetime(2026, 10, 16, tzinfo=UTC), 2)] == [date(2026, 11, 15), date(2026, 12, 15)]
|
||||
|
||||
|
||||
def test_spring_forward_missing_time_moves_forward():
|
||||
# 2026-03-29 (last Sunday of March): 02:00-03:00 local does not exist in Europe/Berlin.
|
||||
w = _w(start_time=time(2, 30))
|
||||
occ = ws.occurrences(w, datetime(2026, 3, 28, tzinfo=UTC), 1)[0]
|
||||
assert occ.local_date == date(2026, 3, 29)
|
||||
assert occ.start == datetime(2026, 3, 29, 1, 30, tzinfo=UTC) # = 03:30 CEST
|
||||
assert occ.start.astimezone(ws.ZoneInfo("Europe/Berlin")).strftime("%H:%M") == "03:30"
|
||||
|
||||
|
||||
def test_fall_back_ambiguous_time_uses_the_first_and_duration_is_real_time():
|
||||
# 2026-10-25 (last Sunday of October): 02:00-03:00 local happens twice.
|
||||
w = _w(start_time=time(2, 30), duration_minutes=120)
|
||||
occ = ws.occurrences(w, datetime(2026, 10, 24, tzinfo=UTC), 1)[0]
|
||||
assert occ.start == datetime(2026, 10, 25, 0, 30, tzinfo=UTC) # 02:30 CEST (first pass)
|
||||
assert occ.end - occ.start == timedelta(hours=2)
|
||||
|
||||
|
||||
def test_skip_and_move_exceptions():
|
||||
moved = datetime(2026, 10, 31, 20, 0, tzinfo=UTC) # Saturday evening instead of Sunday night
|
||||
w = _w(exceptions=[
|
||||
MaintenanceWindowException(occurrence_date=date(2026, 10, 25), kind="skip", reason="x", actor="a"),
|
||||
MaintenanceWindowException(occurrence_date=date(2026, 11, 1), kind="move", moved_start=moved, reason="x", actor="a"),
|
||||
])
|
||||
occs = ws.occurrences(w, datetime(2026, 10, 19, tzinfo=UTC), 3)
|
||||
assert [o.local_date for o in occs] == [date(2026, 11, 1), date(2026, 11, 8), date(2026, 11, 15)]
|
||||
assert occs[0].moved and occs[0].start == moved and occs[0].end == moved + timedelta(minutes=180)
|
||||
listed = ws.occurrences(w, datetime(2026, 10, 19, tzinfo=UTC), 3, with_skipped=True)
|
||||
assert [(o.local_date, o.skipped, o.moved) for o in listed] == [
|
||||
(date(2026, 10, 25), True, False), (date(2026, 11, 1), False, True), (date(2026, 11, 8), False, False)]
|
||||
|
||||
|
||||
def test_open_window_is_current_and_cutoff_limits_new_starts():
|
||||
w = _w(timezone="UTC", weekdays=[6], start_time=time(2, 0), duration_minutes=180, start_cutoff_minutes=60)
|
||||
start = datetime(2026, 10, 18, 2, 0, tzinfo=UTC)
|
||||
assert ws.current(w, start - timedelta(seconds=1)) is None
|
||||
assert ws.current(w, start).start == start
|
||||
assert ws.startable(w, start + timedelta(minutes=59)) is not None
|
||||
assert ws.startable(w, start + timedelta(minutes=60)) is None # past the cut-off: nothing new starts
|
||||
assert ws.current(w, start + timedelta(minutes=179)) is not None # still open, running work may finish
|
||||
assert ws.current(w, start + timedelta(minutes=180)) is None
|
||||
# an occurrence that is open right now is the first one listed
|
||||
assert ws.occurrences(w, start + timedelta(minutes=30), 1)[0].start == start
|
||||
|
||||
|
||||
def test_cutoff_never_exceeds_duration_and_disabled_window_is_never_open():
|
||||
w = _w(timezone="UTC", duration_minutes=30, start_cutoff_minutes=90)
|
||||
start = datetime(2026, 10, 18, 2, 0, tzinfo=UTC)
|
||||
assert ws.occurrences(w, start, 1)[0].cutoff == start + timedelta(minutes=30)
|
||||
w.enabled = False
|
||||
assert ws.current(w, start) is None and ws.startable(w, start) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,ok", [("Europe/Berlin", True), ("UTC", True), ("Mars/Olympus", False), ("", False), ("../etc", False)])
|
||||
def test_timezone_validation(name, ok):
|
||||
assert ws.valid_timezone(name) is ok
|
||||
|
||||
|
||||
# ── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from app.database import SessionLocal # noqa: E402
|
||||
from app.models.audit import AuditEvent # noqa: E402
|
||||
|
||||
_WEEKLY = {"name": "Sonntagnacht", "timezone": "Europe/Berlin", "recurrence": "weekly", "weekdays": [6],
|
||||
"start_time": "02:00", "duration_minutes": 180, "start_cutoff_minutes": 60}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_window_crud_and_next_occurrences(client):
|
||||
resp = await client.post("/api/maintenance-windows", json=_WEEKLY)
|
||||
assert resp.status_code == 201, resp.text
|
||||
w = resp.json()
|
||||
assert w["start_time"] == "02:00" and len(w["next"]) == 5 and w["exceptions"] == [] and w["used_by"] == []
|
||||
assert (await client.post("/api/maintenance-windows", json=_WEEKLY)).json()["detail"]["code"] == "WINDOW_NAME_TAKEN"
|
||||
upd = await client.put(f"/api/maintenance-windows/{w['id']}", json={**_WEEKLY, "start_time": "03:15", "start_cutoff_minutes": 999})
|
||||
assert upd.status_code == 200 and upd.json()["start_time"] == "03:15"
|
||||
assert upd.json()["start_cutoff_minutes"] == 180 # never longer than the window itself
|
||||
occ = await client.get(f"/api/maintenance-windows/{w['id']}/occurrences?count=12")
|
||||
assert occ.status_code == 200 and len(occ.json()) == 12
|
||||
assert (await client.delete(f"/api/maintenance-windows/{w['id']}")).status_code == 204
|
||||
assert (await client.get(f"/api/maintenance-windows/{w['id']}")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change,field", [
|
||||
({"timezone": "Mars/Base"}, "timezone"),
|
||||
({"start_time": "25:00"}, "start_time"),
|
||||
({"weekdays": []}, "weekdays"),
|
||||
({"weekdays": [7]}, "weekdays"),
|
||||
({"recurrence": "monthly", "month_mode": "day", "month_day": 31}, "month_day"),
|
||||
({"recurrence": "monthly", "month_mode": "nth_weekday", "month_nth": 5, "month_weekday": 1}, "month_nth"),
|
||||
({"recurrence": "monthly"}, "month_mode"),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
async def test_window_validation(client, change, field):
|
||||
resp = await client.post("/api/maintenance-windows", json={**_WEEKLY, **change})
|
||||
assert resp.status_code == 422 and resp.json()["detail"] == {"code": "WINDOW_INVALID", "params": {"field": field}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exceptions_skip_move_and_audit(client):
|
||||
w = (await client.post("/api/maintenance-windows", json={**_WEEKLY, "timezone": "UTC"})).json()
|
||||
first = date.fromisoformat(w["next"][0]["local_date"])
|
||||
second = date.fromisoformat(w["next"][1]["local_date"])
|
||||
bad = await client.post(f"/api/maintenance-windows/{w['id']}/exceptions", json={"occurrence_date": (first + timedelta(days=1)).isoformat(), "kind": "skip"})
|
||||
assert bad.status_code == 422 and bad.json()["detail"]["params"]["field"] == "occurrence_date"
|
||||
past = await client.post(f"/api/maintenance-windows/{w['id']}/exceptions", json={"occurrence_date": (first - timedelta(days=14)).isoformat(), "kind": "skip"})
|
||||
assert past.status_code == 422
|
||||
skip = await client.post(f"/api/maintenance-windows/{w['id']}/exceptions", json={"occurrence_date": first.isoformat(), "kind": "skip", "reason": "Feiertag"})
|
||||
assert skip.status_code == 201, skip.text
|
||||
assert skip.json()["next"][0]["skipped"] is True
|
||||
dup = await client.post(f"/api/maintenance-windows/{w['id']}/exceptions", json={"occurrence_date": first.isoformat(), "kind": "skip"})
|
||||
assert dup.status_code == 409 and dup.json()["detail"]["code"] == "WINDOW_EXCEPTION_EXISTS"
|
||||
no_target = await client.post(f"/api/maintenance-windows/{w['id']}/exceptions", json={"occurrence_date": second.isoformat(), "kind": "move"})
|
||||
assert no_target.status_code == 422 and no_target.json()["detail"]["params"]["field"] == "moved_start"
|
||||
target = datetime.combine(second, time(20, 0), tzinfo=UTC) - timedelta(days=1)
|
||||
moved = await client.post(f"/api/maintenance-windows/{w['id']}/exceptions", json={"occurrence_date": second.isoformat(), "kind": "move", "moved_start": target.isoformat()})
|
||||
assert moved.status_code == 201
|
||||
regular = [o for o in moved.json()["next"] if not o["skipped"]]
|
||||
assert regular[0]["moved"] is True and datetime.fromisoformat(regular[0]["start"]) == target
|
||||
eid = moved.json()["exceptions"][1]["id"]
|
||||
after = await client.delete(f"/api/maintenance-windows/{w['id']}/exceptions/{eid}")
|
||||
assert after.status_code == 200 and len(after.json()["exceptions"]) == 1
|
||||
async with SessionLocal() as db:
|
||||
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "maintenance_window"))).all()
|
||||
assert any(e.action == "exception_skip" and e.details["reason"] == "Feiertag" for e in events)
|
||||
assert any(e.status == "accepted" and e.action == "delete" for e in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_window_writes_follow_the_deploy_right(client):
|
||||
from tests.test_operator_role import _operator
|
||||
op = await _operator(client, "winop")
|
||||
try:
|
||||
made = await op.post("/api/maintenance-windows", json={**_WEEKLY, "name": "op-window"})
|
||||
assert made.status_code == 201 # operators may prepare windows while they may deploy
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": False})
|
||||
for method, path, body in [
|
||||
("POST", "/api/maintenance-windows", {**_WEEKLY, "name": "x"}),
|
||||
("PUT", f"/api/maintenance-windows/{made.json()['id']}", _WEEKLY),
|
||||
("DELETE", f"/api/maintenance-windows/{made.json()['id']}", None),
|
||||
("POST", f"/api/maintenance-windows/{made.json()['id']}/exceptions", {"occurrence_date": "2030-01-06", "kind": "skip"}),
|
||||
]:
|
||||
resp = await op.request(method, path, json=body)
|
||||
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "DEPLOY_NEEDS_ADMIN", path
|
||||
assert (await op.get("/api/maintenance-windows")).status_code == 200
|
||||
finally:
|
||||
await op.aclose()
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
|
||||
@@ -206,3 +206,53 @@ async def test_fresh_database_needs_no_backup(scratch, monkeypatch):
|
||||
await migrate.run() # würde ohne Backup-Ziel scheitern, wenn eins verlangt würde
|
||||
from pathlib import Path
|
||||
assert not Path(settings.migration_backup_dir).exists()
|
||||
|
||||
|
||||
async def _alembic(engine, action: str, revision: str) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(lambda sync_conn: migrate._run_alembic(sync_conn, action, revision))
|
||||
|
||||
|
||||
async def test_0020_upgrade_keeps_existing_settings_and_downgrade_is_clean(scratch):
|
||||
"""1.23 -> 1.24 with a real settings row: the new alert columns get their defaults, the stored webhook
|
||||
and flags stay, the channel table appears; a downgrade removes exactly that again and re-upgrading works."""
|
||||
await _alembic(scratch, "upgrade", "0019")
|
||||
async with scratch.begin() as conn:
|
||||
# Every NOT NULL column of the 0019 table gets a value, so the row is what a real 1.23 install has.
|
||||
required = (await conn.execute(text(
|
||||
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'notification_settings' "
|
||||
"AND is_nullable = 'NO' AND column_default IS NULL"
|
||||
))).all()
|
||||
by_type = {"boolean": "true", "integer": "300", "json": "'[]'", "timestamp with time zone": "now()"}
|
||||
values = {name: by_type.get(kind, "'x'") for name, kind in required}
|
||||
values.update({"id": "1", "webhook_enabled": "true", "webhook_url": "'https://hooks.example/x'"})
|
||||
await conn.execute(text(
|
||||
f"INSERT INTO notification_settings ({', '.join(values)}) VALUES ({', '.join(values.values())})"
|
||||
))
|
||||
await _alembic(scratch, "upgrade", "head")
|
||||
async with scratch.connect() as conn:
|
||||
row = (await conn.execute(text(
|
||||
"SELECT webhook_enabled, webhook_url, gateway_down_enabled, resource_threshold_enabled, service_down_enabled, "
|
||||
"disk_threshold_pct, load_per_cpu_threshold, alert_confirm_samples, resource_confirm_samples, service_watchlist "
|
||||
"FROM notification_settings"
|
||||
))).one()
|
||||
assert tuple(row) == (True, "https://hooks.example/x", True, False, False, 90, 2.0, 3, 5, None)
|
||||
await conn.execute(text(
|
||||
"INSERT INTO notification_channels (id, name, channel_type, config, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid(), 'n', 'ntfy', '{\"topic\": \"t\"}', now(), now())"
|
||||
))
|
||||
await conn.commit()
|
||||
assert await _schema_diff(scratch) == []
|
||||
|
||||
await _alembic(scratch, "downgrade", "0019")
|
||||
async with scratch.connect() as conn:
|
||||
assert (await conn.execute(text("SELECT to_regclass('notification_channels')"))).scalar() is None
|
||||
columns = set((await conn.execute(text(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'notification_settings'"
|
||||
))).scalars().all())
|
||||
assert "gateway_down_enabled" not in columns and "service_watchlist" not in columns
|
||||
assert (await conn.execute(text("SELECT webhook_url FROM notification_settings"))).scalar() == "https://hooks.example/x"
|
||||
|
||||
await _alembic(scratch, "upgrade", "head")
|
||||
assert await _version(scratch) == _head()
|
||||
assert await _schema_diff(scratch) == []
|
||||
@@ -0,0 +1,717 @@
|
||||
"""Monitoring alerts (1.24): gateway health, resource thresholds, stopped services, extra channels."""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.notification import NotificationChannel, NotificationProblem, NotificationSettings
|
||||
from app.models.resource_sample import HostResourceSample
|
||||
from app.services import (
|
||||
gateway_health_service, monitor_checks, notification_channels, notification_secret_store, resource_monitor_service,
|
||||
url_guard,
|
||||
)
|
||||
from app.services.notification_delivery import NotificationDeliveryError
|
||||
from app.services.notification_service import run_notification_checks
|
||||
from tests.test_notifications import _create_host
|
||||
from tests.test_resource_monitor import _mock_resource_endpoints, _mock_vip
|
||||
|
||||
# What routes/gateway/status returns on a real 26.7 firewall (test-fw-3, live), plus a degraded and a dead gateway.
|
||||
STATUS_PAYLOAD = {"status": "ok", "items": [
|
||||
{"name": "WAN_GW", "address": "203.0.113.1", "status": "none", "loss": "0.0 %", "delay": "1.9 ms", "stddev": "0.3 ms", "monitor": "203.0.113.1"},
|
||||
{"name": "LTE_GW", "address": "192.0.2.9", "status": "delay+loss", "loss": "12.5 %", "delay": "310.2 ms", "stddev": "40.0 ms", "monitor": "192.0.2.9"},
|
||||
{"name": "DOWN_GW", "address": "192.0.2.1", "status": "down", "loss": "100.0 %", "delay": "~", "stddev": "~", "monitor": "192.0.2.1"},
|
||||
{"name": "WAN_DHCP6", "address": "~", "status": "none", "loss": "~", "delay": "~", "stddev": "~", "monitor": "~"},
|
||||
{"name": "OFF_GW", "address": "192.0.2.5", "status": "force_down", "loss": "~", "delay": "~", "stddev": "~", "monitor": "~"},
|
||||
]}
|
||||
|
||||
|
||||
# ── Gateway parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_gateway_status_maps_states_and_numbers():
|
||||
by_name = {g.name: g for g in gateway_health_service.parse_gateway_status(STATUS_PAYLOAD)}
|
||||
assert by_name["WAN_GW"].state == "online" and by_name["WAN_GW"].loss == 0.0 and by_name["WAN_GW"].delay == 1.9
|
||||
assert by_name["WAN_GW"].stddev == 0.3
|
||||
assert by_name["LTE_GW"].state == "degraded" and by_name["LTE_GW"].loss == 12.5
|
||||
assert by_name["DOWN_GW"].state == "offline" and by_name["DOWN_GW"].delay is None
|
||||
# nothing measured -> unknown, never "online" and never 0 ms
|
||||
assert by_name["WAN_DHCP6"].state == "unknown" and by_name["WAN_DHCP6"].loss is None and by_name["WAN_DHCP6"].address is None
|
||||
assert by_name["OFF_GW"].state == "forced_down"
|
||||
|
||||
|
||||
def test_dead_gateway_has_no_round_trip_time():
|
||||
# dpinger reports "100.0 %" loss together with "0.0 ms" when nothing answers: 0 ms must not look fast
|
||||
payload = {"items": [{"name": "GW", "status": "down", "loss": "100.0 %", "delay": "0.0 ms", "stddev": "0.0 ms", "monitor": "192.0.2.1"}]}
|
||||
gw = gateway_health_service.parse_gateway_status(payload)[0]
|
||||
assert gw.state == "offline" and gw.loss == 100.0 and gw.delay is None and gw.stddev is None
|
||||
|
||||
|
||||
def test_parse_gateway_status_tolerates_failed_answer():
|
||||
assert gateway_health_service.parse_gateway_status({"items": [], "status": "failed"}) == []
|
||||
assert gateway_health_service.parse_gateway_status(None) == []
|
||||
|
||||
|
||||
def test_is_bad_thresholds():
|
||||
by_name = {g.name: g for g in gateway_health_service.parse_gateway_status(STATUS_PAYLOAD)}
|
||||
assert gateway_health_service.is_bad(by_name["DOWN_GW"])
|
||||
assert not gateway_health_service.is_bad(by_name["OFF_GW"]) # switched off on purpose
|
||||
assert not gateway_health_service.is_bad(by_name["WAN_DHCP6"])
|
||||
assert not gateway_health_service.is_bad(by_name["LTE_GW"]) # degraded by OPNsense' limits only, own thresholds off
|
||||
assert gateway_health_service.is_bad(by_name["LTE_GW"], loss_threshold_pct=10)
|
||||
assert gateway_health_service.is_bad(by_name["LTE_GW"], delay_threshold_ms=300)
|
||||
assert not gateway_health_service.is_bad(by_name["WAN_GW"], loss_threshold_pct=10, delay_threshold_ms=300)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gateway_health_reads_live(client, respx_mock):
|
||||
_mock_vip(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(200, json=STATUS_PAYLOAD)
|
||||
resp = await client.get(f"/api/hosts/{host['id']}/gateways")
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = {g["name"]: g for g in resp.json()}
|
||||
assert rows["DOWN_GW"]["state"] == "offline" and rows["WAN_GW"]["delay"] == 1.9
|
||||
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(500)
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/gateways")).status_code == 502
|
||||
|
||||
|
||||
# ── Sampler stores the extra readings ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sampler_stores_gateways_services_swap_and_cpu(client, respx_mock):
|
||||
_mock_vip(respx_mock)
|
||||
await _create_host(client, "fw1", "https://fw1.test")
|
||||
_mock_resource_endpoints(respx_mock)
|
||||
respx_mock.get("https://fw1.test/api/diagnostics/system/systemSwap").respond(200, json={"swap": [{"device": "/dev/da0p3", "total": "8388608", "used": "2097152"}]})
|
||||
respx_mock.get("https://fw1.test/api/diagnostics/cpu_usage/getCPUType").respond(200, json=["QEMU Virtual CPU version 2.5+ (6 cores, 6 threads)"])
|
||||
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(200, json=STATUS_PAYLOAD)
|
||||
respx_mock.get("https://fw1.test/api/core/service/search").respond(200, json={"rows": [
|
||||
{"id": "unbound", "name": "unbound", "locked": 0, "running": 1},
|
||||
{"id": "dhcpd6", "name": "dhcpd6", "locked": 0, "running": 0},
|
||||
{"id": "pf", "name": "pf", "locked": 1, "running": 1},
|
||||
{"id": "3f2a", "name": "openvpn", "locked": 0, "running": 0},
|
||||
]})
|
||||
async with SessionLocal() as db:
|
||||
await resource_monitor_service.poll_all_hosts(db)
|
||||
raw = (await db.scalars(select(HostResourceSample))).one().raw
|
||||
assert raw["swap_used_pct"] == 25.0 and raw["cpu_threads"] == 6
|
||||
assert {g["name"]: g["state"] for g in raw["gateways"]}["DOWN_GW"] == "offline"
|
||||
assert raw["services"] == {"unbound": 1, "dhcpd6": 0, "openvpn/3f2a": 0} # locked services are not recorded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sampler_survives_missing_optional_endpoints(client, respx_mock):
|
||||
_mock_vip(respx_mock)
|
||||
await _create_host(client, "fw1", "https://fw1.test")
|
||||
_mock_resource_endpoints(respx_mock) # nothing for swap/cpu/gateways/services -> unmocked routes fail
|
||||
async with SessionLocal() as db:
|
||||
result = await resource_monitor_service.poll_all_hosts(db)
|
||||
row = (await db.scalars(select(HostResourceSample))).one()
|
||||
assert result["sampled"] == 1 and row.mem_used_pct == 25.0
|
||||
assert row.raw["gateways"] is None and row.raw["services"] is None and row.raw["swap_used_pct"] is None
|
||||
|
||||
|
||||
# ── Debouncing ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_verdict_needs_agreement_and_keeps_state_otherwise():
|
||||
v = monitor_checks.verdict
|
||||
assert v([True, True, True], 3, False) is True
|
||||
assert v([True, True, False], 3, False) is False # no single spike / short blip
|
||||
assert v([True, True], 3, False) is False # not enough history yet
|
||||
assert v([False, False, False], 3, True) is False # needs the full window to close
|
||||
assert v([False, True, False], 3, True) is True # flapping keeps an open alert open
|
||||
assert v([True, None, True], 3, False) is False # unreadable sample is no evidence
|
||||
assert v([None, None, None], 3, True) is True # ... and does not clear either
|
||||
assert v([True], 1, False) is True
|
||||
|
||||
|
||||
async def _add_samples(host_id, rows: list[dict], *, age_minutes: int = 0) -> None:
|
||||
"""rows: newest first; each {mem, load, disk, raw...}. One sample per minute, the newest ``age_minutes`` old."""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with SessionLocal() as db:
|
||||
for i, row in enumerate(rows):
|
||||
raw = {k: v for k, v in row.items() if k not in ("mem", "load")}
|
||||
db.add(HostResourceSample(
|
||||
host_id=host_id, sampled_at=now - timedelta(minutes=age_minutes + i),
|
||||
mem_used_pct=row.get("mem"), load_avg_1m=row.get("load"), disk_used_pct=None, raw=raw,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _gw(state="online", loss=0.0, delay=2.0, name="WAN_GW", status=None):
|
||||
return {"name": name, "status": status or {"offline": "down", "online": "none"}.get(state, state), "state": state,
|
||||
"loss": loss, "delay": delay, "stddev": 0.1}
|
||||
|
||||
|
||||
async def _set(**fields):
|
||||
async with SessionLocal() as db:
|
||||
row = await db.get(NotificationSettings, 1) or NotificationSettings(id=1)
|
||||
for k, v in fields.items():
|
||||
setattr(row, k, v)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
|
||||
|
||||
_ALL_FLAGS = [
|
||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled", "revert_failed_enabled", "maintenance_unknown_enabled",
|
||||
"carp_health_enabled", "nat_drift_enabled", "network_drift_enabled", "carp_auto_revert_failed_enabled",
|
||||
"scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
|
||||
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled",
|
||||
]
|
||||
|
||||
|
||||
async def _only(*enabled: str):
|
||||
"""Run run_notification_checks with just these checks on (the mocked firewalls are unreachable, which would
|
||||
otherwise also open host_offline problems)."""
|
||||
await _set(**{flag: flag in enabled for flag in _ALL_FLAGS})
|
||||
|
||||
|
||||
async def _open_count(db, problem_type: str | None = None) -> int:
|
||||
query = select(NotificationProblem).where(NotificationProblem.status == "open")
|
||||
if problem_type:
|
||||
query = query.where(NotificationProblem.problem_type == problem_type)
|
||||
return len((await db.scalars(query)).all())
|
||||
|
||||
|
||||
async def _gateway_problems():
|
||||
async with SessionLocal() as db:
|
||||
return await monitor_checks.check_gateway_down(db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_down_needs_consecutive_samples(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
await _add_samples(hid, [{"gateways": [_gw("offline", loss=100.0, delay=None)]}, {"gateways": [_gw("offline", 100.0, None)]}, {"gateways": [_gw()]}])
|
||||
assert dict(await _gateway_problems()) == {} # only two bad samples in a row
|
||||
|
||||
await _add_samples(hid, [{"gateways": [_gw("offline", 100.0, None)]}] * 3, age_minutes=-3) # three newer bad ones
|
||||
problems = await _gateway_problems()
|
||||
assert list(problems) == [f"{hid}:WAN_GW"]
|
||||
ctx = problems[f"{hid}:WAN_GW"]
|
||||
assert ctx["host_name"] == "fw1" and ctx["gateway"] == "WAN_GW" and ctx["grund"] == "Gateway offline"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_alert_resolves_only_after_full_recovery_and_ignores_unknown(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
bad, good = {"gateways": [_gw("offline", 100.0, None)]}, {"gateways": [_gw()]}
|
||||
await _only("gateway_down_enabled")
|
||||
await _add_samples(hid, [bad, bad, bad, good, good])
|
||||
async with SessionLocal() as db:
|
||||
await run_notification_checks(db)
|
||||
assert await _open_count(db) == 1
|
||||
|
||||
# one good sample after the outage, then a relapse: still open (flapping link must not re-notify)
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [bad, good, bad, bad, good])
|
||||
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"]
|
||||
# a sample that could not read the gateways does not clear it either
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [{"gateways": None}] * 3)
|
||||
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"]
|
||||
# three good ones close it
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [good, good, good])
|
||||
assert dict(await _gateway_problems()) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_thresholds_and_forced_down(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
lossy = {"gateways": [_gw("degraded", loss=35.0, delay=20.0, status="loss"), _gw("forced_down", None, None, name="OFF", status="force_down")]}
|
||||
await _add_samples(hid, [lossy] * 3)
|
||||
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"] # 35 % >= 20 %; the switched-off gateway is not reported
|
||||
await _set(gateway_loss_threshold_pct=0)
|
||||
assert dict(await _gateway_problems()) == {} # own loss threshold off; OPNsense says "degraded", not "down"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_samples_are_not_judged_and_keep_open_problems(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
bad = {"gateways": [_gw("offline", 100.0, None)]}
|
||||
await _only("gateway_down_enabled")
|
||||
await _add_samples(hid, [bad] * 3)
|
||||
async with SessionLocal() as db:
|
||||
await run_notification_checks(db)
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [{"gateways": [_gw()]}] * 3, age_minutes=30) # the host stopped answering 30 min ago
|
||||
async with SessionLocal() as db:
|
||||
result = await run_notification_checks(db)
|
||||
assert result["resolved"] == 0
|
||||
assert await _open_count(db) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_alert_is_delivered_once_and_resolution_follows(client, respx_mock):
|
||||
_mock_vip(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
await _only("gateway_down_enabled")
|
||||
await _set(webhook_enabled=True, webhook_url="https://hooks.test/n")
|
||||
hook = respx_mock.post("https://hooks.test/n").mock(return_value=httpx.Response(200))
|
||||
bad, good = {"gateways": [_gw("offline", 100.0, None)]}, {"gateways": [_gw()]}
|
||||
await _add_samples(hid, [bad] * 3)
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["opened"] == 1
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["opened"] == 0
|
||||
assert hook.call_count == 1 and b"Gateway" in hook.calls.last.request.content
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [good] * 3)
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["resolved"] == 1
|
||||
assert hook.call_count == 2 and b"behoben" in hook.calls.last.request.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_check_is_on_by_default_and_the_others_are_off(client):
|
||||
data = (await client.get("/api/notifications/settings")).json()
|
||||
assert data["gateway_down_enabled"] is True
|
||||
assert data["resource_threshold_enabled"] is False and data["service_down_enabled"] is False
|
||||
assert data["service_watchlist"][:2] == ["unbound", "dnsmasq"]
|
||||
|
||||
|
||||
# ── Resource thresholds ──────────────────────────────────────────────────────────
|
||||
|
||||
DISK_OK = [{"device": "zroot/ROOT/default", "type": "zfs", "used_pct": 40, "mountpoint": "/"}, {"device": "devfs", "type": "devfs", "used_pct": 100, "mountpoint": "/dev"}]
|
||||
DISK_FULL = [{"device": "zroot/ROOT/default", "type": "zfs", "used_pct": 93, "mountpoint": "/"},
|
||||
{"device": "zroot/var/log", "type": "zfs", "used_pct": 91, "mountpoint": "/var/log"},
|
||||
{"device": "devfs", "type": "devfs", "used_pct": 100, "mountpoint": "/dev"}]
|
||||
|
||||
|
||||
async def _resource_problems():
|
||||
async with SessionLocal() as db:
|
||||
return await monitor_checks.check_resource_threshold(db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disk_threshold_one_alert_per_pool_and_ignores_devfs(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
await _add_samples(hid, [{"disk": DISK_FULL, "mem": 10, "load": 0.1}] * 5)
|
||||
problems = await _resource_problems()
|
||||
assert list(problems) == [f"{hid}:disk:zroot"]
|
||||
assert problems[f"{hid}:disk:zroot"]["wert_prozent"] == 93 and problems[f"{hid}:disk:zroot"]["schwelle_prozent"] == 90
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_spike_does_not_alert_but_sustained_use_does(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
calm = {"disk": DISK_OK, "mem": 40, "load": 0.1}
|
||||
await _add_samples(hid, [{"disk": DISK_OK, "mem": 99, "load": 0.1}, calm, calm, calm, calm])
|
||||
assert dict(await _resource_problems()) == {}
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [{"disk": DISK_OK, "mem": 96, "load": 0.1}] * 5)
|
||||
assert list(await _resource_problems()) == [f"{hid}:memory"]
|
||||
await _set(memory_threshold_pct=0) # 0 = off
|
||||
assert dict(await _resource_problems()) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_and_cpu_per_thread(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
# load 6 on 2 threads = 3 per thread (>= 2.0); load 6 on 8 threads would be fine
|
||||
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 6.0, "swap_used_pct": 70.0, "cpu_threads": 2}] * 5)
|
||||
assert sorted(await _resource_problems()) == sorted([f"{hid}:cpu", f"{hid}:swap"])
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 6.0, "swap_used_pct": None, "cpu_threads": 8}] * 5)
|
||||
assert dict(await _resource_problems()) == {}
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
# unknown thread count: the load cannot be judged, so it is not reported
|
||||
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 50.0, "swap_used_pct": None, "cpu_threads": None}] * 5)
|
||||
assert dict(await _resource_problems()) == {}
|
||||
|
||||
|
||||
# ── Services ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _service_problems():
|
||||
async with SessionLocal() as db:
|
||||
return await monitor_checks.check_service_down(db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_down_only_after_it_was_seen_running(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
up = {"services": {"unbound": 1, "dhcpd6": 0, "cron": 0}}
|
||||
down = {"services": {"unbound": 0, "dhcpd6": 0, "cron": 0}}
|
||||
await _add_samples(hid, [down, down, down, up, up])
|
||||
problems = await _service_problems()
|
||||
# unbound stopped; dhcpd6 never ran; cron is not on the watchlist
|
||||
assert list(problems) == [f"{hid}:unbound"]
|
||||
assert problems[f"{hid}:unbound"]["dienst"] == "unbound"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_that_never_ran_or_stopped_long_ago_is_not_reported(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
await _add_samples(hid, [{"services": {"unbound": 0}}] * 6)
|
||||
assert dict(await _service_problems()) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_alert_resolves_when_it_runs_again_or_leaves_the_watchlist(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
up, down = {"services": {"unbound": 1}}, {"services": {"unbound": 0}}
|
||||
await _only("service_down_enabled")
|
||||
await _add_samples(hid, [down, down, down, up])
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["opened"] == 1
|
||||
await _set(service_watchlist=["ntpd"])
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["resolved"] == 1
|
||||
await _set(service_watchlist=["unbound"])
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["opened"] == 1 # reopens (the row is reused)
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(HostResourceSample.__table__.delete())
|
||||
await db.commit()
|
||||
await _add_samples(hid, [up, up, up, down])
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["resolved"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_watchlist_validation(client):
|
||||
ok = await client.put("/api/notifications/settings", json={"service_watchlist": [" unbound ", "openvpn", "unbound"]})
|
||||
assert ok.status_code == 200 and ok.json()["service_watchlist"] == ["unbound", "openvpn"]
|
||||
assert (await client.put("/api/notifications/settings", json={"service_watchlist": ["bad name!"]})).status_code == 422
|
||||
assert (await client.put("/api/notifications/settings", json={"disk_threshold_pct": 101})).status_code == 422
|
||||
assert (await client.put("/api/notifications/settings", json={"alert_confirm_samples": 0})).status_code == 422
|
||||
|
||||
|
||||
# ── Metrics and dashboard ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_and_dashboard_show_gateways_and_services(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test", cluster_name=None)
|
||||
hid = uuid.UUID(host["id"])
|
||||
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 0.1, "swap_used_pct": 5.0,
|
||||
"gateways": [_gw(), _gw("offline", 100.0, None, name="DOWN_GW")],
|
||||
"services": {"unbound": 1, "openvpn/3f2a": 0, "cron": 0}}])
|
||||
text = (await client.get("/api/metrics")).text
|
||||
assert 'opnexus_host_gateway_up{host="fw1",cluster="",gateway="WAN_GW"} 1' in text
|
||||
assert 'opnexus_host_gateway_up{host="fw1",cluster="",gateway="DOWN_GW"} 0' in text
|
||||
assert 'opnexus_host_gateway_delay_seconds{host="fw1",cluster="",gateway="WAN_GW"} 0.002' in text
|
||||
assert 'opnexus_host_gateway_loss_percent{host="fw1",cluster="",gateway="DOWN_GW"} 100' in text
|
||||
assert 'opnexus_host_swap_used_percent{host="fw1",cluster=""} 5' in text
|
||||
assert 'opnexus_host_service_running{host="fw1",cluster="",service="unbound"} 1' in text
|
||||
assert 'service="openvpn",instance="3f2a"} 0' in text
|
||||
assert 'service="cron"' not in text # not on the watchlist
|
||||
|
||||
summary = (await client.get("/api/dashboard")).json()
|
||||
gateways = {g["name"]: g for g in summary["hosts"][0]["gateways"]}
|
||||
assert gateways["DOWN_GW"]["state"] == "offline"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_has_no_gateways_without_a_fresh_sample(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _add_samples(uuid.UUID(host["id"]), [{"gateways": [_gw()]}], age_minutes=20)
|
||||
assert (await client.get("/api/dashboard")).json()["hosts"][0]["gateways"] is None
|
||||
|
||||
|
||||
# ── URL guard ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://169.254.169.254/latest/meta-data", "http://127.0.0.1:8000/x", "http://localhost/x", "http://[::1]/x",
|
||||
"http://0.0.0.0/x", "http://[::ffff:169.254.169.254]/x", "ftp://ntfy.example/x", "file:///etc/passwd",
|
||||
"https://user:[email protected]/", "https://", "not a url", "http://224.0.0.1/x", "http://[fe80::1]/x",
|
||||
])
|
||||
def test_url_guard_refuses(url):
|
||||
with pytest.raises(ValueError):
|
||||
url_guard.validate_url(url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["https://ntfy.sh", "http://10.20.30.5:8080/path", "https://gotify.lan.example/", "http://192.168.1.10"])
|
||||
def test_url_guard_allows_private_and_public_targets(url):
|
||||
assert url_guard.validate_url(url) == url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_url_guard_refuses_names_resolving_to_metadata_address(monkeypatch):
|
||||
async def fake(self, host, port, **kw):
|
||||
return [(2, 1, 6, "", ("169.254.169.254", 0))]
|
||||
monkeypatch.setattr("asyncio.base_events.BaseEventLoop.getaddrinfo", fake)
|
||||
with pytest.raises(ValueError):
|
||||
await url_guard.ensure_resolved_safe("https://innocent.example/hook")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_url_is_validated_when_saved_and_sent(client, respx_mock):
|
||||
resp = await client.put("/api/notifications/settings", json={"webhook_url": "http://169.254.169.254/x"})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "NOTIFICATION_WEBHOOK_URL_INVALID"
|
||||
row = NotificationSettings(webhook_url="http://127.0.0.1:9/x")
|
||||
from app.services import notification_delivery
|
||||
with pytest.raises(NotificationDeliveryError):
|
||||
await notification_delivery.send_webhook(row, NotificationProblem(problem_type="test", dedup_key="t", context={}), "opened")
|
||||
|
||||
|
||||
# ── Channels: formats ────────────────────────────────────────────────────────────
|
||||
|
||||
PROBLEM = NotificationProblem(problem_type="gateway_down", dedup_key="k", context={"host_name": "fw1", "gateway": "WAN_GW", "grund": "Gateway offline"})
|
||||
|
||||
|
||||
def _channel(kind: str, config: dict, secret: str | None, name: str = "c1") -> NotificationChannel:
|
||||
ch = NotificationChannel(id=uuid.uuid4(), name=name, channel_type=kind, enabled=True, config=config, secret_set=secret is not None)
|
||||
if secret is not None:
|
||||
notification_secret_store.save_secret(notification_channels.secret_name(ch.id), secret)
|
||||
return ch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ntfy_posts_json_to_root_with_bearer(respx_mock):
|
||||
ch = _channel("ntfy", {"server_url": "https://ntfy.test", "topic": "alerts", "priority": 5}, "tk_secret")
|
||||
route = respx_mock.post("https://ntfy.test/").mock(return_value=httpx.Response(200, json={"id": "x"}))
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
req = route.calls.last.request
|
||||
assert req.headers["authorization"] == "Bearer tk_secret"
|
||||
body = httpx.Response(200, content=req.content).json()
|
||||
assert body["topic"] == "alerts" and body["priority"] == 5 and body["tags"] == ["warning"]
|
||||
assert body["title"] == "OPNexus: Gateway gestört" and "gateway: WAN_GW" in body["message"]
|
||||
await notification_channels.send(ch, PROBLEM, "resolved")
|
||||
resolved = httpx.Response(200, content=route.calls.last.request.content).json()
|
||||
assert resolved["priority"] == 3 and resolved["title"].endswith("behoben") and resolved["tags"] == ["white_check_mark"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ntfy_without_token_sends_no_auth_header(respx_mock):
|
||||
ch = _channel("ntfy", {"server_url": "https://ntfy.test", "topic": "alerts"}, None)
|
||||
route = respx_mock.post("https://ntfy.test/").mock(return_value=httpx.Response(200))
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
assert "authorization" not in route.calls.last.request.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gotify_uses_key_header_not_query(respx_mock):
|
||||
ch = _channel("gotify", {"server_url": "https://gotify.test/", "priority": 8}, "AppTok123")
|
||||
route = respx_mock.post("https://gotify.test/message").mock(return_value=httpx.Response(200, json={"id": 1}))
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
req = route.calls.last.request
|
||||
assert req.headers["x-gotify-key"] == "AppTok123" and "AppTok123" not in str(req.url)
|
||||
body = httpx.Response(200, content=req.content).json()
|
||||
assert body["priority"] == 8 and body["title"] == "OPNexus: Gateway gestört"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_sendmessage_and_ok_flag(respx_mock):
|
||||
ch = _channel("telegram", {"chat_id": "-1001234", "api_base": "https://api.telegram.test"}, "123456:ABC-token")
|
||||
route = respx_mock.post("https://api.telegram.test/bot123456:ABC-token/sendMessage").mock(
|
||||
return_value=httpx.Response(200, json={"ok": True, "result": {}}))
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
body = httpx.Response(200, content=route.calls.last.request.content).json()
|
||||
assert body["chat_id"] == "-1001234" and body["text"].startswith("OPNexus: Gateway gestört\n\n") and len(body["text"]) <= 4096
|
||||
route.mock(return_value=httpx.Response(200, json={"ok": False, "description": "chat not found"}))
|
||||
with pytest.raises(NotificationDeliveryError):
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
|
||||
|
||||
def test_telegram_text_is_clipped_to_the_api_limit():
|
||||
big = NotificationProblem(problem_type="gateway_down", dedup_key="k", context={"x": "y" * 9000})
|
||||
text = notification_channels.format_telegram({"chat_id": "1"}, big, "opened")["text"]
|
||||
assert len(text) == 4096 and text.endswith("…")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teams_adaptive_card_envelope(respx_mock):
|
||||
url = "https://prod-12.westeurope.logic.azure.test/workflows/abc/triggers/manual/paths/invoke?sig=SECRETSIG"
|
||||
ch = _channel("teams", {}, url)
|
||||
route = respx_mock.post(url.split("?")[0]).mock(return_value=httpx.Response(202))
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
body = httpx.Response(200, content=route.calls.last.request.content).json()
|
||||
assert body["type"] == "message"
|
||||
att = body["attachments"][0]
|
||||
assert att["contentType"] == "application/vnd.microsoft.card.adaptive" and att["content"]["type"] == "AdaptiveCard"
|
||||
assert att["content"]["body"][0]["text"] == "OPNexus: Gateway gestört"
|
||||
assert "sig=SECRETSIG" in str(route.calls.last.request.url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_put_with_transaction_id_and_encoded_room(respx_mock):
|
||||
ch = _channel("matrix", {"homeserver_url": "https://matrix.test", "room_id": "!abc123:matrix.test"}, "syt_token")
|
||||
route = respx_mock.put(url__regex=r"^https://matrix\.test/_matrix/client/v3/rooms/%21abc123%3Amatrix\.test/send/m\.room\.message/[0-9a-f]{32}$").mock(
|
||||
return_value=httpx.Response(200, json={"event_id": "$e"}))
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
first, second = (c.request for c in route.calls)
|
||||
assert first.headers["authorization"] == "Bearer syt_token"
|
||||
assert first.url.path != second.url.path # a new transaction id per message
|
||||
body = httpx.Response(200, content=first.content).json()
|
||||
assert body["msgtype"] == "m.text" and body["body"].startswith("OPNexus: Gateway gestört")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failures_and_logs_never_contain_the_credential(respx_mock, caplog):
|
||||
token = "123456:SECRET-BOT-TOKEN"
|
||||
ch = _channel("telegram", {"chat_id": "42", "api_base": "https://api.telegram.test"}, token)
|
||||
respx_mock.post(f"https://api.telegram.test/bot{token}/sendMessage").mock(return_value=httpx.Response(401, json={"ok": False}))
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
with pytest.raises(NotificationDeliveryError) as exc_info:
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
respx_mock.post(f"https://api.telegram.test/bot{token}/sendMessage").mock(side_effect=httpx.ConnectError("boom"))
|
||||
with pytest.raises(NotificationDeliveryError) as exc_info2:
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
assert "HTTP 401" in str(exc_info.value) and "ConnectError" in str(exc_info2.value)
|
||||
for text in (str(exc_info.value), str(exc_info2.value), caplog.text):
|
||||
assert "SECRET-BOT-TOKEN" not in text
|
||||
assert exc_info.value.__cause__ is None and exc_info.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_or_unreadable_secret_fails_closed(respx_mock):
|
||||
ch = _channel("gotify", {"server_url": "https://gotify.test"}, "tok")
|
||||
notification_secret_store.delete_secret(notification_channels.secret_name(ch.id))
|
||||
with pytest.raises(NotificationDeliveryError, match="nicht lesbar"):
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
ch.secret_set = False
|
||||
with pytest.raises(NotificationDeliveryError, match="fehlen"):
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirects_are_not_followed(respx_mock):
|
||||
ch = _channel("gotify", {"server_url": "https://gotify.test"}, "tok")
|
||||
respx_mock.post("https://gotify.test/message").mock(return_value=httpx.Response(302, headers={"location": "http://169.254.169.254/"}))
|
||||
with pytest.raises(NotificationDeliveryError, match="HTTP 302"):
|
||||
await notification_channels.send(ch, PROBLEM, "opened")
|
||||
|
||||
|
||||
# ── Channels: API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_crud_keeps_the_secret_out_of_database_and_responses(client, respx_mock):
|
||||
resp = await client.post("/api/notifications/channels", json={
|
||||
"name": "Telegram Ops", "channel_type": "telegram", "config": {"chat_id": "-100123"}, "secret": "111:TOKEN-VALUE",
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
created = resp.json()
|
||||
assert created["secret_set"] is True and "TOKEN-VALUE" not in resp.text
|
||||
assert created["config"] == {"chat_id": "-100123", "api_base": "https://api.telegram.org"}
|
||||
cid = created["id"]
|
||||
assert notification_secret_store.read_secret(f"channel_{cid}") == "111:TOKEN-VALUE"
|
||||
async with SessionLocal() as db:
|
||||
row = await db.get(NotificationChannel, uuid.UUID(cid))
|
||||
assert "TOKEN-VALUE" not in str(row.config) and "TOKEN-VALUE" not in repr(row.__dict__)
|
||||
assert "TOKEN-VALUE" not in (await client.get("/api/notifications/channels")).text
|
||||
|
||||
# test button
|
||||
route = respx_mock.post("https://api.telegram.org/bot111:TOKEN-VALUE/sendMessage").mock(return_value=httpx.Response(200, json={"ok": True}))
|
||||
assert (await client.post(f"/api/notifications/channels/{cid}/test")).json() == {"success": True}
|
||||
assert b"Testbenachrichtigung" in route.calls.last.request.content
|
||||
route.mock(return_value=httpx.Response(500))
|
||||
failed = await client.post(f"/api/notifications/channels/{cid}/test")
|
||||
assert failed.status_code == 502 and failed.json()["detail"]["code"] == "NOTIFICATION_CHANNEL_TEST_FAILED"
|
||||
assert "TOKEN-VALUE" not in failed.text
|
||||
|
||||
# update: rename, disable, rotate secret, clear secret
|
||||
upd = await client.put(f"/api/notifications/channels/{cid}", json={"name": "Telegram", "enabled": False, "secret": "222:NEW"})
|
||||
assert upd.status_code == 200 and upd.json()["enabled"] is False
|
||||
assert notification_secret_store.read_secret(f"channel_{cid}") == "222:NEW"
|
||||
assert (await client.put(f"/api/notifications/channels/{cid}", json={"clear_secret": True})).json()["secret_set"] is False
|
||||
assert notification_secret_store.read_secret(f"channel_{cid}") is None
|
||||
|
||||
assert (await client.delete(f"/api/notifications/channels/{cid}")).status_code == 204
|
||||
assert (await client.get("/api/notifications/channels")).json() == []
|
||||
assert (await client.delete(f"/api/notifications/channels/{cid}")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{"name": "a", "channel_type": "ntfy", "config": {"topic": "bad topic!"}},
|
||||
{"name": "a", "channel_type": "ntfy", "config": {"topic": "ok", "server_url": "http://169.254.169.254"}},
|
||||
{"name": "a", "channel_type": "ntfy", "config": {"topic": "ok", "priority": 9}},
|
||||
{"name": "a", "channel_type": "gotify", "config": {"server_url": "https://g.test"}}, # token missing
|
||||
{"name": "a", "channel_type": "gotify", "config": {}, "secret": "t"}, # server missing
|
||||
{"name": "a", "channel_type": "telegram", "config": {"chat_id": "abc"}, "secret": "t"},
|
||||
{"name": "a", "channel_type": "teams", "config": {}, "secret": "http://insecure.test/hook"},
|
||||
{"name": "a", "channel_type": "teams", "config": {}},
|
||||
{"name": "a", "channel_type": "matrix", "config": {"homeserver_url": "https://m.test", "room_id": "#alias:m.test"}, "secret": "t"},
|
||||
{"name": "a", "channel_type": "carrier-pigeon", "config": {}},
|
||||
{"name": " ", "channel_type": "ntfy", "config": {"topic": "ok"}}, # name only blanks
|
||||
])
|
||||
async def test_channel_validation(client, payload):
|
||||
resp = await client.post("/api/notifications/channels", json=payload)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_names_are_unique_and_ntfy_needs_no_secret(client):
|
||||
body = {"name": "Phone", "channel_type": "ntfy", "config": {"topic": "opnexus-alerts"}}
|
||||
first = await client.post("/api/notifications/channels", json=body)
|
||||
assert first.status_code == 201 and first.json()["secret_set"] is False
|
||||
assert first.json()["config"]["server_url"] == "https://ntfy.sh" and first.json()["config"]["priority"] == 4
|
||||
dup = await client.post("/api/notifications/channels", json=body)
|
||||
assert dup.status_code == 409 and dup.json()["detail"]["code"] == "NOTIFICATION_CHANNEL_NAME_TAKEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_cannot_change_channels_and_does_not_see_their_settings(client):
|
||||
from tests.test_operator_role import PW, _operator
|
||||
created = await client.post("/api/notifications/channels", json={"name": "Phone", "channel_type": "ntfy", "config": {"topic": "secret-topic"}})
|
||||
op = await _operator(client, "chan-op")
|
||||
try:
|
||||
for method, path, body in [
|
||||
("POST", "/api/notifications/channels", {"name": "x", "channel_type": "ntfy", "config": {"topic": "t"}}),
|
||||
("PUT", f"/api/notifications/channels/{created.json()['id']}", {"enabled": False}),
|
||||
("DELETE", f"/api/notifications/channels/{created.json()['id']}", None),
|
||||
("POST", f"/api/notifications/channels/{created.json()['id']}/test", {}),
|
||||
]:
|
||||
resp = await op.request(method, path, json=body) if body is not None else await op.request(method, path)
|
||||
assert resp.status_code == 403, (method, path, resp.text)
|
||||
listing = await op.get("/api/notifications/channels")
|
||||
assert listing.status_code == 200 and listing.json()[0]["config"] == {} and "secret-topic" not in listing.text
|
||||
finally:
|
||||
await op.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alerts_reach_every_enabled_channel_and_one_failure_does_not_block_the_rest(client, respx_mock):
|
||||
_mock_vip(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
hid = uuid.UUID(host["id"])
|
||||
async with SessionLocal() as db:
|
||||
for kind, cfg, secret, name in [
|
||||
("gotify", {"server_url": "https://gotify.test"}, "g-tok", "A gotify"),
|
||||
("ntfy", {"server_url": "https://ntfy.test", "topic": "t"}, None, "B ntfy"),
|
||||
]:
|
||||
ch = _channel(kind, cfg, secret, name)
|
||||
db.add(ch)
|
||||
off = _channel("ntfy", {"server_url": "https://off.test", "topic": "t"}, None, "C disabled")
|
||||
off.enabled = False
|
||||
db.add(off)
|
||||
await db.commit()
|
||||
await _only("gateway_down_enabled")
|
||||
gotify = respx_mock.post("https://gotify.test/message").mock(return_value=httpx.Response(500))
|
||||
ntfy = respx_mock.post("https://ntfy.test/").mock(return_value=httpx.Response(200))
|
||||
await _add_samples(hid, [{"gateways": [_gw("offline", 100.0, None)]}] * 3)
|
||||
async with SessionLocal() as db:
|
||||
assert (await run_notification_checks(db))["opened"] == 1
|
||||
assert gotify.call_count == 1 and ntfy.call_count == 1
|
||||
assert "off.test" not in str([c.request.url for c in respx_mock.calls])
|
||||
@@ -192,6 +192,7 @@ async def test_vip_password_never_leaves_the_server(client, respx_mock):
|
||||
_backup(respx_mock)
|
||||
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
|
||||
return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "10", "status": "BACKUP"}]}))
|
||||
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"password": "NEW-SECRET", "advskew": "100"}}
|
||||
resp = await _preview(client, host_id, **change)
|
||||
@@ -462,6 +463,45 @@ async def test_unlisted_secret_fields_never_reach_the_audit_log(client, respx_mo
|
||||
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vip_description_edit_does_not_reapply_the_vip(client, respx_mock):
|
||||
# Re-applying a CARP VIP for a description edit left it in INIT on both prod nodes (2026-10-20).
|
||||
host_id = await _setup(client, respx_mock, "fw-v2")
|
||||
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
||||
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
||||
"vhid": "12", "advskew": "0", "descr": "old"})
|
||||
_backup(respx_mock)
|
||||
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"descr": "new"}}
|
||||
plan = (await _preview(client, host_id, **change)).json()
|
||||
done = await _execute(client, host_id, plan["token"], **change)
|
||||
assert done.status_code == 200, done.text
|
||||
assert not reconf.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vip_edit_reports_a_vip_that_is_not_live(client, respx_mock, monkeypatch):
|
||||
async def no_sleep(_):
|
||||
return None
|
||||
monkeypatch.setattr("app.services.network_change_service.asyncio.sleep", no_sleep)
|
||||
host_id = await _setup(client, respx_mock, "fw-v3")
|
||||
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
||||
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
||||
"vhid": "13", "advskew": "0", "descr": "x"})
|
||||
_backup(respx_mock)
|
||||
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(
|
||||
return_value=httpx.Response(200, json={"rows": [{"vhid": "13", "status": "DISABLED"}]}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
||||
plan = (await _preview(client, host_id, **change)).json()
|
||||
done = await _execute(client, host_id, plan["token"], **change)
|
||||
assert done.status_code == 200, done.text
|
||||
assert done.json()["verified"] is False and "carp_vip_inactive" in done.json()["mismatches"]
|
||||
assert reconf.call_count == 2 # one retry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
||||
# OPNsense blanks a VIP's address when an update leaves out "network" (seen live on 26.7.6).
|
||||
@@ -471,12 +511,15 @@ async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
||||
"vhid": "11", "advskew": "0", "descr": "x"})
|
||||
_backup(respx_mock)
|
||||
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "11", "status": "BACKUP"}]}))
|
||||
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
||||
plan = (await _preview(client, host_id, **change)).json()
|
||||
done = await _execute(client, host_id, plan["token"], **change)
|
||||
assert done.status_code == 200, done.text
|
||||
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24"}
|
||||
# ...and the interface: OPNsense queues a delete of the live address when the posted interface differs
|
||||
# from the stored one, and the reconfigure then drops the VIP (seen live 2026-10-20).
|
||||
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24", "interface": "lan"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Taking over aliases, port forwards, 1:1 NAT and outbound NAT that were created by hand on a firewall.
|
||||
|
||||
The live rows below have the shape recorded from OPNsense 26.7 (test-fw-3, 2026-10-20), see the module
|
||||
docstring of app/services/object_adopt_service.py."""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from tests.helpers import deploy_approved
|
||||
from tests.test_api_tokens import _login
|
||||
|
||||
BASE = "https://fw-adopt-obj.test"
|
||||
API = f"{BASE}/api/firewall"
|
||||
|
||||
|
||||
async def _host(client, respx_mock, name="fw-obj"):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
r = await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _search(respx_mock, path, rows):
|
||||
return respx_mock.get(f"{API}/{path}").mock(return_value=httpx.Response(200, json={"rows": rows, "total": len(rows)}))
|
||||
|
||||
|
||||
def _mode(respx_mock, mode="hybrid"):
|
||||
respx_mock.get(f"{API}/source_nat/get").mock(return_value=httpx.Response(
|
||||
200, json={"filter": {"general": {"snat_mode": {m: {"selected": 1 if m == mode else 0} for m in
|
||||
("automatic", "hybrid", "advanced", "disabled")}}}}))
|
||||
|
||||
|
||||
def _writes(respx_mock):
|
||||
return [c for c in respx_mock.calls if c.request.method != "GET"]
|
||||
|
||||
|
||||
# ── live rows (shapes captured from OPNsense 26.7) ──────────────────────
|
||||
|
||||
def alias_row(name, type_="host", content="10.9.9.1\n10.9.9.2", **over):
|
||||
row = {"uuid": f"uuid-{name}", "enabled": "1", "name": name, "type": type_, "%type": type_.title(),
|
||||
"content": content, "description": f"{name} desc", "proto": "", "interface": "", "counters": "0",
|
||||
"updatefreq": "", "categories": ""}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
SYSTEM_ALIASES = [
|
||||
alias_row("bogons", "external", "", description="bogon networks (internal)"),
|
||||
alias_row("sshlockout", "external", "", description="abuse lockout table (internal)"),
|
||||
alias_row("__lan_network", "internal", "", description="lan net"),
|
||||
]
|
||||
|
||||
|
||||
def pf_row(**over):
|
||||
row = {"uuid": "pf-1", "ipprotocol": "inet", "protocol": "tcp", "%protocol": "TCP", "disabled": "0", "nordr": "0",
|
||||
"interface": "wan", "%interface": "WAN", "source.network": "any", "source.port": "", "source.not": "0",
|
||||
"destination.network": "wanip", "destination.port": "8443", "destination.not": "0", "target": "192.168.1.50",
|
||||
"local-port": "443", "natreflection": "", "descr": "Web", "pass": "", "log": "0", "poolopts": "", "tag": "",
|
||||
"tagged": "", "nosync": "0", "categories": "", "is_automatic": False}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
def o2o_row(**over):
|
||||
row = {"uuid": "o2o-1", "enabled": "1", "interface": "wan", "%interface": "WAN", "type": "binat",
|
||||
"source_net": "192.168.1.10", "source_not": "0", "destination_net": "any", "destination_not": "0",
|
||||
"external": "203.0.113.201", "natreflection": "", "log": "0", "description": "Mail 1:1", "categories": ""}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
def snat_row(**over):
|
||||
row = {"uuid": "snat-1", "enabled": "1", "interface": "wan", "%interface": "WAN", "ipprotocol": "inet",
|
||||
"protocol": "any", "source_net": "192.168.1.0/24", "source_not": "0", "source_port": "",
|
||||
"destination_net": "any", "destination_not": "0", "destination_port": "", "target": "", "target_port": "",
|
||||
"nonat": "0", "nosync": "0", "staticnatport": "0", "log": "0", "tag": "", "tagged": "",
|
||||
"description": "LAN out", "categories": ""}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
AUTOMATIC_NAT = [
|
||||
{"uuid": "lockout_0", "interface": "lan", "descr": "Anti-Lockout Rule", "is_automatic": True},
|
||||
{"uuid": "automatic_wan", "interface": "wan", "description": "Auto created rule", "is_automatic": True},
|
||||
]
|
||||
|
||||
# kind -> (url prefix, search path, row factory, id field of the preview)
|
||||
NAT_KINDS = {
|
||||
"port-forward": ("d_nat/searchRule", pf_row, "Web"),
|
||||
"one-to-one": ("one_to_one/searchRule", o2o_row, "Mail 1:1"),
|
||||
"outbound-nat": ("source_nat/searchRule", snat_row, "LAN out"),
|
||||
}
|
||||
|
||||
|
||||
# ── aliases ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_candidates_mark_system_unsupported_and_nested(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "alias/searchItem", SYSTEM_ALIASES + [
|
||||
alias_row("ok_hosts"),
|
||||
alias_row("ok_nets", "network", "10.0.0.0/8\n192.168.0.0/16"),
|
||||
alias_row("ok_ports", "port", "8080\n9000:9010"),
|
||||
alias_row("ok_group", "networkgroup", "ok_nets\n__lan_network"),
|
||||
alias_row("a_url", "url", "https://example.org/list.txt", updatefreq="1"),
|
||||
alias_row("geo", "geoip", "DE", proto="IPv4"),
|
||||
alias_row("uses_url", "host", "a_url"),
|
||||
alias_row("uses_hosts", "host", "ok_hosts\n10.9.9.3"),
|
||||
alias_row("asn_v4", "asn", "64496", proto="IPv4"),
|
||||
alias_row("bad name", "host", "10.1.1.1"),
|
||||
alias_row("off", "host", "10.9.9.9", enabled="0"),
|
||||
])
|
||||
resp = await client.get(f"/api/aliases/unmanaged?host_id={host_id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
by = {r["title"]: r for r in resp.json()}
|
||||
|
||||
for name in ("bogons", "sshlockout", "__lan_network"):
|
||||
assert by[name]["reasons"] == ["system"] and not by[name]["adoptable"]
|
||||
for name in ("ok_hosts", "ok_nets", "ok_ports", "off"):
|
||||
assert by[name]["adoptable"] is True and by[name]["reasons"] == []
|
||||
assert by["off"]["enabled"] is False
|
||||
# __lan_network is OPNsense's own and fine; ok_nets is a live alias that is not managed yet
|
||||
assert by["ok_group"]["reasons"] == ["references_unmanaged_alias"] and by["ok_group"]["bulk_adoptable"] is True
|
||||
assert by["ok_group"]["reason_details"]["references_unmanaged_alias"] == "ok_nets"
|
||||
assert by["a_url"]["reasons"] == ["unsupported_type"] and by["geo"]["reasons"] == ["unsupported_type"]
|
||||
assert by["a_url"]["reason_details"]["unsupported_type"]
|
||||
assert by["uses_url"]["reasons"] == ["references_unmanaged_alias"]
|
||||
assert by["uses_url"]["reason_details"]["references_unmanaged_alias"] == "a_url"
|
||||
assert by["uses_url"]["bulk_adoptable"] is False # its target can never be adopted
|
||||
assert by["asn_v4"]["reasons"] == ["proto_set"]
|
||||
assert by["bad name"]["reasons"] == ["name_invalid"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_alias_waits_for_its_target_but_bulk_orders_them(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "alias/searchItem", [
|
||||
alias_row("outer", "host", "inner\n10.0.0.9"),
|
||||
alias_row("inner", "host", "base"),
|
||||
alias_row("base", "host", "10.0.0.1"),
|
||||
])
|
||||
by = {r["title"]: r for r in (await client.get(f"/api/aliases/unmanaged?host_id={host_id}")).json()}
|
||||
assert by["base"]["adoptable"] is True
|
||||
for name, target in (("inner", "base"), ("outer", "inner")):
|
||||
assert by[name]["adoptable"] is False and by[name]["bulk_adoptable"] is True
|
||||
assert by[name]["reason_details"]["references_unmanaged_alias"] == target
|
||||
# alone it is refused ...
|
||||
refused = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-outer"})
|
||||
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "OBJECT_NOT_ADOPTABLE"
|
||||
assert "references_unmanaged_alias" in refused.json()["detail"]["params"]["reasons"]
|
||||
# ... after its target is managed it works
|
||||
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-base"})).status_code == 201
|
||||
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-inner"})).status_code == 201
|
||||
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-outer"})).status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopt_alias_records_it_without_writing_and_preview_is_unchanged(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "alias/searchItem", SYSTEM_ALIASES + [
|
||||
alias_row("web_servers", "host", "10.0.0.1\n 10.0.0.2 \n\nexample.org", description="Web tier"),
|
||||
])
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-web_servers"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
alias = resp.json()
|
||||
assert (alias["name"], alias["type"], alias["description"], alias["enabled"]) == ("web_servers", "host", "Web tier", True)
|
||||
assert alias["content"] == "10.0.0.1\n10.0.0.2\nexample.org"
|
||||
assert alias["target_host_ids"] == [host_id]
|
||||
assert [d["status"] for d in alias["deployments"]] == ["success"]
|
||||
assert alias["workflow_state"] == "synced"
|
||||
assert not any("/firewall/" in str(c.request.url) for c in _writes(respx_mock))
|
||||
|
||||
preview = (await client.get(f"/api/aliases/{alias['id']}/preview")).json()
|
||||
assert [(h["action"], h["changes"]) for h in preview["hosts"]] == [("unchanged", [])]
|
||||
assert preview["can_deploy"] is False
|
||||
# no longer offered; adopting again is refused
|
||||
assert [r["title"] for r in (await client.get(f"/api/aliases/unmanaged?host_id={host_id}")).json()
|
||||
if r["reasons"] != ["system"]] == []
|
||||
again = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-web_servers"})
|
||||
assert again.status_code == 409 and "already_managed" in again.json()["detail"]["params"]["reasons"]
|
||||
|
||||
# audit: the deployment record (kind alias, remote_uuid) is written with the adoption
|
||||
async with SessionLocal() as db:
|
||||
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "alias"))).all()
|
||||
assert any(e.entity_name == "web_servers" and e.details["remote_uuid"] == "uuid-web_servers"
|
||||
and e.message == "Von der Firewall übernommen" for e in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_after_alias_adopt_updates_the_same_remote_object(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "alias/searchItem", [alias_row("web_servers", "host", "10.0.0.1")])
|
||||
alias = (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-web_servers"})).json()
|
||||
edit = {"name": "web_servers", "type": "host", "content": "10.0.0.1\n10.0.0.2", "description": "web_servers desc",
|
||||
"enabled": True, "target_host_ids": [host_id], "target_group_ids": [], "expected_updated_at": alias["updated_at"]}
|
||||
assert (await client.put(f"/api/aliases/{alias['id']}", json=edit)).status_code == 200
|
||||
add = respx_mock.post(f"{API}/alias/addItem").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
|
||||
setter = respx_mock.post(f"{API}/alias/setItem/uuid-web_servers").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.post(f"{API}/alias/reconfigure").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
resp = await deploy_approved(client, f"/api/aliases/{alias['id']}/deploy")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert setter.called and not add.called # no duplicate
|
||||
assert json.loads(setter.calls.last.request.content)["alias"]["content"] == "10.0.0.1\n10.0.0.2"
|
||||
confirm = await client.post(f"/api/aliases/{alias['id']}/confirm")
|
||||
assert [d["status"] for d in confirm.json()["deployments"]] == ["success"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_name_that_exists_centrally_is_refused(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
assert (await client.post("/api/aliases", json={"name": "dup", "type": "host", "content": "1.1.1.1"})).status_code == 201
|
||||
_search(respx_mock, "alias/searchItem", [alias_row("dup", "host", "2.2.2.2")])
|
||||
row = (await client.get(f"/api/aliases/unmanaged?host_id={host_id}")).json()[0]
|
||||
assert row["reasons"] == ["name_exists"]
|
||||
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-dup"})).status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopt_all_aliases_orders_dependencies_and_reports_skipped(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "alias/searchItem", SYSTEM_ALIASES + [
|
||||
alias_row("outer", "host", "inner"),
|
||||
alias_row("inner", "host", "10.0.0.1"),
|
||||
alias_row("a_url", "url", "https://example.org/x"),
|
||||
alias_row("uses_url", "host", "a_url"),
|
||||
alias_row("plain", "port", "443"),
|
||||
])
|
||||
resp = await client.post("/api/aliases/adopt-all", json={"host_id": host_id})
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert sorted(a["title"] for a in body["adopted"]) == ["inner", "outer", "plain"]
|
||||
skipped = {s["title"]: s for s in body["skipped"]}
|
||||
assert set(skipped) == {"a_url", "uses_url"} # system aliases are not reported
|
||||
assert skipped["a_url"]["reasons"] == ["unsupported_type"]
|
||||
assert skipped["uses_url"]["reasons"] == ["references_unmanaged_alias"]
|
||||
aliases = {a["name"]: a for a in (await client.get("/api/aliases")).json()}
|
||||
assert set(aliases) == {"inner", "outer", "plain"}
|
||||
assert all([d["status"] for d in a["deployments"]] == ["success"] and a["workflow_state"] == "synced" for a in aliases.values())
|
||||
# a second run finds nothing left to take over
|
||||
again = (await client.post("/api/aliases/adopt-all", json={"host_id": host_id})).json()
|
||||
assert again["adopted"] == [] and {s["title"] for s in again["skipped"]} == {"a_url", "uses_url"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopt_unknown_alias_is_404(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "alias/searchItem", [alias_row("x")])
|
||||
resp = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "nope"})
|
||||
assert resp.status_code == 404 and resp.json()["detail"]["code"] == "ADOPT_OBJECT_NOT_FOUND"
|
||||
|
||||
|
||||
# ── NAT ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_automatic_nat_rows_are_never_offered(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_mode(respx_mock)
|
||||
for prefix, (path, factory, _) in NAT_KINDS.items():
|
||||
_search(respx_mock, path, [factory(), *AUTOMATIC_NAT])
|
||||
rows = (await client.get(f"/api/{prefix}/unmanaged?host_id={host_id}")).json()
|
||||
assert [r["uuid"] for r in rows] == [factory()["uuid"]], prefix
|
||||
assert rows[0]["adoptable"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_port_forward_refusals(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
cases = {
|
||||
"multi": (dict(interface="wan,lan"), "interface_not_single"),
|
||||
"v46": (dict(ipprotocol="inet46"), "ipprotocol_unsupported"),
|
||||
"anyproto": (dict(protocol=""), "protocol_any"),
|
||||
"nordr": (dict(nordr="1", target="", **{"local-port": ""}), "no_redirect"),
|
||||
"notarget": (dict(target=""), "no_target"),
|
||||
"linked": (dict(**{"pass": "pass"}), "filter_rule_linked"),
|
||||
"srcnot": (dict(**{"source.not": "1"}), "negated_source"),
|
||||
"dstnot": (dict(**{"destination.not": "1"}), "negated_destination"),
|
||||
"refl": (dict(natreflection="enable"), "natreflection_unsupported"),
|
||||
"nodesc": (dict(descr=""), "no_description"),
|
||||
"long": (dict(target="x" * 200), "value_too_long"),
|
||||
}
|
||||
_search(respx_mock, "d_nat/searchRule", [pf_row(**{"uuid": k, "descr": f"rule {k}", **over})
|
||||
for k, (over, _) in cases.items()] + [pf_row(uuid="fine")])
|
||||
rows = {r["uuid"]: r for r in (await client.get(f"/api/port-forward/unmanaged?host_id={host_id}")).json()}
|
||||
assert rows["fine"]["adoptable"] is True
|
||||
for key, (_, reason) in cases.items():
|
||||
assert reason in rows[key]["reasons"] and not rows[key]["adoptable"], key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_port_forward_adopt_maps_fields_and_keeps_unmanaged_ones(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "d_nat/searchRule", [
|
||||
pf_row(disabled="1", natreflection="purenat", log="1", poolopts="random", tag="tg1", **{"source.network": "10.0.0.0/8"}),
|
||||
pf_row(uuid="pf-2", descr="Alias based", target="web_servers", **{"destination.port": "web_ports", "local-port": ""}),
|
||||
*AUTOMATIC_NAT,
|
||||
])
|
||||
candidates = {r["uuid"]: r for r in (await client.get(f"/api/port-forward/unmanaged?host_id={host_id}")).json()}
|
||||
assert sorted(candidates["pf-1"]["kept"]) == ["log", "poolopts", "tag"]
|
||||
resp = await client.post("/api/port-forward/adopt", json={"host_id": host_id, "uuid": "pf-1"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
rule = resp.json()
|
||||
assert (rule["description"], rule["interface"], rule["ipprotocol"], rule["protocol"]) == ("Web", "wan", "inet", "tcp")
|
||||
assert (rule["source_net"], rule["destination_net"], rule["destination_port"]) == ("10.0.0.0/8", "wanip", "8443")
|
||||
assert (rule["target"], rule["local_port"], rule["natreflection"], rule["enabled"]) == ("192.168.1.50", "443", "purenat", False)
|
||||
assert rule["workflow_state"] == "synced" and [d["status"] for d in rule["deployments"]] == ["success"]
|
||||
preview = (await client.get(f"/api/port-forward/{rule['id']}/preview")).json()
|
||||
assert [h["action"] for h in preview["hosts"]] == ["unchanged"] and preview["can_deploy"] is False
|
||||
assert not _writes(respx_mock) or not any("/firewall/" in str(c.request.url) for c in _writes(respx_mock))
|
||||
|
||||
# edit -> deploy updates the same remote rule and never sends the fields it does not manage
|
||||
edit = {k: rule[k] for k in ("description", "interface", "ipprotocol", "protocol", "source_net", "source_port",
|
||||
"destination_net", "destination_port", "target", "local_port", "natreflection", "enabled")}
|
||||
edit.update(target="192.168.1.51", target_host_ids=[host_id], target_group_ids=[], expected_updated_at=rule["updated_at"])
|
||||
assert (await client.put(f"/api/port-forward/{rule['id']}", json=edit)).status_code == 200
|
||||
add = respx_mock.post(f"{API}/d_nat/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
|
||||
setter = respx_mock.post(f"{API}/d_nat/setRule/pf-1").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.post(f"{API}/d_nat/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
assert (await deploy_approved(client, f"/api/port-forward/{rule['id']}/deploy")).status_code == 200
|
||||
assert setter.called and not add.called
|
||||
sent = json.loads(setter.calls.last.request.content)["rule"]
|
||||
assert sent["target"] == "192.168.1.51" and sent["disabled"] == "1"
|
||||
assert not {"log", "poolopts", "tag", "tagged", "nosync", "categories", "nordr"} & set(sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_to_one_adopt_and_refusals(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_search(respx_mock, "one_to_one/searchRule", [
|
||||
o2o_row(),
|
||||
o2o_row(uuid="o2", description="NAT off", type="nat", enabled="0", natreflection="enable", log="1",
|
||||
source_not="1", destination_net="10.0.0.0/8", destination_not="1"),
|
||||
o2o_row(uuid="o3", interface="wan,lan", description="multi"),
|
||||
o2o_row(uuid="o4", description=""),
|
||||
o2o_row(uuid="o5", description="weird", type="other"),
|
||||
o2o_row(uuid="o6", description="refl", natreflection="purenat"),
|
||||
*AUTOMATIC_NAT,
|
||||
])
|
||||
rows = {r["uuid"]: r for r in (await client.get(f"/api/one-to-one/unmanaged?host_id={host_id}")).json()}
|
||||
assert rows["o2o-1"]["adoptable"] and rows["o2"]["adoptable"]
|
||||
assert rows["o3"]["reasons"] == ["interface_not_single"] and rows["o4"]["reasons"] == ["no_description"]
|
||||
assert rows["o5"]["reasons"] == ["type_unsupported"] and rows["o6"]["reasons"] == ["natreflection_unsupported"]
|
||||
|
||||
rule = (await client.post("/api/one-to-one/adopt", json={"host_id": host_id, "uuid": "o2"})).json()
|
||||
assert (rule["type"], rule["enabled"], rule["natreflection"], rule["log"], rule["source_not"], rule["destination_not"]) == (
|
||||
"nat", False, "enable", True, True, True)
|
||||
assert rule["destination_net"] == "10.0.0.0/8" and rule["external"] == "203.0.113.201"
|
||||
preview = (await client.get(f"/api/one-to-one/{rule['id']}/preview")).json()
|
||||
assert [h["action"] for h in preview["hosts"]] == ["unchanged"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_nat_adopt_and_refusals(client, respx_mock):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_mode(respx_mock, "hybrid")
|
||||
_search(respx_mock, "source_nat/searchRule", [
|
||||
snat_row(),
|
||||
snat_row(uuid="s2", description="to pool", protocol="TCP", destination_port="443", target="203.0.113.205",
|
||||
target_port="1024", staticnatport="1", log="1", enabled="0", destination_not="1", destination_net="10.0.0.0/8"),
|
||||
snat_row(uuid="s3", description="no nat", nonat="1"),
|
||||
snat_row(uuid="s4", description="v46", ipprotocol="inet46"),
|
||||
snat_row(uuid="s5", description=""),
|
||||
*AUTOMATIC_NAT,
|
||||
])
|
||||
rows = {r["uuid"]: r for r in (await client.get(f"/api/outbound-nat/unmanaged?host_id={host_id}")).json()}
|
||||
assert rows["snat-1"]["adoptable"] and rows["s2"]["adoptable"]
|
||||
assert sorted(rows["s2"]["kept"]) == ["log", "staticnatport"]
|
||||
assert rows["s3"]["reasons"] == ["no_nat_rule"] and rows["s4"]["reasons"] == ["ipprotocol_unsupported"]
|
||||
assert rows["s5"]["reasons"] == ["no_description"]
|
||||
|
||||
rule = (await client.post("/api/outbound-nat/adopt", json={"host_id": host_id, "uuid": "s2"})).json()
|
||||
assert (rule["protocol"], rule["destination_port"], rule["target"], rule["target_port"], rule["enabled"],
|
||||
rule["destination_not"]) == ("TCP", "443", "203.0.113.205", "1024", False, True)
|
||||
assert rule["source_port"] is None
|
||||
preview = (await client.get(f"/api/outbound-nat/{rule['id']}/preview")).json()
|
||||
assert [h["action"] for h in preview["hosts"]] == ["unchanged"]
|
||||
|
||||
# edit -> deploy: setRule on the same uuid, nonat/log/staticnatport are not part of the payload
|
||||
edit = {k: rule[k] for k in ("description", "interface", "source_net", "destination_net", "source_not", "destination_not",
|
||||
"enabled", "ipprotocol", "protocol", "source_port", "destination_port", "target", "target_port")}
|
||||
edit.update(enabled=True, target_host_ids=[host_id], target_group_ids=[], expected_updated_at=rule["updated_at"])
|
||||
assert (await client.put(f"/api/outbound-nat/{rule['id']}", json=edit)).status_code == 200
|
||||
add = respx_mock.post(f"{API}/source_nat/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
|
||||
setter = respx_mock.post(f"{API}/source_nat/setRule/s2").mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||
respx_mock.post(f"{API}/source_nat/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||
assert (await deploy_approved(client, f"/api/outbound-nat/{rule['id']}/deploy")).status_code == 200
|
||||
assert setter.called and not add.called
|
||||
sent = json.loads(setter.calls.last.request.content)["rule"]
|
||||
assert sent["enabled"] == "1" and not {"nonat", "log", "staticnatport", "nosync", "tag"} & set(sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", ["automatic", "disabled"])
|
||||
async def test_outbound_nat_adoption_needs_a_manual_capable_mode(client, respx_mock, mode):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_mode(respx_mock, mode)
|
||||
search = _search(respx_mock, "source_nat/searchRule", [snat_row()])
|
||||
for call in (client.get(f"/api/outbound-nat/unmanaged?host_id={host_id}"),
|
||||
client.post("/api/outbound-nat/adopt", json={"host_id": host_id, "uuid": "snat-1"}),
|
||||
client.post("/api/outbound-nat/adopt-all", json={"host_id": host_id})):
|
||||
resp = await call
|
||||
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "OUTBOUND_MODE_BLOCKS_ADOPT", resp.text
|
||||
assert resp.json()["detail"]["params"]["mode"] == mode
|
||||
assert not search.called
|
||||
assert (await client.get("/api/outbound-nat")).json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("prefix", list(NAT_KINDS))
|
||||
async def test_adopt_all_nat_takes_what_is_adoptable_and_is_idempotent(client, respx_mock, prefix):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_mode(respx_mock)
|
||||
path, factory, title = NAT_KINDS[prefix]
|
||||
bad = {"descr": ""} if prefix == "port-forward" else {"description": ""}
|
||||
_search(respx_mock, path, [factory(), factory(uuid="second", **({"descr": "Second"} if prefix == "port-forward" else {"description": "Second"})),
|
||||
factory(uuid="bad", **bad), *AUTOMATIC_NAT])
|
||||
result = (await client.post(f"/api/{prefix}/adopt-all", json={"host_id": host_id})).json()
|
||||
assert sorted(a["title"] for a in result["adopted"]) == sorted([title, "Second"])
|
||||
assert [(s["uuid"], s["reasons"]) for s in result["skipped"]] == [("bad", ["no_description"])]
|
||||
listed = (await client.get(f"/api/{prefix}")).json()
|
||||
assert len(listed) == 2 and all(r["workflow_state"] == "synced" for r in listed)
|
||||
again = (await client.post(f"/api/{prefix}/adopt-all", json={"host_id": host_id})).json()
|
||||
assert again["adopted"] == [] and len((await client.get(f"/api/{prefix}")).json()) == 2
|
||||
assert not any("/firewall/" in str(c.request.url) for c in _writes(respx_mock))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("prefix", ["aliases", *NAT_KINDS])
|
||||
async def test_roles_and_unknown_host(client, respx_mock, prefix):
|
||||
host_id = await _host(client, respx_mock)
|
||||
_mode(respx_mock)
|
||||
path = "alias/searchItem" if prefix == "aliases" else NAT_KINDS[prefix][0]
|
||||
row = alias_row("a1") if prefix == "aliases" else NAT_KINDS[prefix][1]()
|
||||
_search(respx_mock, path, [row])
|
||||
unknown = "00000000-0000-0000-0000-000000000000"
|
||||
assert (await client.get(f"/api/{prefix}/unmanaged?host_id={unknown}")).status_code == 404
|
||||
assert (await client.post(f"/api/{prefix}/adopt", json={"host_id": unknown, "uuid": "x"})).status_code == 404
|
||||
|
||||
# read-only users cannot adopt (it creates central objects), operators can (it writes nothing to a firewall)
|
||||
for role, expected in (("read_only", 403), ("operator", 201)):
|
||||
user = await client.post("/api/users", json={"username": f"u-{role}-{prefix}", "password": "adopt-test-password-1", "role": role})
|
||||
assert user.status_code == 201, user.text
|
||||
session = await _login(f"u-{role}-{prefix}", "adopt-test-password-1")
|
||||
try:
|
||||
assert (await session.get(f"/api/{prefix}/unmanaged?host_id={host_id}")).status_code == 200
|
||||
resp = await session.post(f"/api/{prefix}/adopt", json={"host_id": host_id, "uuid": row["uuid"]})
|
||||
assert resp.status_code == expected, (role, resp.text)
|
||||
finally:
|
||||
await session.aclose()
|
||||
@@ -166,11 +166,24 @@ async def test_groups_can_come_from_userinfo_but_only_for_the_same_subject(clien
|
||||
assert _error(resp) == "no_role"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_username_comes_from_userinfo_if_the_id_token_has_none_and_a_failing_userinfo_does_not_block_the_login(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
# Authelia keeps preferred_username/email out of the ID token; without userinfo the account would be named after the opaque sub.
|
||||
query = await _start(anon_client, respx_mock, userinfo={"sub": "user-1", "preferred_username": "alice", "email": "[email protected]"})
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None))
|
||||
assert resp.headers["location"] == "/dashboard" and list(await _users("alice")) == ["alice"]
|
||||
respx_mock.clear()
|
||||
query = await _start(anon_client, respx_mock, userinfo={"sub": "someone-else", "preferred_username": "mallory"})
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None, sub="user-2"))
|
||||
assert resp.headers["location"] == "/dashboard" and "mallory" not in await _users("x") and "user-2" in await _users("x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers bar"))
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers"))
|
||||
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "read_only"
|
||||
|
||||
|
||||
@@ -342,3 +355,57 @@ async def test_failed_callbacks_are_throttled(client, anon_client, respx_mock):
|
||||
await _configure(client)
|
||||
results = [_error(await anon_client.get("/api/auth/oidc/callback", params={"code": "x", "state": f"s{i}"})) for i in range(12)]
|
||||
assert "throttled" in results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_string_group_claim_keeps_names_with_spaces_together(client, anon_client, respx_mock):
|
||||
await _configure(client, admin_group="Network Admins", operator_group="Admins", read_only_group="")
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network Admins"))
|
||||
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "admin" # not "Admins" = operator, not split
|
||||
query = await _start(anon_client, respx_mock)
|
||||
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network, Team"))
|
||||
assert _error(resp) == "no_role"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_callback_only_works_in_the_browser_that_started_the_login(client, anon_client, respx_mock):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from app.main import app
|
||||
await _configure(client)
|
||||
query = await _start(anon_client, respx_mock)
|
||||
# Somebody else (another browser, no state cookie) opens the callback URL: refused, and the state is not burned for the owner.
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as stranger:
|
||||
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(200, json={"id_token": _id_token(_claims(query["nonce"]))}))
|
||||
resp = await stranger.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
|
||||
assert _error(resp) == "state"
|
||||
assert await _users("alice") == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sso_sessions_are_short_and_the_state_cookie_is_httponly(client, anon_client, respx_mock):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.models.user import Session
|
||||
await _configure(client)
|
||||
_mock_idp(respx_mock)
|
||||
start = await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})
|
||||
cookie = start.headers["set-cookie"].lower()
|
||||
assert "opnexus-oidc-state=" in cookie and "httponly" in cookie and "samesite=lax" in cookie and "path=/api/auth/oidc" in cookie
|
||||
query = {k: v[0] for k, v in parse_qs(urlsplit(start.headers["location"]).query).items()}
|
||||
resp, _ = await _callback(anon_client, respx_mock, query)
|
||||
assert resp.headers["location"] == "/dashboard"
|
||||
assert "max-age=43200" in resp.headers["set-cookie"].lower()
|
||||
async with SessionLocal() as db:
|
||||
user = (await _users("alice"))["alice"]
|
||||
session = (await db.scalars(select(Session).where(Session.user_id == user.id))).one()
|
||||
assert session.expires_at - datetime.now(timezone.utc) < timedelta(hours=12, minutes=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_text_from_the_provider_cannot_forge_log_lines(client, anon_client, caplog):
|
||||
await _configure(client)
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await anon_client.get("/api/auth/oidc/callback", params={"error": "x\r\nFAKE LOG LINE\x1b[31m", "state": "s"})
|
||||
text = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "FAKE LOG LINE" in text and "\r" not in text and "\x1b" not in text and "\nFAKE" not in text
|
||||
@@ -113,6 +113,23 @@ async def test_demoting_an_admin_to_operator_takes_effect_at_once(client):
|
||||
("POST", "/api/hosts/abc/maintenance/carp-execute", True),
|
||||
("POST", "/api/hosts/abc/maintenance/schedule", True),
|
||||
("POST", "/api/hosts/abc/network/changes", True),
|
||||
("DELETE", "/api/rules/abc", True),
|
||||
("DELETE", "/api/aliases/abc", True),
|
||||
("DELETE", "/api/port-forward/abc", True),
|
||||
("DELETE", "/api/one-to-one/abc", True),
|
||||
("DELETE", "/api/outbound-nat/abc", True),
|
||||
("DELETE", "/api/policy-hierarchy/rules/abc", True),
|
||||
("DELETE", "/api/ipsec/tunnels/abc", True),
|
||||
("DELETE", "/api/certificates/managed/abc", True),
|
||||
("DELETE", "/api/hosts/abc/snapshots/def", True),
|
||||
("POST", "/api/certificates/managed/abc/push", True),
|
||||
("POST", "/api/acme/accounts/abc/register", True),
|
||||
("POST", "/api/acme/certificates/abc/sign", True),
|
||||
("POST", "/api/acme/certificates/abc/poll", True),
|
||||
("POST", "/api/policy-hierarchy/groups/abc/reassert-ordering", True),
|
||||
("POST", "/api/hosts/abc/services/unbound/restart", True),
|
||||
("DELETE", "/api/groups/abc", False),
|
||||
("DELETE", "/api/rules/abc/extra", False),
|
||||
("POST", "/api/rules/abc/preview", False),
|
||||
("POST", "/api/rules", False),
|
||||
("PUT", "/api/rules/abc", False),
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_public_key_validation(value, ok):
|
||||
|
||||
def test_subnets():
|
||||
assert str(svc.parse_subnet("10.99.0.0/24")) == "10.99.0.0/24"
|
||||
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", ""):
|
||||
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", "", "127.0.0.0/24", "169.254.1.0/24"):
|
||||
with pytest.raises(svc.OverlayError):
|
||||
svc.parse_subnet(bad)
|
||||
|
||||
@@ -259,6 +259,7 @@ def test_hub_applies_the_interface_peers_and_the_inbound_drop_rule(tmp_path):
|
||||
cmds = [c[:3] for c in runner.calls]
|
||||
assert ("ip", "link", "add") in cmds and ("ip", "address", "replace") in cmds and ("wg", "syncconf", "wg0") in cmds
|
||||
assert any(c[0] == "iptables" and c[1] == "-I" and "-i" in c and "NEW" in c and c[-1] == "DROP" for c in runner.calls)
|
||||
assert ("iptables", "-I", "FORWARD", "-j", "DROP") in runner.calls # no routing between peers or into the compose network
|
||||
assert not list(tmp_path.glob(".wg0.conf")) # the private key file does not linger
|
||||
assert hub.subnet == ipaddress.ip_network("10.99.0.0/24")
|
||||
# An existing interface is reused, not re-created.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Rollout plans: CRUD, wave resolution, preview token, activation, roles."""
|
||||
import pytest
|
||||
|
||||
from tests.test_maintenance_windows import _WEEKLY
|
||||
from tests.test_rules import _create_host
|
||||
|
||||
|
||||
async def _fleet(client):
|
||||
"""canary: c1 · pilot: p1 + ha-b (BACKUP) · prod: ha-a; ha-a/ha-b form cluster "ha"."""
|
||||
hosts = {}
|
||||
for name in ("c1", "p1", "ha-a", "ha-b"):
|
||||
hosts[name] = await _create_host(client, name, f"https://{name}.test")
|
||||
for name in ("ha-a", "ha-b"):
|
||||
await client.patch(f"/api/hosts/{hosts[name]['id']}", json={"cluster_name": "ha"})
|
||||
groups = {}
|
||||
for gname, members in (("ring-canary", ["c1"]), ("ring-pilot", ["p1", "ha-b"]), ("ring-prod", ["ha-a", "c1"])):
|
||||
resp = await client.post("/api/groups", json={"name": gname, "host_ids": [hosts[m]["id"] for m in members]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
groups[gname] = resp.json()["id"]
|
||||
return hosts, groups
|
||||
|
||||
|
||||
def _plan_body(groups, **kw):
|
||||
body = {"name": "Ringe", "action": "update", "waves": [
|
||||
{"label": "canary", "group_ids": [groups["ring-canary"]], "soak_minutes": 10},
|
||||
{"label": "pilot", "group_ids": [groups["ring-pilot"]], "soak_minutes": 10},
|
||||
{"label": "prod", "group_ids": [groups["ring-prod"]], "soak_minutes": 0, "carp_handover": True, "require_approval": True},
|
||||
]}
|
||||
body.update(kw)
|
||||
return body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_crud_and_preview_resolves_rings(client):
|
||||
_hosts, groups = await _fleet(client)
|
||||
resp = await client.post("/api/rollouts/plans", json=_plan_body(groups))
|
||||
assert resp.status_code == 201, resp.text
|
||||
plan = resp.json()
|
||||
assert [w["label"] for w in plan["waves"]] == ["canary", "pilot", "prod"] and plan["host_count"] == 4
|
||||
prev = await client.post(f"/api/rollouts/plans/{plan['id']}/preview")
|
||||
assert prev.status_code == 200
|
||||
body = prev.json()
|
||||
assert [[h["name"] for h in w["hosts"]] for w in body["waves"]] == [["c1"], ["ha-b", "p1"], ["ha-a"]] # c1 only once
|
||||
codes = {w["code"] for w in body["warnings"]}
|
||||
assert {"duplicate_hosts", "mixed_versions", "master_needs_handover", "no_window"} <= codes
|
||||
assert body["errors"] == [] and body["can_start"] and len(body["preview_token"]) == 64
|
||||
assert (await client.post("/api/rollouts/plans", json=_plan_body(groups))).json()["detail"]["code"] == "ROLLOUT_NAME_TAKEN"
|
||||
assert (await client.delete(f"/api/groups/{groups['ring-canary']}")).json()["detail"]["code"] == "ROLLOUT_GROUP_IN_USE"
|
||||
assert (await client.delete(f"/api/rollouts/plans/{plan['id']}")).status_code == 204
|
||||
assert (await client.get(f"/api/rollouts/plans/{plan['id']}")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_major_update_must_keep_a_cluster_in_one_wave(client):
|
||||
_hosts, groups = await _fleet(client)
|
||||
plan = (await client.post("/api/rollouts/plans", json=_plan_body(groups, action="major_update"))).json()
|
||||
assert {e["code"] for e in plan["errors"]} == {"cluster_split"}
|
||||
prev = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()
|
||||
assert prev["can_start"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activation_needs_auto_start_a_window_and_the_current_token(client):
|
||||
_hosts, groups = await _fleet(client)
|
||||
window = (await client.post("/api/maintenance-windows", json=_WEEKLY)).json()
|
||||
plan = (await client.post("/api/rollouts/plans", json=_plan_body(groups))).json()
|
||||
token = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
|
||||
resp = await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["params"]["field"] == "auto_start"
|
||||
|
||||
plan = (await client.put(f"/api/rollouts/plans/{plan['id']}", json=_plan_body(groups, auto_start=True, window_id=window["id"]))).json()
|
||||
stale = await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})
|
||||
assert stale.status_code == 409 and stale.json()["detail"]["code"] == "ROLLOUT_PREVIEW_STALE"
|
||||
token = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
|
||||
active = await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})
|
||||
assert active.status_code == 200 and active.json()["active"] and active.json()["fingerprint_ok"]
|
||||
assert active.json()["activated_by"] == "admin"
|
||||
|
||||
# A group change behind the plan's back: still "active", but the fingerprint no longer matches.
|
||||
await client.put(f"/api/groups/{groups['ring-canary']}", json={"name": "ring-canary", "host_ids": []})
|
||||
assert (await client.get(f"/api/rollouts/plans/{plan['id']}")).json()["fingerprint_ok"] is False
|
||||
# A window that a plan uses cannot be deleted (the plan would silently lose its time limit).
|
||||
assert (await client.delete(f"/api/maintenance-windows/{window['id']}")).json()["detail"]["code"] == "WINDOW_IN_USE"
|
||||
|
||||
# Any edit disarms it.
|
||||
token = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
|
||||
await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})
|
||||
edited = await client.put(f"/api/rollouts/plans/{plan['id']}", json=_plan_body(groups, auto_start=True, window_id=window["id"], name="Ringe 2"))
|
||||
assert edited.json()["active"] is False
|
||||
off = await client.post(f"/api/rollouts/plans/{plan['id']}/deactivate")
|
||||
assert off.status_code == 200 and off.json()["active"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_start_only_for_minor_updates_with_a_window(client):
|
||||
_hosts, groups = await _fleet(client)
|
||||
plan = (await client.post("/api/rollouts/plans", json=_plan_body(groups, auto_start=True))).json()
|
||||
assert "auto_start_needs_window" in {e["code"] for e in plan["errors"]}
|
||||
token = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
|
||||
resp = await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "ROLLOUT_PLAN_INVALID"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change,field", [
|
||||
({"gate_checks": {"nonsense": True}}, "gate_checks"),
|
||||
({"window_id": "00000000-0000-0000-0000-000000000000"}, "window_id"),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_validation(client, change, field):
|
||||
_hosts, groups = await _fleet(client)
|
||||
resp = await client.post("/api/rollouts/plans", json=_plan_body(groups, **change))
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["params"]["field"] == field
|
||||
bad_group = _plan_body(groups)
|
||||
bad_group["waves"][0]["group_ids"] = ["00000000-0000-0000-0000-000000000000"]
|
||||
assert (await client.post("/api/rollouts/plans", json=bad_group)).json()["detail"]["params"]["field"] == "group_ids"
|
||||
too_many = _plan_body(groups, waves=[{"label": str(i), "group_ids": [groups["ring-canary"]]} for i in range(11)])
|
||||
assert (await client.post("/api/rollouts/plans", json=too_many)).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_matrix_for_plans(client):
|
||||
from tests.test_operator_role import _operator
|
||||
_hosts, groups = await _fleet(client)
|
||||
window = (await client.post("/api/maintenance-windows", json=_WEEKLY)).json()
|
||||
op = await _operator(client, "rollop")
|
||||
try:
|
||||
made = await op.post("/api/rollouts/plans", json=_plan_body(groups, auto_start=True, window_id=window["id"]))
|
||||
assert made.status_code == 201 # preparing is fine
|
||||
pid = made.json()["id"]
|
||||
token = (await op.post(f"/api/rollouts/plans/{pid}/preview")).json()["preview_token"]
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": False})
|
||||
denied = await op.post(f"/api/rollouts/plans/{pid}/activate", json={"preview_token": token})
|
||||
assert denied.status_code == 403 and denied.json()["detail"]["code"] == "DEPLOY_NEEDS_ADMIN"
|
||||
assert (await op.post(f"/api/rollouts/plans/{pid}/deactivate")).status_code == 200 # braking is always allowed
|
||||
assert (await client.post(f"/api/rollouts/plans/{pid}/activate", json={"preview_token": token})).status_code == 200
|
||||
finally:
|
||||
await op.aclose()
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
|
||||
from tests.test_api_tokens import _login
|
||||
assert (await client.post("/api/users", json={"username": "rollro", "password": "readonly-pass-1", "role": "read_only"})).status_code == 201
|
||||
ro = await _login("rollro", "readonly-pass-1")
|
||||
try:
|
||||
assert (await ro.get("/api/rollouts/plans")).status_code == 200
|
||||
for path in ("/api/rollouts/plans", f"/api/rollouts/plans/{pid}/deactivate"):
|
||||
assert (await ro.post(path, json={})).json()["detail"]["code"] == "READ_ONLY_ACCESS"
|
||||
finally:
|
||||
await ro.aclose()
|
||||
@@ -0,0 +1,620 @@
|
||||
"""The rollout engine end to end against a fake fleet: c1 (stand-alone), ha-a (MASTER) + ha-b (BACKUP)
|
||||
in cluster "ha". A fake clock drives timeouts, soak and re-checks; every tick runs exactly what the
|
||||
poll loop runs (fire due schedules, step running ones, step the rollout)."""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.models.rollout import RolloutPlan, RolloutRun
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.services import rollout_engine as engine
|
||||
from app.services import scheduled_maintenance_service as svc
|
||||
from tests.test_rules import _create_host
|
||||
|
||||
|
||||
class Fw:
|
||||
def __init__(self, fleet, name, *, role=None, version="26.7.6", latest="26.7.7"):
|
||||
self.fleet, self.name, self.base = fleet, name, f"https://{name}.test"
|
||||
self.role, self.maint = role, False
|
||||
self.version, self.latest, self.offer = version, latest, "update"
|
||||
self.job, self.job_ticks, self.boot, self.up = "ready", 0, "boot-1", True
|
||||
self.update_error = False
|
||||
self.services = [{"id": "unbound", "name": "unbound", "locked": 0, "running": 1},
|
||||
{"id": "ntpd", "name": "ntpd", "locked": 0, "running": 1}]
|
||||
self.gateways = [{"name": "WAN_GW", "status": "none", "loss": "0.0 %", "delay": "1 ms", "stddev": "0.1 ms"}]
|
||||
r, b = fleet.mock, self.base
|
||||
self.update = r.post(f"{b}/api/core/firmware/update").mock(side_effect=self._update)
|
||||
self.check = r.post(f"{b}/api/core/firmware/check").mock(side_effect=lambda q: self._ok({"status": "ok"}))
|
||||
self.toggles = r.post(f"{b}/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=self._toggle)
|
||||
r.get(f"{b}/api/core/backup/download/this").mock(side_effect=lambda q: self._raw(b"<opnsense/>"))
|
||||
r.get(f"{b}/api/diagnostics/interface/getVipStatus").mock(side_effect=self._vip)
|
||||
r.get(f"{b}/api/core/firmware/running").mock(side_effect=lambda q: self._ok({"status": self.job}))
|
||||
r.get(f"{b}/api/core/firmware/info").mock(side_effect=lambda q: self._ok({"product_version": self.version}))
|
||||
r.get(f"{b}/api/core/firmware/status").mock(side_effect=self._status)
|
||||
r.get(f"{b}/api/diagnostics/system/systemTime").mock(side_effect=lambda q: self._ok({"boottime": self.boot}))
|
||||
r.get(f"{b}/api/routes/gateway/status").mock(side_effect=lambda q: self._ok({"items": self.gateways, "status": "ok"}))
|
||||
r.get(f"{b}/api/core/service/search").mock(side_effect=lambda q: self._ok({"rows": self.services}))
|
||||
|
||||
@property
|
||||
def down(self):
|
||||
return self.job == "down" or not self.up
|
||||
|
||||
def _ok(self, data):
|
||||
if self.down:
|
||||
raise httpx.ConnectError("down")
|
||||
return httpx.Response(200, json=data)
|
||||
|
||||
def _raw(self, content):
|
||||
if self.down:
|
||||
raise httpx.ConnectError("down")
|
||||
return httpx.Response(200, content=content)
|
||||
|
||||
def _status(self, request):
|
||||
if self.offer == "update":
|
||||
return self._ok({"status": "update", "status_msg": "x", "status_reboot": "1", "all_packages": {},
|
||||
"product": {"product_latest": self.latest}})
|
||||
return self._ok({"status": self.offer, "status_msg": "x", "status_reboot": "0", "all_packages": {}, "product": {}})
|
||||
|
||||
def _update(self, request):
|
||||
if self.update_error:
|
||||
return httpx.Response(500, json={"status": "error"})
|
||||
self.job, self.job_ticks = "running", 2
|
||||
return self._ok({"status": "ok"})
|
||||
|
||||
def _vip(self, request):
|
||||
rows = [{"vhid": "1", "status": self.role}] if self.role else []
|
||||
return self._ok({"rows": rows, "carp": {"allow": "1", "maintenancemode": self.maint, "demotion": "240" if self.maint else "0",
|
||||
"status_msg": ""}})
|
||||
|
||||
def _toggle(self, request):
|
||||
self.maint = not self.maint
|
||||
partner = self.fleet.partner(self)
|
||||
if self.maint:
|
||||
self.role, partner.role = "BACKUP", "MASTER"
|
||||
else:
|
||||
self.role, partner.role = "MASTER", "BACKUP" # preempt: takes MASTER back
|
||||
return self._ok({"status": "ok", "action": "enter_maintenance" if self.maint else "leave_maintenance"})
|
||||
|
||||
def progress(self):
|
||||
if self.job == "running":
|
||||
self.job_ticks -= 1
|
||||
if self.job_ticks <= 0:
|
||||
self.job, self.job_ticks = "down", 2
|
||||
elif self.job == "down":
|
||||
self.job_ticks -= 1
|
||||
if self.job_ticks <= 0:
|
||||
self.job, self.version, self.offer, self.boot = "ready", self.latest, "none", self.boot + "+"
|
||||
|
||||
|
||||
class Fleet:
|
||||
def __init__(self, respx_mock):
|
||||
self.mock = respx_mock
|
||||
self.fw = {"c1": Fw(self, "c1"), "ha-a": Fw(self, "ha-a", role="MASTER"), "ha-b": Fw(self, "ha-b", role="BACKUP")}
|
||||
|
||||
def partner(self, fw):
|
||||
return self.fw["ha-b"] if fw.name == "ha-a" else self.fw["ha-a"]
|
||||
|
||||
def progress(self):
|
||||
for fw in self.fw.values():
|
||||
fw.progress()
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = datetime.now(timezone.utc)
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
c = Clock()
|
||||
monkeypatch.setattr(engine, "_now", c)
|
||||
monkeypatch.setattr(svc, "_now", c)
|
||||
return c
|
||||
|
||||
|
||||
async def _setup(client, respx_mock, *, waves=None, **plan_kw):
|
||||
fleet = Fleet(respx_mock)
|
||||
hosts = {n: await _create_host(client, n, f"https://{n}.test") for n in ("c1", "ha-a", "ha-b")}
|
||||
for n in ("ha-a", "ha-b"):
|
||||
await client.patch(f"/api/hosts/{hosts[n]['id']}", json={"cluster_name": "ha"})
|
||||
groups = {}
|
||||
for g, members in (("canary", ["c1"]), ("pilot", ["ha-b"]), ("prod", ["ha-a"]), ("cluster", ["ha-a", "ha-b"])):
|
||||
groups[g] = (await client.post("/api/groups", json={"name": g, "host_ids": [hosts[m]["id"] for m in members]})).json()["id"]
|
||||
waves = waves or [
|
||||
{"label": "canary", "group_ids": [groups["canary"]], "soak_minutes": 5},
|
||||
{"label": "pilot", "group_ids": [groups["pilot"]], "soak_minutes": 5},
|
||||
{"label": "prod", "group_ids": [groups["prod"]], "soak_minutes": 0, "carp_handover": True},
|
||||
]
|
||||
waves = [{**w, "group_ids": [groups[g] if g in groups else g for g in w["group_ids"]]} for w in waves]
|
||||
plan = (await client.post("/api/rollouts/plans", json={"name": "ringe", "action": "update", "waves": waves, **plan_kw})).json()
|
||||
return fleet, hosts, groups, plan
|
||||
|
||||
|
||||
async def _start(client, plan, api=None):
|
||||
api = api or client
|
||||
token = (await api.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
|
||||
resp = await api.post(f"/api/rollouts/plans/{plan['id']}/start",
|
||||
json={"preview_token": token, "confirm_name": plan["name"], "acknowledge_unattended": True})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _tick(fleet, clock, seconds=30):
|
||||
fleet.progress()
|
||||
async with SessionLocal() as db:
|
||||
await svc.fire_due(db)
|
||||
async with SessionLocal() as db:
|
||||
await svc.advance_running(db)
|
||||
async with SessionLocal() as db:
|
||||
await engine.advance_runs(db)
|
||||
clock.now += timedelta(seconds=seconds)
|
||||
|
||||
|
||||
async def _run(run_id):
|
||||
async with SessionLocal() as db:
|
||||
return await db.scalar(select(RolloutRun).where(RolloutRun.id == uuid.UUID(str(run_id))).execution_options(populate_existing=True))
|
||||
|
||||
|
||||
async def _until(fleet, clock, run_id, pred, max_ticks=400, check=None):
|
||||
for _ in range(max_ticks):
|
||||
run = await _run(run_id)
|
||||
if pred(run):
|
||||
return run
|
||||
if check:
|
||||
await check()
|
||||
await _tick(fleet, clock)
|
||||
raise AssertionError(f"not reached: status={run.status} reason={run.pause_reason} detail={run.pause_detail}")
|
||||
|
||||
|
||||
def _host(run, name):
|
||||
return next(h for h in run.hosts if h.host_name == name)
|
||||
|
||||
|
||||
async def _scheds():
|
||||
async with SessionLocal() as db:
|
||||
return list((await db.scalars(select(ScheduledMaintenance).order_by(ScheduledMaintenance.created_at))).all())
|
||||
|
||||
|
||||
async def _no_two_at_once():
|
||||
active = [s for s in await _scheds() if s.status in ("pending", "running")]
|
||||
assert len(active) <= 1, [(s.host_name, s.status, s.phase) for s in active]
|
||||
|
||||
|
||||
# ── happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_rings_run_through_and_the_master_hands_over(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"), check=_no_two_at_once)
|
||||
assert done.status == "succeeded", (done.pause_reason, done.pause_detail)
|
||||
assert done.target_version == "26.7.7"
|
||||
assert [(h.host_name, h.status, h.version_before, h.version_after) for h in sorted(done.hosts, key=lambda h: h.wave_index)] == [
|
||||
("c1", "succeeded", "26.7.6", "26.7.7"), ("ha-b", "succeeded", "26.7.6", "26.7.7"), ("ha-a", "succeeded", "26.7.6", "26.7.7")]
|
||||
assert all(fw.update.call_count == 1 for fw in fleet.fw.values())
|
||||
# The MASTER went through the CARP handover and is MASTER again; the BACKUP never needed one.
|
||||
assert fleet.fw["ha-a"].toggles.call_count == 2 and fleet.fw["ha-b"].toggles.call_count == 0
|
||||
assert (fleet.fw["ha-a"].role, fleet.fw["ha-b"].role) == ("MASTER", "BACKUP")
|
||||
scheds = await _scheds()
|
||||
assert [s.host_name for s in scheds] == ["c1", "ha-b", "ha-a"]
|
||||
assert all(s.origin == "rollout" and s.auto_verify and s.status == "succeeded" for s in scheds)
|
||||
async with SessionLocal() as db:
|
||||
ops = (await db.scalars(select(HostOperation))).all()
|
||||
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "rollout"))).all()
|
||||
assert {o.status for o in ops} == {"acknowledged"} # nothing left locked
|
||||
assert sum(1 for e in events if e.action == "gate" and e.status == "ok") == 3
|
||||
assert any(e.action == "finish" and e.status == "succeeded" for e in events)
|
||||
host_events = [e for e in (await _audit_host()) if (e.details or {}).get("rollout")]
|
||||
assert host_events and all(e.details["rollout"]["run_id"] == run["id"] for e in host_events)
|
||||
|
||||
|
||||
async def _audit_host():
|
||||
async with SessionLocal() as db:
|
||||
return (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "host"))).all()
|
||||
|
||||
|
||||
# ── the gate stops the rollout ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gate_failure_in_the_canary_ring_stops_everything_after_it(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
await _until(fleet, clock, run["id"], lambda r: r.status == "soaking")
|
||||
fleet.fw["c1"].services[1]["running"] = 0 # ntpd did not come back
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert paused.pause_reason == "gate_failed" and paused.paused_from == "gate"
|
||||
assert paused.waves[0].gate_attempts == 2 # looked at twice (slow start), then stopped
|
||||
assert paused.waves[0].gate_result["hosts"][_host(paused, "c1").host_id.__str__()]["services"]["code"] == "service_stopped"
|
||||
for _ in range(20): # time passes, nothing moves
|
||||
await _tick(fleet, clock)
|
||||
assert fleet.fw["ha-b"].update.call_count == 0 and fleet.fw["ha-a"].update.call_count == 0
|
||||
assert [s.host_name for s in await _scheds()] == ["c1"]
|
||||
# Notification while paused.
|
||||
from app.services import notification_checks
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_rollout_paused(db)
|
||||
assert problems[run["id"]]["grund"] == "gate_failed"
|
||||
|
||||
# Fixed by hand, resumed: the gate is checked again first, then it goes on.
|
||||
fleet.fw["c1"].services[1]["running"] = 1
|
||||
resp = await client.post(f"/api/rollouts/runs/{run['id']}/resume")
|
||||
assert resp.status_code == 200 and resp.json()["status"] == "resuming"
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded", (done.pause_reason, done.pause_detail)
|
||||
assert fleet.fw["ha-a"].update.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_rechecks_and_pauses_again_if_still_broken(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
await _until(fleet, clock, run["id"], lambda r: r.status == "soaking")
|
||||
fleet.fw["c1"].gateways[0] = {"name": "WAN_GW", "status": "down", "loss": "100 %", "delay": "~", "stddev": "~"}
|
||||
await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert (await client.post(f"/api/rollouts/runs/{run['id']}/resume")).status_code == 200
|
||||
again = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert again.pause_reason == "gate_failed" and again.pause_detail.get("on_resume") is True
|
||||
assert fleet.fw["ha-b"].update.call_count == 0
|
||||
|
||||
|
||||
# ── never twice, never in parallel ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_in_the_middle_of_job_wait_submits_exactly_once(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
|
||||
async def in_job_wait(r):
|
||||
return any(s.host_name == "ha-b" and s.phase == "job_wait" for s in await _scheds())
|
||||
for _ in range(300):
|
||||
if await in_job_wait(None):
|
||||
break
|
||||
await _tick(fleet, clock)
|
||||
# "Restart": nothing survives but the database (each tick opens fresh sessions anyway); two backends
|
||||
# even step the same run at the very same moment.
|
||||
await asyncio.gather(*(engine.tick(uuid.UUID(run["id"])) for _ in range(3)))
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded"
|
||||
assert fleet.fw["ha-b"].update.call_count == 1 and len([s for s in await _scheds() if s.host_name == "ha-b"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_backends_deciding_at_once_create_one_schedule(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
await _until(fleet, clock, run["id"], lambda r: r.status == "wave_running")
|
||||
await asyncio.gather(*(engine.tick(uuid.UUID(run["id"])) for _ in range(4)))
|
||||
assert len(await _scheds()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cluster_in_one_wave_goes_backup_first_and_never_both_at_once(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock, waves=[
|
||||
{"label": "cluster", "group_ids": ["cluster"], "soak_minutes": 1, "carp_handover": True}])
|
||||
run = await _start(client, plan)
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"), check=_no_two_at_once)
|
||||
assert done.status == "succeeded", (done.pause_reason, done.pause_detail)
|
||||
assert [s.host_name for s in await _scheds()] == ["ha-b", "ha-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_master_without_handover_pauses_before_anything_happens_to_it(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock, waves=[
|
||||
{"label": "cluster", "group_ids": ["cluster"], "soak_minutes": 1, "carp_handover": False}])
|
||||
run = await _start(client, plan)
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert paused.pause_reason == "master_needs_handover" and paused.pause_detail["host"] == "ha-a"
|
||||
assert fleet.fw["ha-b"].update.call_count == 1 and fleet.fw["ha-a"].update.call_count == 0
|
||||
assert fleet.fw["ha-a"].toggles.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_gate_before_the_second_node(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock, waves=[
|
||||
{"label": "cluster", "group_ids": ["cluster"], "soak_minutes": 1, "carp_handover": True}])
|
||||
run = await _start(client, plan)
|
||||
|
||||
async def break_backup_after_update():
|
||||
if fleet.fw["ha-b"].version == "26.7.7":
|
||||
fleet.fw["ha-b"].services[0]["running"] = 0 # unbound dead on the freshly updated node
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused", check=break_backup_after_update)
|
||||
assert paused.pause_reason == "cluster_gate_failed" and paused.pause_detail["host"] == "ha-a"
|
||||
assert fleet.fw["ha-a"].update.call_count == 0 and fleet.fw["ha-a"].toggles.call_count == 0
|
||||
|
||||
|
||||
# ── stop when in doubt ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_different_target_version_in_a_later_ring_pauses(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
fleet.fw["ha-b"].latest = "26.7.8" # a newer release appeared after the canary ran
|
||||
run = await _start(client, plan)
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert paused.pause_reason == "target_version_changed"
|
||||
assert paused.pause_detail == {"expected": "26.7.7", "offered": "26.7.8"}
|
||||
assert fleet.fw["ha-b"].update.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosts_without_an_update_are_skipped(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
fleet.fw["ha-b"].offer = "none"
|
||||
run = await _start(client, plan)
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded" and _host(done, "ha-b").status == "skipped"
|
||||
assert _host(done, "ha-b").detail["code"] == "no_update_offered" and done.waves[1].status == "skipped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_host_pauses_and_resume_needs_an_explicit_skip(client, respx_mock, clock):
|
||||
fleet, hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
fleet.fw["c1"].update_error = True
|
||||
run = await _start(client, plan)
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert paused.pause_reason == "host_failed" and _host(paused, "c1").status == "failed"
|
||||
refused = await client.post(f"/api/rollouts/runs/{run['id']}/resume")
|
||||
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "ROLLOUT_HOST_FAILED"
|
||||
skip = await client.post(f"/api/rollouts/runs/{run['id']}/skip-host", json={"host_id": hosts["c1"]["id"], "reason": "geprüft, später"})
|
||||
assert skip.status_code == 200
|
||||
# The failed operation stays "unknown" (locks c1, notifies) until a human closes it; it is not retried.
|
||||
assert (await client.post(f"/api/rollouts/runs/{run['id']}/resume")).status_code == 200
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded", (done.pause_reason, done.pause_detail)
|
||||
assert fleet.fw["c1"].update.call_count == 1 and _host(done, "c1").detail["code"] == "skipped_by"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable_host_in_preflight_is_blocked_then_paused(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
fleet.fw["c1"].up = False
|
||||
run = await _start(client, plan)
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert paused.pause_reason == "preflight_blocked" and _host(paused, "c1").status == "blocked"
|
||||
assert clock.now - datetime.fromisoformat(paused.progress["paused_at"]) < timedelta(minutes=1)
|
||||
fleet.fw["c1"].up = True
|
||||
assert (await client.post(f"/api/rollouts/runs/{run['id']}/resume")).status_code == 200
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_someone_elses_open_maintenance_blocks_then_pauses(client, respx_mock, clock):
|
||||
fleet, hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
await _until(fleet, clock, run["id"], lambda r: r.current_wave == 1 and r.status == "wave_running")
|
||||
async with SessionLocal() as db:
|
||||
db.add(HostOperation(host_id=uuid.UUID(hosts["ha-a"]["id"]), host_name="ha-a", actor="someone", action="reboot",
|
||||
status="submitted", expires_at=clock.now, fingerprint="", snapshot={}))
|
||||
await db.commit()
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert paused.pause_reason in ("blocked_by_maintenance", "cluster_gate_failed")
|
||||
assert fleet.fw["ha-b"].update.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_actor_who_lost_the_right_pauses_the_run(client, respx_mock, clock):
|
||||
from tests.test_operator_role import _operator
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
op = await _operator(client, "rollengine-op")
|
||||
try:
|
||||
run = await _start(client, plan, api=op)
|
||||
assert run["started_by"] == "rollengine-op"
|
||||
await _until(fleet, clock, run["id"], lambda r: r.status == "soaking")
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": False})
|
||||
paused = await _until(fleet, clock, run["id"], lambda r: r.status == "paused")
|
||||
assert paused.pause_reason == "actor_invalid" and fleet.fw["ha-b"].update.call_count == 0
|
||||
# An admin takes over by resuming.
|
||||
assert (await client.post(f"/api/rollouts/runs/{run['id']}/resume")).json()["started_by"] == "admin"
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded"
|
||||
finally:
|
||||
await op.aclose()
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
|
||||
|
||||
|
||||
# ── windows, approval, skip, abort, automatic start ───────────────────────────
|
||||
|
||||
async def _window_now(client, clock, *, cutoff=60, duration=120):
|
||||
local = clock.now
|
||||
body = {"name": f"jetzt-{uuid.uuid4().hex[:6]}", "timezone": "UTC", "recurrence": "weekly", "weekdays": [local.weekday()],
|
||||
"start_time": (local - timedelta(minutes=1)).strftime("%H:%M"), "duration_minutes": duration, "start_cutoff_minutes": cutoff}
|
||||
if (local - timedelta(minutes=1)).date() != local.date():
|
||||
pytest.skip("too close to midnight for a same-day window")
|
||||
resp = await client.post("/api/maintenance-windows", json=body)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closed_window_waits_and_the_next_occurrence_resumes(client, respx_mock, clock):
|
||||
window = await _window_now(client, clock, cutoff=15)
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock, window_id=window["id"])
|
||||
run = await _start(client, plan)
|
||||
waiting = await _until(fleet, clock, run["id"], lambda r: r.status in ("waiting_window", "paused", "succeeded"))
|
||||
assert waiting.status == "waiting_window" and waiting.current_wave >= 1
|
||||
started = {s.host_name for s in await _scheds()}
|
||||
assert "c1" in started and "ha-a" not in started
|
||||
clock.now += timedelta(days=7) - timedelta(minutes=40) # one week later, a few minutes into the next occurrence
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded", (done.pause_reason, done.pause_detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_holds_the_wave(client, respx_mock, clock):
|
||||
fleet, _hosts, groups, plan = await _setup(client, respx_mock, waves=[
|
||||
{"label": "canary", "group_ids": ["canary"], "soak_minutes": 1},
|
||||
{"label": "rest", "group_ids": ["cluster"], "soak_minutes": 1, "carp_handover": True, "require_approval": True}])
|
||||
run = await _start(client, plan)
|
||||
held = await _until(fleet, clock, run["id"], lambda r: r.status == "awaiting_approval")
|
||||
for _ in range(10):
|
||||
await _tick(fleet, clock)
|
||||
assert fleet.fw["ha-b"].update.call_count == 0
|
||||
resp = await client.post(f"/api/rollouts/runs/{run['id']}/approve")
|
||||
assert resp.status_code == 200 and resp.json()["waves"][1]["approved_by"] == "admin"
|
||||
done = await _until(fleet, clock, run["id"], lambda r: r.status in ("succeeded", "paused"))
|
||||
assert done.status == "succeeded" and held.current_wave == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort_cancels_what_has_not_started_and_lets_the_running_host_finish(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
await _until(fleet, clock, run["id"], lambda r: any(h.status == "running" for h in r.hosts))
|
||||
bad = await client.post(f"/api/rollouts/runs/{run['id']}/abort", json={"reason": "Notfall", "confirm_name": "falsch"})
|
||||
assert bad.status_code == 409 and bad.json()["detail"]["code"] == "ROLLOUT_CONFIRM_NAME"
|
||||
resp = await client.post(f"/api/rollouts/runs/{run['id']}/abort", json={"reason": "Notfall", "confirm_name": "ringe"})
|
||||
assert resp.status_code == 200 and resp.json()["status"] == "cancelled"
|
||||
for _ in range(40):
|
||||
await _tick(fleet, clock)
|
||||
run_now = await _run(run["id"])
|
||||
assert run_now.status == "cancelled"
|
||||
assert {h.host_name: h.status for h in run_now.hosts} in (
|
||||
{"c1": "succeeded", "ha-b": "cancelled", "ha-a": "cancelled"}, {"c1": "cancelled", "ha-b": "cancelled", "ha-a": "cancelled"})
|
||||
assert fleet.fw["ha-b"].update.call_count == 0 and fleet.fw["ha-a"].update.call_count == 0
|
||||
assert (await client.post(f"/api/rollouts/runs/{run['id']}/resume")).status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_by_hand_and_operator_may_always_brake(client, respx_mock, clock):
|
||||
from tests.test_operator_role import _operator
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": False})
|
||||
op = await _operator(client, "brake-op")
|
||||
try:
|
||||
resp = await op.post(f"/api/rollouts/runs/{run['id']}/pause", json={"reason": "Bauchgefühl"})
|
||||
assert resp.status_code == 200 and resp.json()["pause_reason"] == "manual"
|
||||
denied = await op.post(f"/api/rollouts/runs/{run['id']}/resume")
|
||||
assert denied.status_code == 403 and denied.json()["detail"]["code"] == "DEPLOY_NEEDS_ADMIN"
|
||||
finally:
|
||||
await op.aclose()
|
||||
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
|
||||
for _ in range(10):
|
||||
await _tick(fleet, clock)
|
||||
assert sum(fw.update.call_count for fw in fleet.fw.values()) == 0
|
||||
second = await client.post(f"/api/rollouts/plans/{plan['id']}/start", json={
|
||||
"preview_token": (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"],
|
||||
"confirm_name": "ringe", "acknowledge_unattended": True})
|
||||
assert second.status_code == 409 and second.json()["detail"]["code"] == "ROLLOUT_ALREADY_ACTIVE"
|
||||
|
||||
|
||||
async def _until_step_pending(fleet, clock, run_id):
|
||||
"""Right after the engine created a host's schedule (fire_due has not seen it yet)."""
|
||||
run = await _until(fleet, clock, run_id, lambda r: any(h.status == "running" for h in r.hosts))
|
||||
name = next(h.host_name for h in run.hosts if h.status == "running")
|
||||
assert [s.status for s in await _scheds()] == ["pending"]
|
||||
return name
|
||||
|
||||
|
||||
async def _resume_to_the_end(client, fleet, clock, run_id):
|
||||
assert (await client.post(f"/api/rollouts/runs/{run_id}/resume")).status_code == 200
|
||||
done = await _until(fleet, clock, run_id, lambda r: r.status in ("succeeded", "paused"), check=_no_two_at_once)
|
||||
assert done.status == "succeeded", (done.pause_reason, done.pause_detail)
|
||||
assert all(fw.update.call_count == 1 for fw in fleet.fw.values())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_while_a_step_is_pending_really_brakes_and_resume_runs_it_once(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
name = await _until_step_pending(fleet, clock, run["id"])
|
||||
resp = await client.post(f"/api/rollouts/runs/{run['id']}/pause", json={"reason": "halt"})
|
||||
assert resp.status_code == 200
|
||||
for _ in range(10):
|
||||
await _tick(fleet, clock)
|
||||
assert fleet.fw[name].update.call_count == 0 # the pending step did not fire behind the pause
|
||||
paused = await _run(run["id"])
|
||||
assert paused.status == "paused" and _host(paused, name).status == "queued"
|
||||
assert _host(paused, name).scheduled_maintenance_id is None
|
||||
assert [s.status for s in await _scheds()] == ["cancelled"]
|
||||
await _resume_to_the_end(client, fleet, clock, run["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_step_found_long_overdue_after_an_outage_is_not_fired(client, respx_mock, clock):
|
||||
fleet, _hosts, _groups, plan = await _setup(client, respx_mock)
|
||||
run = await _start(client, plan)
|
||||
name = await _until_step_pending(fleet, clock, run["id"])
|
||||
clock.now += timedelta(minutes=30) # OPNexus was down (e.g. a web update); the window may be over by now
|
||||
await _tick(fleet, clock)
|
||||
assert fleet.fw[name].update.call_count == 0
|
||||
paused = await _run(run["id"])
|
||||
assert (paused.status, paused.pause_reason) == ("paused", "host_not_started")
|
||||
assert _host(paused, name).status == "queued" and _host(paused, name).detail["code"] == "not_fired"
|
||||
(sched,) = await _scheds()
|
||||
assert sched.status == "failed" and sched.result["not_fired"]
|
||||
await _resume_to_the_end(client, fleet, clock, run["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_automatic_start_once_per_occurrence_and_only_while_unchanged(client, respx_mock, clock):
|
||||
window = await _window_now(client, clock)
|
||||
fleet, _hosts, groups, plan = await _setup(client, respx_mock, window_id=window["id"], auto_start=True)
|
||||
token = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
|
||||
assert (await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})).status_code == 200
|
||||
for _ in range(3):
|
||||
async with SessionLocal() as db:
|
||||
await engine.auto_start(db)
|
||||
async with SessionLocal() as db:
|
||||
runs = (await db.scalars(select(RolloutRun))).all()
|
||||
assert len(runs) == 1 and runs[0].trigger == "window" and runs[0].started_by == "admin"
|
||||
assert runs[0].window_occurrence is not None
|
||||
|
||||
# Next week, but somebody added a host to the canary group meanwhile: no start, a notification instead.
|
||||
async with SessionLocal() as db:
|
||||
(await db.get(RolloutRun, runs[0].id)).status = "cancelled"
|
||||
await db.commit()
|
||||
await client.put(f"/api/groups/{groups['canary']}", json={"name": "canary", "host_ids": [_hosts["c1"]["id"], _hosts["ha-a"]["id"]]})
|
||||
clock.now += timedelta(days=7)
|
||||
async with SessionLocal() as db:
|
||||
await engine.auto_start(db)
|
||||
async with SessionLocal() as db:
|
||||
assert len((await db.scalars(select(RolloutRun))).all()) == 1
|
||||
stored = await db.get(RolloutPlan, uuid.UUID(plan["id"]))
|
||||
assert stored.auto_start_error["code"] == "plan_changed"
|
||||
from app.services import notification_checks
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_rollout_start_failed(db)
|
||||
assert list(problems.values())[0]["grund"] == "plan_changed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_second_backend_waiting_for_the_lock_reports_no_refusal(client, respx_mock, clock, monkeypatch):
|
||||
window = await _window_now(client, clock)
|
||||
_fleet, _hosts, _groups, plan = await _setup(client, respx_mock, window_id=window["id"], auto_start=True)
|
||||
token = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
|
||||
assert (await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})).status_code == 200
|
||||
real_lock = engine.policy_lock
|
||||
|
||||
async def racing_lock(db):
|
||||
monkeypatch.setattr(engine, "policy_lock", real_lock)
|
||||
async with SessionLocal() as other: # backend A starts the run while B waits for the lock
|
||||
assert await engine.auto_start(other) == 1
|
||||
await real_lock(db)
|
||||
monkeypatch.setattr(engine, "policy_lock", racing_lock)
|
||||
async with SessionLocal() as db:
|
||||
assert await engine.auto_start(db) == 0
|
||||
async with SessionLocal() as db:
|
||||
assert len((await db.scalars(select(RolloutRun))).all()) == 1
|
||||
assert (await db.get(RolloutPlan, uuid.UUID(plan["id"]))).auto_start_error is None
|
||||
|
||||
|
||||
def test_target_version_comes_from_the_core_package_not_product_latest():
|
||||
# Shape seen live on test-fw-3 (26.7.1_1): product_latest lagged behind what would really be installed.
|
||||
status = {"status": "update", "product": {"product_latest": "26.7.2"},
|
||||
"all_packages": {"opnsense": {"old": "26.7.1_1", "new": "26.7.7", "reason": "upgrade"}},
|
||||
"upgrade_packages": [{"name": "opnsense", "current_version": "26.7.1_1", "new_version": "26.7.7"}]}
|
||||
assert engine.target_of(status, "update") == "26.7.7"
|
||||
assert engine.target_of({**status, "all_packages": {}}, "update") == "26.7.7"
|
||||
assert engine.target_of({"product": {"product_latest": "26.7.7"}}, "update") == "26.7.7"
|
||||
assert engine.target_of({"upgrade_major_version": "27.1"}, "major_update") == "27.1"
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Health gate checks of the rollout engine, one by one, against a fake firewall pair."""
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.carp_maintenance_window import CarpMaintenanceWindow
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.services import rollout_health as rh
|
||||
from tests.test_rules import _create_host
|
||||
|
||||
A, B = "https://gw-a.test", "https://gw-b.test"
|
||||
|
||||
|
||||
class Fw:
|
||||
def __init__(self, respx_mock, base, *, role="MASTER", version="26.7.7"):
|
||||
self.base, self.role, self.version, self.up, self.job, self.maint = base, role, version, True, "ready", False
|
||||
self.vips = None
|
||||
self.gateways = [{"name": "WAN_GW", "status": "none", "loss": "0.0 %", "delay": "1.0 ms", "stddev": "0.1 ms"}]
|
||||
self.services = [{"id": "unbound", "name": "unbound", "locked": 0, "running": 1},
|
||||
{"id": "ntpd", "name": "ntpd", "locked": 0, "running": 1}]
|
||||
self.gw_error = False
|
||||
r = respx_mock
|
||||
r.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=self._vip)
|
||||
r.get(f"{base}/api/core/firmware/running").mock(side_effect=lambda q: self._ok({"status": self.job}))
|
||||
r.get(f"{base}/api/core/firmware/info").mock(side_effect=lambda q: self._ok({"product_version": self.version}))
|
||||
r.get(f"{base}/api/routes/gateway/status").mock(side_effect=self._gw)
|
||||
r.get(f"{base}/api/core/service/search").mock(side_effect=lambda q: self._ok({"rows": self.services}))
|
||||
|
||||
def _ok(self, data):
|
||||
if not self.up:
|
||||
raise httpx.ConnectError("down")
|
||||
return httpx.Response(200, json=data)
|
||||
|
||||
def _gw(self, request):
|
||||
if self.gw_error:
|
||||
return httpx.Response(403, json={"message": "forbidden"})
|
||||
return self._ok({"items": self.gateways, "status": "ok"})
|
||||
|
||||
def _vip(self, request):
|
||||
rows = [{"vhid": v, "status": s} for v, s in (self.vips or {"1": self.role}).items()]
|
||||
return self._ok({"rows": rows, "carp": {"allow": "1", "maintenancemode": self.maint, "demotion": "0", "status_msg": ""}})
|
||||
|
||||
|
||||
async def _hosts(client, cluster=True):
|
||||
a = await _create_host(client, "gw-a", A)
|
||||
if not cluster:
|
||||
return a, None
|
||||
b = await _create_host(client, "gw-b", B)
|
||||
if cluster:
|
||||
for h in (a, b):
|
||||
await client.patch(f"/api/hosts/{h['id']}", json={"cluster_name": "ha"})
|
||||
return a, b
|
||||
|
||||
|
||||
async def _eval(host_ids, baselines=None, target="26.7.7", **kw):
|
||||
async with SessionLocal() as db:
|
||||
hosts = [await db.get(OpnsenseHost, uuid.UUID(h)) for h in host_ids]
|
||||
if baselines is None:
|
||||
baselines = {str(h.id): await rh.baseline(db, h) for h in hosts}
|
||||
return await rh.evaluate(db, hosts, baselines, target_version=target, **kw), baselines
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_healthy_cluster_passes_every_check(client, respx_mock):
|
||||
Fw(respx_mock, A)
|
||||
Fw(respx_mock, B, role="BACKUP")
|
||||
a, _ = await _hosts(client)
|
||||
gate, base = await _eval([a["id"]])
|
||||
assert gate["verdict"] == "ok", gate
|
||||
row = gate["hosts"][a["id"]]
|
||||
assert {k: v["result"] for k, v in row.items()} == {
|
||||
"online": "ok", "version": "ok", "carp": "ok", "gateways": "ok", "services": "ok", "locks": "ok"}
|
||||
assert base[a["id"]]["gateways"] == {"WAN_GW": "online"} and base[a["id"]]["services"]["ntpd"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offline_or_busy_host_and_wrong_version_fail(client, respx_mock):
|
||||
fa = Fw(respx_mock, A)
|
||||
a, _ = await _hosts(client, cluster=False)
|
||||
fa.version = "26.7.6"
|
||||
gate, base = await _eval([a["id"]])
|
||||
assert gate["hosts"][a["id"]]["version"] == {"result": "fail", "code": "version_mismatch",
|
||||
"params": {"expected": "26.7.7", "actual": "26.7.6"}}
|
||||
fa.job = "busy"
|
||||
gate, _ = await _eval([a["id"]], base)
|
||||
assert gate["hosts"][a["id"]]["online"]["code"] == "firmware_busy"
|
||||
fa.up = False
|
||||
gate, _ = await _eval([a["id"]], base)
|
||||
assert gate["hosts"][a["id"]]["online"]["code"] == "offline" and gate["verdict"] == "fail"
|
||||
assert gate["hosts"][a["id"]]["gateways"]["result"] == "fail" # readable before, not now
|
||||
assert rh.failed_checks(gate)[0].startswith(a["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_carp_split_brain_maintenance_mode_and_timer_fail(client, respx_mock):
|
||||
fa = Fw(respx_mock, A)
|
||||
fb = Fw(respx_mock, B, role="BACKUP")
|
||||
a, b = await _hosts(client)
|
||||
fb.role = "MASTER" # both MASTER on VHID 1
|
||||
gate, _ = await _eval([a["id"]])
|
||||
assert gate["hosts"][a["id"]]["carp"] == {"result": "fail", "code": "vhid_roles", "params": {"vhids": "1"}}
|
||||
fb.role = "BACKUP"
|
||||
fa.vips, fb.vips = {"1": "MASTER", "2": "MASTER"}, {"1": "BACKUP", "2": "MASTER"} # partial failover on VHID 2
|
||||
gate, _ = await _eval([a["id"]])
|
||||
assert gate["hosts"][a["id"]]["carp"]["params"] == {"vhids": "2"}
|
||||
fa.vips = fb.vips = None
|
||||
fb.maint = True
|
||||
gate, _ = await _eval([a["id"]])
|
||||
assert gate["hosts"][a["id"]]["carp"]["code"] == "maintenance_mode"
|
||||
fb.maint = False
|
||||
async with SessionLocal() as db:
|
||||
db.add(CarpMaintenanceWindow(host_id=uuid.UUID(b["id"]), host_name="gw-b", status="active"))
|
||||
await db.commit()
|
||||
gate, _ = await _eval([a["id"]])
|
||||
assert gate["hosts"][a["id"]]["carp"]["code"] == "auto_revert_active"
|
||||
fb.up = False
|
||||
gate, _ = await _eval([a["id"]])
|
||||
assert gate["hosts"][a["id"]]["carp"] == {"result": "fail", "code": "node_offline", "params": {"names": "gw-b"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateways_compare_against_the_baseline(client, respx_mock):
|
||||
fa = Fw(respx_mock, A)
|
||||
fa.gateways.append({"name": "OLD_GW", "status": "down", "loss": "100.0 %", "delay": "0.0 ms", "stddev": "0.0 ms"})
|
||||
a, _ = await _hosts(client, cluster=False)
|
||||
_gate, base = await _eval([a["id"]])
|
||||
assert (await _eval([a["id"]], base))[0]["hosts"][a["id"]]["gateways"]["code"] == "gateway_bad_before" # only warns
|
||||
fa.gateways[0] = {"name": "WAN_GW", "status": "down", "loss": "100.0 %", "delay": "~", "stddev": "~"}
|
||||
gate, _ = await _eval([a["id"]], base)
|
||||
assert gate["hosts"][a["id"]]["gateways"] == {"result": "fail", "code": "gateway_down", "params": {"names": "WAN_GW"}}
|
||||
fa.gateways[0] = {"name": "WAN_GW", "status": "loss", "loss": "30 %", "delay": "5 ms", "stddev": "1 ms"}
|
||||
assert (await _eval([a["id"]], base))[0]["hosts"][a["id"]]["gateways"]["result"] == "warn"
|
||||
fa.gw_error = True
|
||||
assert (await _eval([a["id"]], base))[0]["hosts"][a["id"]]["gateways"]["result"] == "fail"
|
||||
gate, _ = await _eval([a["id"]], {a["id"]: {**base[a["id"]], "gateways": None}})
|
||||
assert gate["hosts"][a["id"]]["gateways"]["result"] == "skip" # no permission before either: nothing to compare
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_services_that_ran_before_must_run_again(client, respx_mock):
|
||||
fa = Fw(respx_mock, A)
|
||||
fa.services.append({"id": "dhcpd6", "name": "dhcpd6", "locked": 0, "running": 0})
|
||||
a, _ = await _hosts(client, cluster=False)
|
||||
_gate, base = await _eval([a["id"]])
|
||||
fa.services[1] = {"id": "ntpd", "name": "ntpd", "locked": 0, "running": 0}
|
||||
gate, _ = await _eval([a["id"]], base)
|
||||
assert gate["hosts"][a["id"]]["services"] == {"result": "fail", "code": "service_stopped", "params": {"names": "ntpd"}}
|
||||
fa.services[1]["running"] = 1
|
||||
assert (await _eval([a["id"]], base))[0]["hosts"][a["id"]]["services"]["result"] == "ok" # dhcpd6 never ran: fine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_operation_in_the_cluster_is_a_lock(client, respx_mock):
|
||||
Fw(respx_mock, A)
|
||||
Fw(respx_mock, B, role="BACKUP")
|
||||
a, b = await _hosts(client)
|
||||
from datetime import datetime, timezone
|
||||
async with SessionLocal() as db:
|
||||
db.add(HostOperation(host_id=uuid.UUID(b["id"]), host_name="gw-b", actor="x", action="reboot", status="submitted",
|
||||
expires_at=datetime.now(timezone.utc), fingerprint="", snapshot={}))
|
||||
await db.commit()
|
||||
gate, _ = await _eval([a["id"]])
|
||||
assert gate["hosts"][a["id"]]["locks"] == {"result": "fail", "code": "open_locks", "params": {"count": 1}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,expected", [("warn", "warn"), ("fail", "fail")])
|
||||
@pytest.mark.asyncio
|
||||
async def test_lower_compliance_score_warns_or_fails(client, respx_mock, monkeypatch, mode, expected):
|
||||
Fw(respx_mock, A)
|
||||
a, _ = await _hosts(client, cluster=False)
|
||||
async with SessionLocal() as db:
|
||||
db.add(ComplianceResult(host_id=uuid.UUID(a["id"]), checked_at=datetime_now(), score=80, findings=[]))
|
||||
await db.commit()
|
||||
|
||||
async def fake_check(db, host):
|
||||
row = await db.get(ComplianceResult, (await db.scalar(__import__("sqlalchemy").select(ComplianceResult.id))))
|
||||
row.score = 70
|
||||
await db.commit()
|
||||
return row
|
||||
monkeypatch.setattr(rh.compliance_service, "check_host", fake_check)
|
||||
gate, _ = await _eval([a["id"]], checks={"compliance": True}, compliance_mode=mode)
|
||||
assert gate["hosts"][a["id"]]["compliance"] == {"result": expected, "code": "score_lower", "params": {"before": 80, "after": 70}}
|
||||
assert gate["verdict"] == expected
|
||||
# the gate cannot be switched off where it matters
|
||||
gate, _ = await _eval([a["id"]], checks={"online": False, "version": False, "locks": False})
|
||||
assert {"online", "version", "locks"} <= set(gate["hosts"][a["id"]])
|
||||
|
||||
|
||||
def datetime_now():
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Rule analysis (1.26): normalization, resolver and pf semantics of the engine.
|
||||
|
||||
Pure functions against an anonymized live fixture (OPNsense 26.7: automatic rules, antispoof
|
||||
lines, a CARP VIP on LAN). Synthetic rules get pf lines in sort order, as pf would load them.
|
||||
The numbers in the test names follow the test matrix of the design (section 8).
|
||||
"""
|
||||
import copy
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.rule_analysis import context as ctx_mod
|
||||
from app.services.rule_analysis import pf_ruleset, resolve
|
||||
from app.services.rule_analysis.engine import evaluate
|
||||
from app.services.rule_analysis.normalize import normalize
|
||||
from app.services.rule_analysis.service import build_query
|
||||
|
||||
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "rule_analysis_fw.json").read_text())
|
||||
DEVICES = {"lan": "vtnet1_vlan10", "wan": "vtnet0"}
|
||||
LAN_CLIENT, INTERNET, WAN_IP = "10.20.30.50", "198.51.100.10", "203.0.113.57"
|
||||
|
||||
|
||||
def mvc(uuid, *, prio=400000, seq=1, action="pass", quick="1", interface="lan", src="any", dst="any",
|
||||
proto="any", ipprotocol="inet", dport="", sport="", direction="in", loaded=True, **extra):
|
||||
row = {
|
||||
"uuid": uuid, "sort_order": f"{prio:06d}.{seq:07d}", "prio_group": str(prio), "sequence": str(seq),
|
||||
"enabled": "1", "action": action, "quick": quick, "interface": interface, "interfacenot": "0",
|
||||
"direction": direction, "ipprotocol": ipprotocol, "protocol": proto, "source_net": src, "source_not": "0",
|
||||
"source_port": sport, "destination_net": dst, "destination_not": "0", "destination_port": dport,
|
||||
"gateway": "", "sched": "", "statetype": "keep", "tagged": "", "description": uuid,
|
||||
}
|
||||
row.update(extra)
|
||||
row["_loaded"] = loaded
|
||||
return row
|
||||
|
||||
|
||||
def build(extra_rows=(), *, drop=(), aliases=None, refs=None, groups=None, pf=True, port_forwards=()):
|
||||
"""A Context from the fixture plus synthetic MVC rules. pf lines of MVC rules are generated
|
||||
in sort order (pf numbers follow the evaluation order like on a real firewall)."""
|
||||
data = copy.deepcopy(FIXTURE)
|
||||
rows = [r for r in data["rules"]["rows"] if r["description"] not in drop] + [dict(r) for r in extra_rows]
|
||||
interfaces = ctx_mod._interfaces(data["interfaces"])
|
||||
group_rows = data["groups"]["rows"] + [{"ifname": k, "members": ",".join(v)} for k, v in (groups or {}).items()]
|
||||
stats = None
|
||||
if pf:
|
||||
auto = {k: v for k, v in data["pf"]["rules"]["filter rules"].items()
|
||||
if not any(r.get("uuid") in k for r in data["rules"]["rows"] if not r.get("is_automatic"))}
|
||||
lines = sorted(((int(k.split()[0][1:]), k.split(" ", 1)[1]) for k in auto), key=lambda x: x[0])
|
||||
number = max(n for n, _ in lines) + 1
|
||||
mvc_rows = sorted((r for r in rows if not r.get("is_automatic") and r.get("_loaded", True)
|
||||
and r.get("enabled") == "1"), key=lambda r: r.get("sort_order") or "400000." + r["sequence"].zfill(7))
|
||||
for r in mvc_rows:
|
||||
words = ["block" if r["action"] == "block" else ("block return" if r["action"] == "reject" else "pass"),
|
||||
r["direction"] if r["direction"] != "any" else ""]
|
||||
if r["quick"] == "1":
|
||||
words.append("quick")
|
||||
ifs = [i for i in str(r["interface"]).split(",") if i]
|
||||
if len(ifs) == 1 and ifs[0] in DEVICES:
|
||||
words.append(f"on {DEVICES[ifs[0]]}")
|
||||
if r["ipprotocol"] in ("inet", "inet6"):
|
||||
words.append(r["ipprotocol"])
|
||||
words.append(f'all label "{r["uuid"]}"')
|
||||
lines.append((number, " ".join(w for w in words if w)))
|
||||
number += 1
|
||||
stats = {"rules": {"filter rules": {f"@{n} {t}": {} for n, t in lines}}}
|
||||
for r in rows:
|
||||
r.pop("_loaded", None)
|
||||
alias_rows = {a["name"]: a for a in data["aliases"]["rows"]}
|
||||
alias_rows.update({name: {"name": name, **a} for name, a in (aliases or {}).items()})
|
||||
ctx = ctx_mod.Context(rows=rows, pf=pf_ruleset.parse(stats) if pf else None, interfaces=interfaces,
|
||||
groups=ctx_mod._groups({"rows": group_rows}, interfaces), aliases=alias_rows,
|
||||
port_forwards=list(port_forwards), notes=[] if pf else ["PF_RULESET_UNAVAILABLE"])
|
||||
default_refs = {LAN_CLIENT: ["__lan_network"], INTERNET: [], WAN_IP: ["__wan_network"]}
|
||||
ctx.refs = {**default_refs, **(refs or {})}
|
||||
return ctx
|
||||
|
||||
|
||||
def run(ctx, src=LAN_CLIENT, dst=INTERNET, proto="tcp", dport="443", iface="lan", **kw):
|
||||
q = build_query(src=src, dst=dst, proto=proto, dport=dport, iface=iface, **kw)
|
||||
for ip in (str(q.src), str(q.dst)):
|
||||
ctx.refs.setdefault(ip, [])
|
||||
return evaluate(ctx, q)
|
||||
|
||||
|
||||
def decided_by(result):
|
||||
return result["decisive_rule"]["description"] if result["decisive_rule"] else None
|
||||
|
||||
|
||||
# ── Normalization ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_normalize_live_fixture_uses_pf_for_automatic_rules():
|
||||
rules, notes = normalize(build())
|
||||
by_desc = {}
|
||||
for r in rules:
|
||||
by_desc.setdefault(r.description, r)
|
||||
assert notes == []
|
||||
deny = by_desc["Default deny / state violation rule"]
|
||||
assert deny.quick is False and deny.action == "block" and deny.loaded and deny.pf_numbers
|
||||
dhcp = by_desc["allow access to DHCP server"]
|
||||
assert dhcp.action == "pass" and dhcp.quick is True and dhcp.interfaces == {"lan"} # "LAN" + empty action
|
||||
lockout = by_desc["anti-lockout rule"]
|
||||
assert lockout.interfaces == {"lan"} and lockout.automatic and lockout.legacy
|
||||
allow = by_desc["Default allow LAN to any rule"]
|
||||
assert allow.section == "interface" and allow.quick and allow.af == {4} and not allow.automatic
|
||||
spoof = [r for r in rules if r.section == "antispoof"]
|
||||
assert spoof and spoof[0].interfaces == {"lan"} and spoof[0].if_not and spoof[0].quick is False
|
||||
# Antispoof lines sit before the first automatic rule, like in pf.
|
||||
assert rules.index(spoof[0]) < rules.index(deny)
|
||||
|
||||
|
||||
def test_normalize_reports_order_mismatch_and_missing_sort_order():
|
||||
ctx = build([mvc("r-a", seq=1)])
|
||||
# Swap pf positions: the rule now appears in pf before the system rules.
|
||||
key = next(k for k in ctx.pf.by_label if k == "r-a")
|
||||
ctx.pf.by_label[key][0].number = -1
|
||||
assert "ORDER_MISMATCH" in normalize(ctx)[1]
|
||||
row = mvc("r-b")
|
||||
row.pop("sort_order")
|
||||
assert "ORDER_GUESSED" in normalize(build([row]))[1]
|
||||
|
||||
|
||||
def test_unmigrated_legacy_rule_with_interface_description():
|
||||
# Seen live: legacy user rules have a 32-hex label and "WAN" instead of "wan".
|
||||
legacy = mvc("0123456789abcdef0123456789abcdef", seq=1, action="block", interface="WAN", legacy=True)
|
||||
r = run(build([legacy], drop=("Allow ALL",)), src=INTERNET, dst=WAN_IP, iface="wan")
|
||||
assert decided_by(r) == legacy["uuid"] and r["confidence"] == "sure"
|
||||
assert r["decisive_rule"]["legacy"] and not r["decisive_rule"]["editable"]
|
||||
|
||||
|
||||
# ── Engine: matrix 1-8, 14-20 ─────────────────────────────────────────────
|
||||
|
||||
def test_01_lan_client_to_internet_is_default_allow_sure():
|
||||
r = run(build())
|
||||
assert r["action"] == "pass" and decided_by(r) == "Default allow LAN to any rule"
|
||||
assert r["confidence"] == "sure" and r["rule_confidence"] == "sure" and r["unknowns"] == []
|
||||
|
||||
|
||||
def test_02_wan_to_firewall_ssh_hits_default_deny():
|
||||
r = run(build(drop=("Allow ALL",)), src=INTERNET, dst=WAN_IP, dport="22", iface="wan")
|
||||
assert r["action"] == "block" and decided_by(r) == "Default deny / state violation rule"
|
||||
assert r["confidence"] == "sure" and r["decisive_rule"]["quick"] is False
|
||||
|
||||
|
||||
def test_03_floating_non_quick_block_loses_to_interface_quick_pass():
|
||||
ctx = build([mvc("float-block", prio=200000, action="block", quick="0", interface="")])
|
||||
r = run(ctx)
|
||||
assert r["action"] == "pass" and decided_by(r) == "Default allow LAN to any rule"
|
||||
effects = {s["rule"]["description"]: s["effect"] for s in r["trace"][0]["steps"]}
|
||||
assert effects["float-block"] == "overridden"
|
||||
|
||||
|
||||
def test_04_floating_quick_block_on_lan_and_wan_wins():
|
||||
r = run(build([mvc("float-quick", prio=200000, action="block", quick="1", interface="lan,wan")]))
|
||||
assert r["action"] == "block" and decided_by(r) == "float-quick" and r["confidence"] == "sure"
|
||||
assert r["decisive_rule"]["section"] == "floating"
|
||||
|
||||
|
||||
def test_05_floating_interfacenot_applies_to_the_other_interfaces():
|
||||
ctx = build([mvc("not-lan", prio=200000, action="block", interface="lan", interfacenot="1")])
|
||||
assert decided_by(run(ctx)) == "Default allow LAN to any rule"
|
||||
wan = run(ctx, src=INTERNET, dst=WAN_IP, dport="22", iface="wan")
|
||||
assert wan["action"] == "block" and decided_by(wan) == "not-lan"
|
||||
|
||||
|
||||
def test_06_group_rule_comes_before_interface_rules():
|
||||
ctx = build([mvc("grp-block", prio=300005, seq=900, action="block", interface="office")], groups={"office": ["lan"]})
|
||||
r = run(ctx)
|
||||
assert r["action"] == "block" and decided_by(r) == "grp-block" and r["decisive_rule"]["section"] == "group"
|
||||
|
||||
|
||||
def test_07_last_non_quick_match_wins():
|
||||
ctx = build([mvc("first-pass", seq=1, quick="0", interface="wan"), mvc("then-block", seq=2, action="block", quick="0", interface="wan")],
|
||||
drop=("Allow ALL",))
|
||||
r = run(ctx, src=INTERNET, dst=WAN_IP, iface="wan")
|
||||
assert r["action"] == "block" and decided_by(r) == "then-block" and r["confidence"] == "sure"
|
||||
|
||||
|
||||
def test_08_negated_source_alias():
|
||||
aliases = {"BADHOSTS": {"type": "host", "content": "10.20.30.66"}}
|
||||
rule = mvc("not-bad", seq=1, action="block", src="BADHOSTS", source_not="1")
|
||||
ctx = build([rule], aliases=aliases, refs={LAN_CLIENT: ["__lan_network"], "10.20.30.66": ["BADHOSTS", "__lan_network"]})
|
||||
assert run(ctx)["action"] == "block" # .50 is not in BADHOSTS -> !BADHOSTS matches
|
||||
assert run(ctx, src="10.20.30.66")["action"] == "pass" # in BADHOSTS -> negation does not match
|
||||
# Table membership unknown: the negation stays unknown, the verdict is not "sure".
|
||||
unknown = build([dict(rule, source_net="FEED")], aliases={"FEED": {"type": "urltable", "content": ""}},
|
||||
refs={LAN_CLIENT: None})
|
||||
r = run(unknown)
|
||||
assert r["action"] == "pass" and r["confidence"] == "unknown" and "ALIAS_TABLE_UNAVAILABLE" in r["unknowns"]
|
||||
|
||||
|
||||
def test_14_rule_not_loaded_is_skipped():
|
||||
ctx = build([mvc("scheduled", seq=1, action="block", sched="weekends", loaded=False)])
|
||||
r = run(ctx)
|
||||
assert decided_by(r) == "Default allow LAN to any rule"
|
||||
step = next(s for s in r["trace"][0]["steps"] if s["rule"]["description"] == "scheduled")
|
||||
assert step["effect"] == "not_loaded" and "RULES_NOT_LOADED" in r["notes"]
|
||||
|
||||
|
||||
def test_15_16_gateway_and_stateless_notes():
|
||||
r = run(build([mvc("via-gw", seq=1, gateway="WAN_GW", statetype="none")]))
|
||||
assert decided_by(r) == "via-gw" and {"POLICY_ROUTE", "STATELESS"} <= set(r["notes"])
|
||||
assert r["confidence"] == "sure"
|
||||
|
||||
|
||||
def test_17_tagged_rule_is_never_sure():
|
||||
r = run(build([mvc("tagged-block", seq=1, action="block", tagged="FROM_NAT")]))
|
||||
assert r["confidence"] == "unknown" and "TAGGED" in r["unknowns"]
|
||||
|
||||
|
||||
def test_18_unknown_rule_with_same_action_keeps_the_verdict_sure():
|
||||
ctx = build([mvc("maybe-pass", seq=1, src="GHOST")]) # alias unknown on the firewall
|
||||
r = run(ctx)
|
||||
assert r["action"] == "pass" and r["confidence"] == "sure" and r["rule_confidence"] == "probable"
|
||||
|
||||
|
||||
def test_19_unknown_rule_with_other_action_makes_the_verdict_unknown():
|
||||
ctx = build([mvc("maybe-block", seq=1, action="block", src="GHOST")])
|
||||
r = run(ctx)
|
||||
assert r["action"] == "pass" and r["confidence"] == "unknown" and "ALIAS_MISSING" in r["unknowns"]
|
||||
assert any(s["effect"] == "possible" for s in r["trace"][0]["steps"])
|
||||
|
||||
|
||||
def test_20_rule_with_missing_interface_never_matches():
|
||||
row = mvc("orphan", prio=600000, action="pass", interface="opt9", loaded=False)
|
||||
r = run(build([row], drop=("Allow ALL",)), src=INTERNET, dst=WAN_IP, iface="wan")
|
||||
assert decided_by(r) == "Default deny / state violation rule"
|
||||
assert next(s for s in r["trace"][0]["steps"] if s["rule"]["description"] == "orphan")["effect"] == "not_loaded"
|
||||
# Even without a pf ruleset a 600000 rule counts as not loaded; the verdict is no longer sure.
|
||||
no_pf = run(build([row], pf=False))
|
||||
assert decided_by(no_pf) == "Default allow LAN to any rule" and no_pf["confidence"] != "sure"
|
||||
|
||||
|
||||
def test_guessed_interface_and_nat_nearby_lower_confidence():
|
||||
r = run(build(), iface=None)
|
||||
assert r["legs"][0]["interface"] == "lan" and "IFACE_GUESSED" in r["notes"] and r["confidence"] == "probable"
|
||||
nat = run(build(port_forwards=[{"interface": "lan", "protocol": "tcp", "destination.port": "443"}]))
|
||||
assert "NAT_NEARBY" in nat["notes"] and nat["confidence"] == "probable"
|
||||
# "no rdr" exclusions and forwards for other ports do not count.
|
||||
other = run(build(port_forwards=[{"interface": "lan", "protocol": "tcp", "destination.port": "22", "nordr": "0"},
|
||||
{"interface": "lan", "protocol": "tcp", "destination.port": "443", "nordr": "1"}]))
|
||||
assert "NAT_NEARBY" not in other["notes"] and other["confidence"] == "sure"
|
||||
|
||||
|
||||
def test_egress_leg_blocks_when_out_rule_blocks():
|
||||
ctx = build([mvc("no-out", prio=200000, action="block", interface="wan", direction="out")])
|
||||
r = run(ctx, egress="wan")
|
||||
assert [leg["action"] for leg in r["legs"]] == ["pass", "block"]
|
||||
assert r["action"] == "block" and decided_by(r) == "no-out" and r["confidence"] == "sure"
|
||||
# Without the block the automatic "let out anything" rule passes the out leg.
|
||||
ok = run(build(), egress="wan")
|
||||
assert ok["action"] == "pass" and ok["legs"][1]["decisive_rule"]["description"] == "let out anything from firewall host itself"
|
||||
|
||||
|
||||
# ── Resolver: matrix 9-13 ─────────────────────────────────────────────────
|
||||
|
||||
def _ip(text):
|
||||
return ipaddress.ip_address(text)
|
||||
|
||||
|
||||
def test_09_nested_aliases_and_cycles():
|
||||
ctx = build(aliases={
|
||||
"OUTER": {"type": "host", "content": "MIDDLE\n192.0.2.9"},
|
||||
"MIDDLE": {"type": "network", "content": "10.20.30.0/25\nINNER"},
|
||||
"INNER": {"type": "host", "content": "10.20.30.200"},
|
||||
"LOOP_A": {"type": "host", "content": "LOOP_B"}, "LOOP_B": {"type": "host", "content": "LOOP_A"},
|
||||
"NAMED": {"type": "host", "content": "server.example.org"},
|
||||
})
|
||||
ctx.refs = {}
|
||||
assert resolve.alias_contains("OUTER", _ip("10.20.30.5"), ctx).value is True
|
||||
assert resolve.alias_contains("OUTER", _ip("10.20.30.200"), ctx).value is True
|
||||
assert resolve.alias_contains("OUTER", _ip("10.20.30.150"), ctx).value is False
|
||||
loop = resolve.alias_contains("LOOP_A", _ip("10.20.30.5"), ctx)
|
||||
assert loop.value is None and "ALIAS_CYCLE" in loop.reasons
|
||||
fqdn = resolve.alias_contains("NAMED", _ip("10.20.30.5"), ctx)
|
||||
assert fqdn.value is None and "FQDN_UNRESOLVED" in fqdn.reasons
|
||||
assert resolve.alias_contains("NOPE", _ip("10.20.30.5"), ctx).value is None
|
||||
|
||||
|
||||
def test_10_table_aliases_need_find_references():
|
||||
ctx = build(aliases={"FEED": {"type": "urltable", "content": "https://example.org/list"}})
|
||||
ctx.refs = {"192.0.2.7": ["FEED"], "192.0.2.8": []}
|
||||
assert resolve.alias_contains("FEED", _ip("192.0.2.7"), ctx).value is True
|
||||
assert resolve.alias_contains("FEED", _ip("192.0.2.8"), ctx).value is False
|
||||
ctx.refs = {"192.0.2.7": None}
|
||||
out = resolve.alias_contains("FEED", _ip("192.0.2.7"), ctx)
|
||||
assert out.value is None and out.reasons == ["ALIAS_TABLE_UNAVAILABLE"]
|
||||
|
||||
|
||||
def test_11_address_family():
|
||||
ctx = build([mvc("v4-only", seq=1, action="block", interface="wan", ipprotocol="inet")], drop=("Allow ALL",))
|
||||
v6 = run(ctx, src="2001:db8::5", dst="2001:db8:1::1", iface="wan")
|
||||
assert decided_by(v6) == "Default deny / state violation rule"
|
||||
both = build([mvc("v46", seq=1, action="reject", interface="wan", ipprotocol="inet46")], drop=("Allow ALL",))
|
||||
assert decided_by(run(both, src="2001:db8::5", dst="2001:db8:1::1", iface="wan")) == "v46"
|
||||
|
||||
|
||||
def test_12_port_ranges_protocol_pairs_and_nested_port_aliases():
|
||||
ctx = build(aliases={"WEB": {"type": "port", "content": "1000:2000\nMORE"}, "MORE": {"type": "port", "content": "https\n8000-8001"}})
|
||||
for port, expected in ((999, False), (1000, True), (2000, True), (2001, False), (443, True), (8001, True), (8002, False)):
|
||||
assert resolve.port_match("WEB", port, ctx).value is expected, port
|
||||
assert resolve.port_match("1000:2000", 1500, ctx).value is True
|
||||
rule = mvc("tcpudp", seq=1, action="block", proto="TCP/UDP", dport="1000-2000")
|
||||
assert decided_by(run(build([rule]), proto="udp", dport="1000")) == "tcpudp"
|
||||
assert decided_by(run(build([rule]), proto="tcp", dport="2001")) == "Default allow LAN to any rule"
|
||||
# No source port given: assumed ephemeral, so "port 0" rules do not match, a narrow one is unknown.
|
||||
assert resolve.port_match("0", None, ctx, ephemeral_default=True).value is False
|
||||
assert resolve.port_match("5000", None, ctx, ephemeral_default=True).value is None
|
||||
assert resolve.port_match("1024-65535", None, ctx, ephemeral_default=True).value is True
|
||||
|
||||
|
||||
def test_13_interface_networks_addresses_self_and_carp_vip():
|
||||
ctx = build()
|
||||
vip, primary, client = _ip("10.20.30.1"), _ip("10.20.30.2"), _ip("10.20.30.50")
|
||||
assert resolve.addr_item("lan", vip, ctx).value is True and resolve.addr_item("lan", client, ctx).value is True
|
||||
assert resolve.addr_item("lanip", primary, ctx).value is True
|
||||
assert resolve.addr_item("lanip", client, ctx).value is False
|
||||
assert resolve.addr_item("lanip", vip, ctx).value is None # VIP vs interface address: not provable
|
||||
assert resolve.addr_item("(self)", vip, ctx).value is True
|
||||
assert resolve.addr_item("(self)", _ip(WAN_IP), ctx).value is True
|
||||
assert resolve.addr_item("(self)", client, ctx).value is False
|
||||
assert resolve.addr_item("__wan_network", _ip("203.0.113.9"), ctx).value is True
|
||||
|
||||
|
||||
def test_query_validation_errors():
|
||||
from fastapi import HTTPException
|
||||
for kwargs, code in (({"src": "host.example", "dst": INTERNET}, "ANALYSE_ADRESSE_UNGUELTIG"),
|
||||
({"src": "2001:db8::1", "dst": INTERNET}, "ANALYSE_FAMILIEN_MISCHUNG"),
|
||||
({"src": LAN_CLIENT, "dst": INTERNET, "dport": "70000"}, "ANALYSE_PARAMETER_UNGUELTIG"),
|
||||
({"src": LAN_CLIENT, "dst": INTERNET, "proto": "icmp", "dport": "80"}, "ANALYSE_PARAMETER_UNGUELTIG")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
build_query(**kwargs)
|
||||
assert exc.value.detail["code"] == code
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user