Compare commits

..
12 Commits
Author SHA1 Message Date
Julian Boldt 79783d6d62 Release v1.17.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
unbeaufsichtigt aktualisieren. Auf einem Test-Cluster live geprüft (Neustart und ein echtes Update
mit Umschaltung), siehe Handbuch „Geplante Wartung“.

- **Rollenprüfung beim Einplanen:** Ein Host, der in einem Cluster gerade CARP-MASTER ist,
  lässt sich nicht mehr einplanen (Neustart/Update/Major-Upgrade). Bisher wurde der Plan
  angenommen und scheiterte erst zur Uhrzeit. Der Planungsdialog zeigt den Grund an und
  sperrt den Button. Ist die Rolle nicht feststellbar (Host gerade nicht erreichbar), wird
  weiter angenommen; die volle Prüfung läuft wie bisher zum Zündzeitpunkt.
- **Benachrichtigung „Geplante Wartung nicht ausgeführt“:** Eine fehlgeschlagene geplante
  Wartung meldet sich per Webhook/E-Mail (einzeln schaltbar in den Einstellungen, standardmäßig
  an). Offen für drei Tage nach dem geplanten Zeitpunkt, dann ohne „behoben“-Meldung geschlossen.
  Neue Spalte `notification_settings.scheduled_maintenance_failed_enabled` (Migration `0006`).
- **Automatische CARP-Umschaltung (Option pro Plan, standardmäßig aus):** Ein geplanter Neustart,
  Update oder Major-Upgrade auf dem MASTER schaltet vorher auf den Partner um, prüft die
  Übernahme, führt die Aktion aus, wartet auf die Rückkehr des Hosts und schaltet zurück. Fehler
  vor dem Start der Aktion stellen den Normalbetrieb wieder her; danach bleibt der Host bewusst im
  Wartungsmodus (Partner trägt den Verkehr) und es kommt eine Benachrichtigung. Der Ablauf steht in
  der Datenbank und überlebt einen Neustart des Backends. Neue Spalten an `scheduled_maintenance`
  (Migration `0007`).
- **Backup vor geplantem Update:** Ein geplantes Update oder Major-Upgrade zieht unmittelbar vor
  dem Start selbst ein Konfigurationsbackup (nur lesend auf der Firewall). Bisher scheiterte jeder
  Plan, der mehr als 24 Stunden in der Zukunft lag, an der Backup-Aktualität. Schlägt das Backup
  fehl, wird nicht aktualisiert.
2026-10-19 16:45:07 +02:00
Julian Boldt c15417869b Handbook rewritten as a user manual
docs/HANDBUCH.md is now organised for operators (install, TLS, configuration, users, firewalls, rules, network, VPN, certificates, HA, updates, monitoring, operation, security, troubleshooting, limits). The unused September screenshots are removed; README and configuration comments point at the new section names.
2026-10-10 14:36:58 +02:00
Julian Boldt cf326d2d17 Release v1.16.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:

- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
  Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
  verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
  Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
  Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
  überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
  oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
  (Migration `0005`).
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
  (`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
  Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
  vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
  `python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
2026-10-10 14:15:55 +02:00
Julian Boldt 8fccb1a547 Remove maintainer and website tooling from the public repository
Website build/deploy scripts, release/publishing tooling, developer verification scripts and internal planning notes are not part of the product for operators. The release process notes moved out of the manual.
2026-10-10 12:43:22 +02:00
Julian Boldt cf59bdb38e Remove website sources from the public repository
The site opnexus.dev is built and deployed from a separate repository. README images now live in docs/screenshots/.
2026-10-10 12:40:41 +02:00
Julian Boldt c662a58426 Release v1.15.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
„Update aus dem Webinterface (optional)“:

- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
  neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
  automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
  (mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
  **Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
  in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
  **aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
  hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
  ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
  „zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
  Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
  Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
  zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
2026-10-10 00:24:01 +02:00
Julian Boldt 3b76f9f5fa Release v1.14.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:

- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
  ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
  Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
  Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
  Engine wie die übrigen Geräte.
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
  falsch, weil `/hostsystem` mit `/hosts` beginnt.
2026-10-09 22:49:10 +02:00
Julian Boldt b341ccd49f Release v1.13.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Mehr Verwaltung pro Firewall und ein Abgleich der Cluster-Knoten. Alles über
dieselbe abgesicherte Engine wie in 1.12.0 und live auf drei Test-Firewalls
geprüft, siehe Handbuch „Erweiterung 1.13.0“:

- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Dazu Gateway-Gruppen,
  DNS-Weiterleitungen (Unbound) und DHCP-Bereiche (dnsmasq).
- **Neue Gruppe „Host-System“:** Tunables (immer mit Bestätigung, Vorgaben nur per
  „Überschreiben“), Cron, Syslog-Ziele und ZFS-Snapshots (anlegen und löschen; der
  aktive Snapshot ist geschützt, das Aktivieren bleibt OPNsense selbst überlassen).
- **Netzwerk › Abgleich:** zeigt Unterschiede in der Netzwerk-Konfiguration zwischen den
  Knoten eines Clusters. Neuer Alarm „Netzwerk-Abweichung im Cluster“ (Einstellungen ›
  Benachrichtigungen, Migration 0004).
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts lässt es sich mit
  einem Klick auch auf dem anderen Cluster-Knoten anlegen (eigene Vorschau, eigenes Backup).
- **Kopfzeile:** Mit acht Gruppen zeigt die Navigation die Namen ab 1150 px, darunter
  nur Icons, damit Suche und Abmelden in einer Zeile bleiben.
2026-10-09 22:08:39 +02:00
Julian Boldt 20fd575420 Release v1.12.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
OPNsense-Core-Quellen geprüft und live auf drei Test-Firewalls (26.7.1 und
26.7.6) getestet, siehe Handbuch „Netzwerk, DNS und DHCP“:

- **Neuer Bereich „Netzwerk“:** Inventar je Firewall (Schnittstellen mit
  Adressen und CARP-Zustand, VLANs, virtuelle IPs, Gateways, Routen, DNS-Einträge,
  DHCP) und Änderungen daran: anlegen, bearbeiten, löschen für VLANs, virtuelle
  IPs, Gateways, statische Routen, Schnittstellen-Zuweisung samt IP-Konfiguration,
  Unbound-DNS-Einträge, dnsmasq-Hosts sowie Kea-Subnetze und -Reservierungen.
- **Abgesichert:** Jede Änderung läuft über eine Vorschau mit Diff und einem Token,
  der den Firewall-Stand erfasst. Vor dem Schreiben wird ein Konfig-Backup
  gespeichert (ohne Backup keine Änderung), danach angewendet und zurückgelesen.
  OPNexus erkennt, über welche Schnittstelle es die Firewall erreicht; Änderungen,
  die diese Verbindung kappen können (Verwaltungs-Schnittstelle, Standard-Gateway,
  Standardroute, Route über die Firewall-Adresse), brauchen eine ausdrückliche
  Bestätigung mit Eingabe des Firewall-Namens. Es gibt bewusst **keinen**
  automatischen Rollback: OPNexus kann eine Änderung nicht zurücknehmen, wenn sie
  seine eigene Verbindung trennt. Das Backup vor der Änderung ist der Rückweg.
- **Schnittstellen-Löschen:** OPNsense entfernt dabei selbst alle Regeln, die die
  Schnittstelle nutzen. Die Vorschau listet sie, die Bestätigung ist Pflicht.
  Bereits auf der Firewall vorgemerkte Änderungen blockieren eine neue Änderung.
- **IP-Konfiguration per API gibt es erst ab OPNsense 26.7.6.** Auf 26.7.1 ist die
  Zuweisungsliste nur lesbar; OPNexus erkennt das an den Daten und sperrt die
  Änderung mit Begründung.
- **Von der Firewall übernehmen:** Regeln, die direkt in OPNsense angelegt wurden,
  lassen sich in OPNexus übernehmen, bearbeiten und ausrollen. Die Übernahme schreibt
  nichts auf die Firewall. Regeln, die das Modell verfälschen würde (invertierte
  Quelle/Ziel, mehrere Schnittstellen, keine Quick-Regel, …), werden mit Grund
  abgelehnt. Neu: Regeln haben ein Protokollieren-Flag (`log`, Migration 0003),
  damit es beim Bearbeiten nicht still verloren geht; nicht modellierte Felder
  (Gateway, Zustandsoptionen, …) bleiben auf der Firewall unverändert.
- **Geheimnisse:** Das CARP-Passwort (liefert OPNsense im Klartext) und der
  Kea-DDNS-Schlüssel werden nie zurückgegeben, gespeichert oder auditiert.
- **Host-Adresse änderbar:** Hosts › Bearbeiten hat jetzt das Feld „Adresse“. Nach
  einer Änderung der Verwaltungs-IP muss der Host nicht mehr gelöscht und neu
  angelegt werden; Verlauf und Zuordnungen bleiben erhalten. Meldet eine Änderung
  „angewendet, aber nicht prüfbar“, weist die Oberfläche darauf hin.
- **Fehler aus den Live-Tests, die Mocks nicht gezeigt haben:** Jede Änderung einer
  virtuellen IP muss die Adresse (`network`) wiederholen, sonst leert OPNsense sie
  bzw. lehnt ab; die Prüfung nach dem Anwenden vergleicht jetzt den vollständigen
  Soll-Zustand statt nur der gesendeten Felder. Dazu: Gateway-Priorität als Zahl,
  Routen mit `enabled` statt `disabled`, Beschreibungsmuster der Schnittstellen,
  gesperrte Schnittstellen, Pflicht-Backup vor jeder Änderung.
- **Hinweis zu den Versionen:** Die IP-Konfiguration von Schnittstellen per API gibt
  es erst ab OPNsense 26.7.6. Mit 26.7.5 (die Prod-Firewalls) sind VLANs, virtuelle
  IPs, Gateways, Routen, DNS und DHCP verwaltbar, Schnittstellen nur lesbar.
2026-10-09 21:23:04 +02:00
Julian Boldt 1fc2d2c87c Release v1.11.1
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:

- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
  endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
  `/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
  Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
  gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
  es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
  `cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
  Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
  `/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
  Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
  (VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
  kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
  er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
  unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
  `SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
  Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
  („instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
  Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
  Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
  („Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
  einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
  und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
  Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
  sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
  Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
  „Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
  zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
  noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
  der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
  abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
  Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
  ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
  die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
  Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
  auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
  Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
  Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
  Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
  WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
  allen Seiten, hell und dunkel, nichts mehr.

- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
  blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
  bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
  Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
  jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
  (Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
  sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
  Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
  das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
2026-10-09 18:35:53 +02:00
Julian Boldt 9889be64ef Release v1.11.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Sicherheitsüberprüfung vom 2026-10-07, umgesetzte Korrekturen.
**Verhaltensänderungen**, die beim Update auffallen können:

- **Backup-Rohdownload nur für Admins**. Nur-Lese-Konten sehen weiter die
  Liste der Snapshots, bekommen die `config.xml` (enthält Passwort-Hashes, VPN-Schlüssel)
  aber nicht mehr; jeder Versuch steht im Audit-Verlauf (neuer Status „abgelehnt“).
- **Passwortwechsel/-reset beendet alle Sitzungen** des Kontos; ebenso
  2FA-Reset durch einen Admin. 2FA aktivieren/deaktivieren beendet die *anderen* Sitzungen.
- **SMTP über SSL/STARTTLS prüft Zertifikat und Hostname**. Mailserver mit
  privater CA: `SMTP_CA_FILE` setzen. SMTP-Anmeldung ohne Verschlüsselung ist gesperrt
  (`ALLOW_INSECURE_SMTP_AUTH=true` hebt das bewusst auf).
- **Firewalls nur noch per `https://`** — beim Anlegen und bei jeder Anfrage;
  ein vorhandener `http://`-Host zeigt „offline“, statt Zugangsdaten im Klartext zu senden
  (`ALLOW_INSECURE_FIREWALL_URLS=true` nur für Labor-Setups).
- **VPN-Push (IPsec/OpenVPN/WireGuard) verlangt das Vorschau-Token** und das
  Token ändert sich, sobald nur das Geheimnis (PSK/Schlüssel) rotiert wurde. Die Vorschau
  zeigt „unverändert“ jetzt korrekt, statt jeden Tunnel dauerhaft als geändert.
- **Host löschen** ist auch bei VPN-, Zertifikats- und ACME-Objekten auf der Firewall
  gesperrt, bis diese entfernt sind; Schlüsseldateien nie bereitgestellter
  Entwürfe werden mit entfernt.
- Benachrichtigungs-Geheimnisse liegen jetzt im Volume `notification_secrets`
 . Ohne lesbares Signatur-/SMTP-Geheimnis wird **nichts** mehr gesendet
  (vorher: unsigniert/ohne Login). Wer Geheimnisse gesetzt hat, muss sie nach dem
  Update einmal neu eingeben, falls der Container zwischenzeitlich ersetzt wurde.

Weitere Korrekturen:

- Oberfläche: Der „Bestätigen“-Button steht jetzt am Ende der Deploy-Ergebnis-Zeile statt
  in der Aktionszeile (Regeln, Aliase, Port-Forwards, 1:1-NAT, Outbound-NAT,
  Hierarchie-Regeln); die Aktionszeile springt nicht mehr, wenn ein Deployment offen wird.

- **Deployments hinterlassen vor dem ersten Firewall-Aufruf eine dauerhafte Spur**
  (gemeinsamer Ablauf `deploy_flow.py` für Regeln, Aliase, Port-Forwards, 1:1-NAT,
  Outbound-NAT und Hierarchie-Regeln): Bei Absturz, verlorener Antwort oder Timeout bleibt
  die Rollback-Frist erhalten und der Sweeper macht die Änderung rückgängig. Wo die Firewall
  eine neue Regel möglicherweise angelegt hat, ohne dass eine UUID bekannt ist, steht der
  Eintrag auf „Rollback fehlgeschlagen“ mit dem Hinweis „Ausgang unklar“ (blockiert
  erneutes Deployen, löst die Benachrichtigung aus); „Rollback wiederholen“ entfernt ihn.
  Von der Firewall beantwortete Ablehnungen und Verbindungsfehler bleiben einfache Fehler.
- TOTP- und Recovery-Codes sind auch bei gleichzeitigen Anfragen genau einmal
  verwendbar (Zeilensperre).
- Login-Sperre: begrenzter Speicher, Argon2 läuft außerhalb des Event-Loops mit
  Parallelitätsgrenze, 2FA-Einrichtung/-Aktivierung/-Deaktivierung und Passwortwechsel
  haben ein Versuchslimit; offene 2FA-Einrichtung läuft nach 15 Minuten ab; Proxy
  begrenzt API-Anfragen auf 2 MB.
- Fehlermeldungen der Firewall werden vor Speicherung/Anzeige von Geheimnissen
  bereinigt und gekürzt; Validierungsfehler (422) geben Eingaben nicht mehr
  zurück; Webhook-URLs (oft mit Token) sieht ein Nur-Lese-Konto nur noch als
  Host.
- Audit: MFA/Passwort, VPN, Zertifikate/ACME, Benachrichtigungen und Backup-Exporte
  werden aufgezeichnet, ebenso abgelehnte Schreibversuche von Nur-Lese-Konten.
- Same-Origin-Prüfung berücksichtigt das Schema bei `COOKIE_SECURE=true`;
  Warnung beim Start, wenn `FRONTEND_ORIGIN` https ist, das Cookie aber nicht `Secure`
 . Geheimnisdateien werden atomar mit Modus 0600 angelegt.
- Frontend-Abhängigkeiten (`sharp`, `source-map-js`, `brace-expansion`) aktualisiert.
  Offen: `braces`/`postcss-selector-parser` nur in Build-Werkzeugen (Tailwind 3 /
  ESLint), nicht im Laufzeit-Image; für `braces` gibt es keine gepatchte Version.
2026-10-07 13:34:54 +02:00
Julian Boldt 5c092fabac Release v1.11.0-rc2
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Siehe CHANGELOG.md
2026-10-07 12:50:51 +02:00
162 changed files with 11765 additions and 3730 deletions

No files matched your search

+14 -6
View File
@@ -36,7 +36,7 @@ PROXY_BIND_ADDRESS=0.0.0.0
# Host interface the backend/frontend ports are published on. Defaults
# to 127.0.0.1 (this host only) if unset. Set to 0.0.0.0 for access from
# other machines without a reverse proxy (see README's "Produktivbetrieb"
# other machines without a reverse proxy (see the handbook, "Zugriff aus dem Netz und TLS"
# for the safer TLS-proxy alternative), or to one specific interface IP.
BIND_ADDRESS=
# Host-Ports von Backend und Frontend (nur nötig, wenn 8100/3100 schon belegt
@@ -44,15 +44,15 @@ BIND_ADDRESS=
# BACKEND_PORT=8100
# FRONTEND_PORT=3100
# Set to true once this is running behind TLS (see README's
# "Produktivbetrieb" section) — otherwise the session cookie is sent
# Set to true once this is running behind TLS (see the handbook,
# "Zugriff aus dem Netz und TLS") — otherwise the session cookie is sent
# unencrypted and browsers won't even attach a Secure cookie without
# HTTPS in the first place.
COOKIE_SECURE=false
# Set to true ONLY when running behind a reverse proxy you trust to
# always set/overwrite X-Forwarded-For itself (every example in README's
# "Produktivbetrieb" does). Used to attribute failed logins to the real
# always set/overwrite X-Forwarded-For itself (every example in the handbook's
# "Zugriff aus dem Netz und TLS" does). Used to attribute failed logins to the real
# client IP for the login lockout instead of the proxy's own IP. Leave
# false (default) if the app is reachable directly with no proxy in
# front — otherwise any client could spoof this header to dodge the
@@ -71,10 +71,18 @@ TRUST_PROXY_HEADERS=false
# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht
# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update`.
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
# (optional, siehe unten) per Klick im Webinterface.
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
# UPDATE_CHECK_INTERVAL_HOURS=12
# ── Updates aus dem Webinterface (optional, braucht Release-Images) ───
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch, "Updates per Klick".
# COMPOSE_PROFILES=updater
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
# Firewall-API-Zugangsdaten gehen per HTTP-Basic-Auth raus, deshalb sind
# http://-Adressen für Firewalls abgelehnt (beim Anlegen UND bei jeder
+2
View File
@@ -59,3 +59,5 @@ jobs:
- uses: actions/checkout@v4
- run: python3 scripts/check_i18n.py
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
# Updater-Container (Zustandsübergänge, Ablehnungen, Auth) ohne Docker-Daemon.
- run: python3 -m unittest discover -s updater -p 'test_*.py'
+2 -2
View File
@@ -12,7 +12,7 @@
# Berechtigung "write:package" eintragen)
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration.
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy}:<version>
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy,updater}:<version>
# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen).
#
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
@@ -42,7 +42,7 @@ jobs:
strategy:
fail-fast: true
matrix:
service: [backend, frontend, proxy]
service: [backend, frontend, proxy, updater]
steps:
- uses: actions/checkout@v4
+226
View File
@@ -31,6 +31,228 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.17.0] "Imperial" — 2026-10-19
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
unbeaufsichtigt aktualisieren. Auf einem Test-Cluster live geprüft (Neustart und ein echtes Update
mit Umschaltung), siehe Handbuch „Geplante Wartung“.
- **Rollenprüfung beim Einplanen:** Ein Host, der in einem Cluster gerade CARP-MASTER ist,
lässt sich nicht mehr einplanen (Neustart/Update/Major-Upgrade). Bisher wurde der Plan
angenommen und scheiterte erst zur Uhrzeit. Der Planungsdialog zeigt den Grund an und
sperrt den Button. Ist die Rolle nicht feststellbar (Host gerade nicht erreichbar), wird
weiter angenommen; die volle Prüfung läuft wie bisher zum Zündzeitpunkt.
- **Benachrichtigung „Geplante Wartung nicht ausgeführt“:** Eine fehlgeschlagene geplante
Wartung meldet sich per Webhook/E-Mail (einzeln schaltbar in den Einstellungen, standardmäßig
an). Offen für drei Tage nach dem geplanten Zeitpunkt, dann ohne „behoben“-Meldung geschlossen.
Neue Spalte `notification_settings.scheduled_maintenance_failed_enabled` (Migration `0006`).
- **Automatische CARP-Umschaltung (Option pro Plan, standardmäßig aus):** Ein geplanter Neustart,
Update oder Major-Upgrade auf dem MASTER schaltet vorher auf den Partner um, prüft die
Übernahme, führt die Aktion aus, wartet auf die Rückkehr des Hosts und schaltet zurück. Fehler
vor dem Start der Aktion stellen den Normalbetrieb wieder her; danach bleibt der Host bewusst im
Wartungsmodus (Partner trägt den Verkehr) und es kommt eine Benachrichtigung. Der Ablauf steht in
der Datenbank und überlebt einen Neustart des Backends. Neue Spalten an `scheduled_maintenance`
(Migration `0007`).
- **Backup vor geplantem Update:** Ein geplantes Update oder Major-Upgrade zieht unmittelbar vor
dem Start selbst ein Konfigurationsbackup (nur lesend auf der Firewall). Bisher scheiterte jeder
Plan, der mehr als 24 Stunden in der Zukunft lag, an der Backup-Aktualität. Schlägt das Backup
fehl, wird nicht aktualisiert.
## [1.16.0] "Hobby" — 2026-10-10
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:
- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
(Migration `0005`).
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
(`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
`python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
## [1.15.0] "Gyrfalcon" — 2026-10-10
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
„Update aus dem Webinterface (optional)“:
- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
(mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
**Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
**aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
„zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
## [1.14.0] "Eagle" — 2026-10-09
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
Engine wie die übrigen Geräte.
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
falsch, weil `/hostsystem` mit `/hosts` beginnt.
## [1.13.0] "Buzzard" — 2026-10-09
Mehr Verwaltung pro Firewall und ein Abgleich der Cluster-Knoten. Alles über
dieselbe abgesicherte Engine wie in 1.12.0 und live auf drei Test-Firewalls
geprüft, siehe Handbuch „Erweiterung 1.13.0“:
- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Dazu Gateway-Gruppen,
DNS-Weiterleitungen (Unbound) und DHCP-Bereiche (dnsmasq).
- **Neue Gruppe „Host-System“:** Tunables (immer mit Bestätigung, Vorgaben nur per
„Überschreiben“), Cron, Syslog-Ziele und ZFS-Snapshots (anlegen und löschen; der
aktive Snapshot ist geschützt, das Aktivieren bleibt OPNsense selbst überlassen).
- **Netzwerk › Abgleich:** zeigt Unterschiede in der Netzwerk-Konfiguration zwischen den
Knoten eines Clusters. Neuer Alarm „Netzwerk-Abweichung im Cluster“ (Einstellungen ›
Benachrichtigungen, Migration 0004).
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts lässt es sich mit
einem Klick auch auf dem anderen Cluster-Knoten anlegen (eigene Vorschau, eigenes Backup).
- **Kopfzeile:** Mit acht Gruppen zeigt die Navigation die Namen ab 1150 px, darunter
nur Icons, damit Suche und Abmelden in einer Zeile bleiben.
## [1.12.0] "Condor" — 2026-10-09
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
OPNsense-Core-Quellen geprüft und live auf drei Test-Firewalls (26.7.1 und
26.7.6) getestet, siehe Handbuch „Netzwerk, DNS und DHCP“:
- **Neuer Bereich „Netzwerk“:** Inventar je Firewall (Schnittstellen mit
Adressen und CARP-Zustand, VLANs, virtuelle IPs, Gateways, Routen, DNS-Einträge,
DHCP) und Änderungen daran: anlegen, bearbeiten, löschen für VLANs, virtuelle
IPs, Gateways, statische Routen, Schnittstellen-Zuweisung samt IP-Konfiguration,
Unbound-DNS-Einträge, dnsmasq-Hosts sowie Kea-Subnetze und -Reservierungen.
- **Abgesichert:** Jede Änderung läuft über eine Vorschau mit Diff und einem Token,
der den Firewall-Stand erfasst. Vor dem Schreiben wird ein Konfig-Backup
gespeichert (ohne Backup keine Änderung), danach angewendet und zurückgelesen.
OPNexus erkennt, über welche Schnittstelle es die Firewall erreicht; Änderungen,
die diese Verbindung kappen können (Verwaltungs-Schnittstelle, Standard-Gateway,
Standardroute, Route über die Firewall-Adresse), brauchen eine ausdrückliche
Bestätigung mit Eingabe des Firewall-Namens. Es gibt bewusst **keinen**
automatischen Rollback: OPNexus kann eine Änderung nicht zurücknehmen, wenn sie
seine eigene Verbindung trennt. Das Backup vor der Änderung ist der Rückweg.
- **Schnittstellen-Löschen:** OPNsense entfernt dabei selbst alle Regeln, die die
Schnittstelle nutzen. Die Vorschau listet sie, die Bestätigung ist Pflicht.
Bereits auf der Firewall vorgemerkte Änderungen blockieren eine neue Änderung.
- **IP-Konfiguration per API gibt es erst ab OPNsense 26.7.6.** Auf 26.7.1 ist die
Zuweisungsliste nur lesbar; OPNexus erkennt das an den Daten und sperrt die
Änderung mit Begründung.
- **Von der Firewall übernehmen:** Regeln, die direkt in OPNsense angelegt wurden,
lassen sich in OPNexus übernehmen, bearbeiten und ausrollen. Die Übernahme schreibt
nichts auf die Firewall. Regeln, die das Modell verfälschen würde (invertierte
Quelle/Ziel, mehrere Schnittstellen, keine Quick-Regel, …), werden mit Grund
abgelehnt. Neu: Regeln haben ein Protokollieren-Flag (`log`, Migration 0003),
damit es beim Bearbeiten nicht still verloren geht; nicht modellierte Felder
(Gateway, Zustandsoptionen, …) bleiben auf der Firewall unverändert.
- **Geheimnisse:** Das CARP-Passwort (liefert OPNsense im Klartext) und der
Kea-DDNS-Schlüssel werden nie zurückgegeben, gespeichert oder auditiert.
- **Host-Adresse änderbar:** Hosts › Bearbeiten hat jetzt das Feld „Adresse“. Nach
einer Änderung der Verwaltungs-IP muss der Host nicht mehr gelöscht und neu
angelegt werden; Verlauf und Zuordnungen bleiben erhalten. Meldet eine Änderung
„angewendet, aber nicht prüfbar“, weist die Oberfläche darauf hin.
- **Fehler aus den Live-Tests, die Mocks nicht gezeigt haben:** Jede Änderung einer
virtuellen IP muss die Adresse (`network`) wiederholen, sonst leert OPNsense sie
bzw. lehnt ab; die Prüfung nach dem Anwenden vergleicht jetzt den vollständigen
Soll-Zustand statt nur der gesendeten Felder. Dazu: Gateway-Priorität als Zahl,
Routen mit `enabled` statt `disabled`, Beschreibungsmuster der Schnittstellen,
gesperrte Schnittstellen, Pflicht-Backup vor jeder Änderung.
- **Hinweis zu den Versionen:** Die IP-Konfiguration von Schnittstellen per API gibt
es erst ab OPNsense 26.7.6. Mit 26.7.5 (die Prod-Firewalls) sind VLANs, virtuelle
IPs, Gateways, Routen, DNS und DHCP verwaltbar, Schnittstellen nur lesbar.
## [1.11.1] "Merlin" — 2026-10-09
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:
- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
`/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
`cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
`/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
(VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
`SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
(„instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
(„Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
„Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
allen Seiten, hell und dunkel, nichts mehr.
- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
(Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
## [1.11.0] "Merlin" — 2026-10-07
Sicherheitsüberprüfung vom 2026-10-07, umgesetzte Korrekturen.
@@ -60,6 +282,10 @@ Sicherheitsüberprüfung vom 2026-10-07, umgesetzte Korrekturen.
Weitere Korrekturen:
- Oberfläche: Der „Bestätigen“-Button steht jetzt am Ende der Deploy-Ergebnis-Zeile statt
in der Aktionszeile (Regeln, Aliase, Port-Forwards, 1:1-NAT, Outbound-NAT,
Hierarchie-Regeln); die Aktionszeile springt nicht mehr, wenn ein Deployment offen wird.
- **Deployments hinterlassen vor dem ersten Firewall-Aufruf eine dauerhafte Spur**
(gemeinsamer Ablauf `deploy_flow.py` für Regeln, Aliase, Port-Forwards, 1:1-NAT,
Outbound-NAT und Hierarchie-Regeln): Bei Absturz, verlorener Antwort oder Timeout bleibt
+18 -6
View File
@@ -5,9 +5,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp)
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.11.0-rc1` ist der aktuelle öffentliche Release-Candidate (er löst die `1.10.x`-Kandidaten ab). Entwickelt wurde gegen echte OPNsense-Instanzen, eine frische Installation aus den veröffentlichten Images ist aber noch nicht im Feld erprobt — bitte Ecken und Kanten melden.
> **Stand:** `1.17.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -16,13 +16,23 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Sichere Änderungen** — jede Änderung läuft *Prüfen → Ausrollen → Bestätigen*; bleibt die Bestätigung im Zeitfenster aus, rollt OPNexus zurück.
- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume.
- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt.
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog, Snapshots und Diagnose (Ping, Traceroute, Port-Prüfung, DNS), ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln lassen sich übernehmen.
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
Alle Screenshots zeigen fiktive Beispieldaten.
## Ratgeber
- [OPNsense-Firewalls zentral verwalten](https://opnexus.dev/opnsense-zentral-verwalten/) – wann sich zentrale Verwaltung lohnt und wie sie funktioniert
- [OPNsense-HA-Cluster mit CARP verwalten](https://opnexus.dev/opnsense-ha-cluster-carp/) – Rollen, Alarme, geordnete Updates, Abgleich beider Knoten
- [OPNsense-Regeln sicher ausrollen](https://opnexus.dev/opnsense-regeln-sicher-ausrollen/) – Vorschau, Bestätigungsfenster und automatischer Rollback
- [Das Kommandozeilen-Werkzeug `opnexus`](https://opnexus.dev/docs/cli/) – Installation aktualisieren, zurückrollen und sichern
## Schnellstart
Voraussetzung: Docker mit Compose-Plugin.
@@ -34,19 +44,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
docker compose up -d --build
```
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben:
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben. Verloren oder nie gesehen? Dieses Kommando setzt ein neues Zufallspasswort und zeigt es an (im Repo-Checkout `scripts/opnexus`, im Release-Bundle `./opnexus`):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls).
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls).
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.11.0-rc1
OPNEXUS_VERSION=1.17.0
```
```bash
+21 -7
View File
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp)
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.11.0-rc1` is the current public release candidate (it replaces the `1.10.x` candidates). It has been developed against real OPNsense instances, but a fresh install from the published images has not been field-tested yet — please expect rough edges and report them.
> **Status:** `1.17.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -16,15 +16,25 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Safe changes** — every change goes *preview → deploy → confirm*; without your confirmation inside the time window OPNexus rolls it back.
- **Hierarchical policies** — pre/post rulebases with inheritance across group trees.
- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out.
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog, snapshots and diagnostics (ping, traceroute, port check, DNS), a comparison of cluster nodes, with a diff preview, a backup before every change and a guard against cutting your own connection; rules created by hand on a firewall can be adopted.
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
![Cluster view](website/assets/screenshots/en/cluster-light-desktop.webp)
![Cluster view](docs/screenshots/readme-cluster-en.webp)
All screenshots show fictitious sample data.
## Guides
- [Manage OPNsense firewalls centrally](https://opnexus.dev/en/manage-opnsense-centrally/) – when central management pays off and how it works
- [Manage OPNsense HA clusters with CARP](https://opnexus.dev/en/opnsense-ha-cluster-carp/) – roles, alerts, ordered updates, comparing both nodes
- [Roll out OPNsense rules safely](https://opnexus.dev/en/roll-out-opnsense-rules-safely/) – preview, confirmation window and automatic rollback
- [The `opnexus` command-line tool](https://opnexus.dev/en/docs/cli/) – update, roll back and back up an installation
## Quick start
Requires Docker with the Compose plugin.
@@ -36,19 +46,21 @@ cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD)
docker compose up -d --build
```
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log:
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log. Lost it, or never saw it? This sets a new random one and prints it (use `scripts/opnexus` in a repo checkout, `./opnexus` in the release bundle):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German).
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls) (German).
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.11.0-rc1
OPNEXUS_VERSION=1.17.0
```
```bash
@@ -57,6 +69,8 @@ docker compose pull && docker compose up -d --no-build
`scripts/opnexus update` / `rollback` then update the installation (pull first, database backup before migrations, automatic rollback if the services do not become healthy).
**Updating from the web interface** is available as an opt-in: set `COMPOSE_PROFILES=updater` in `.env` and an administrator can update (and roll back) under *Settings › Update OPNexus*. It runs as a separate small container that is the only one with Docker access — see the [handbook](docs/HANDBUCH.md#update-aus-dem-webinterface-optional) (German) for the security model.
## Privacy
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
+1 -1
View File
@@ -92,7 +92,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
user = await auth_service.resolve_session(db, token)
if user is not None:
parts = request.url.path.strip("/").split("/")
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user"}
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system"}
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}}
+8
View File
@@ -28,6 +28,14 @@ class Settings(BaseSettings):
update_check_url: str = ""
update_check_interval_hours: int = 12
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
# nie Zugriff auf den Docker-Socket; es reicht nur "aktualisieren" /
# "zurückrollen" an diesen Dienst weiter, authentifiziert mit dem Token,
# den der Updater in sein Zustands-Volume schreibt.
updater_url: str = "http://updater:8080"
updater_token_file: str = "/updater/token"
# Cookies now carry the session, so this can no longer be a bare "*"
# (the Fetch spec forbids credentialed requests against a wildcard
# response) — see main.py's allow_origin_regex. Default only matches
+19
View File
@@ -6,6 +6,12 @@ from fastapi import HTTPException
class ErrorCode(str, Enum):
UPDATER_UNAVAILABLE = "UPDATER_UNAVAILABLE"
UPDATER_BUSY = "UPDATER_BUSY"
UPDATER_REJECTED = "UPDATER_REJECTED"
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
ADMIN_REQUIRED = "ADMIN_REQUIRED"
BACKUP_EMPTY = "BACKUP_EMPTY"
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
@@ -43,6 +49,8 @@ class ErrorCode(str, Enum):
FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998"
FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR = "FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR"
GEPLANTE_WARTUNG_NICHT_GEFUNDEN = "GEPLANTE_WARTUNG_NICHT_GEFUNDEN"
GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH = "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER = "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
GROUP_HAS_ASSIGNED_ITEMS = "GROUP_HAS_ASSIGNED_ITEMS"
GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN = "GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN"
GRUPPENNAME_BEREITS_VERGEBEN = "GRUPPENNAME_BEREITS_VERGEBEN"
@@ -99,6 +107,13 @@ class ErrorCode(str, Enum):
REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203 = "REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203"
REGEL_HAT_KEINE_ZIEL_HOSTS = "REGEL_HAT_KEINE_ZIEL_HOSTS"
REGEL_NICHT_GEFUNDEN = "REGEL_NICHT_GEFUNDEN"
NETWORK_ACK_REQUIRED = "NETWORK_ACK_REQUIRED"
NETWORK_APPLY_UNCERTAIN = "NETWORK_APPLY_UNCERTAIN"
NETWORK_BACKUP_FAILED = "NETWORK_BACKUP_FAILED"
NETWORK_CHANGE_BLOCKED = "NETWORK_CHANGE_BLOCKED"
NETWORK_FIELD_INVALID = "NETWORK_FIELD_INVALID"
NETWORK_ITEM_NOT_FOUND = "NETWORK_ITEM_NOT_FOUND"
RULE_NOT_ADOPTABLE = "RULE_NOT_ADOPTABLE"
REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0"
RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN"
ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL"
@@ -134,6 +149,10 @@ class ErrorCode(str, Enum):
OPEN_DEPLOYMENT_DRAFT_UNCHANGED = "OPEN_DEPLOYMENT_DRAFT_UNCHANGED"
OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY = "OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY"
OPEN_RESTORE_BLOCKS_DEPLOY = "OPEN_RESTORE_BLOCKS_DEPLOY"
UPSTREAM_REJECTED = "UPSTREAM_REJECTED"
INVALID_CHARACTERS = "INVALID_CHARACTERS"
SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
SERVICE_LOCKED = "SERVICE_LOCKED"
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
+30 -1
View File
@@ -7,6 +7,9 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import DBAPIError
from app.errors import ErrorCode
from app import models # noqa: F401 - registers models with Base.metadata
from app.auth_middleware import AuthMiddleware
@@ -33,6 +36,10 @@ from app.routers import ipsec as ipsec_router
from app.routers import openvpn as openvpn_router
from app.routers import wireguard as wireguard_router
from app.routers import certificates as certificates_router
from app.routers import network as network_router
from app.routers import snapshots as snapshots_router
from app.routers import network_drift as network_drift_router
from app.routers import diagnostics as diagnostics_router
from app.routers import restore as restore_router
from app.version import APP_VERSION
from app.routers import dashboard as dashboard_router
@@ -222,6 +229,7 @@ async def _resource_poll_loop():
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
_RUNNING_MAINTENANCE_POLL_SECONDS = 15
async def _scheduled_maintenance_poll_loop():
@@ -237,9 +245,13 @@ async def _scheduled_maintenance_poll_loop():
"Geplante Wartung: %d fällig, %d ausgeführt, %d blockiert/fehlgeschlagen",
result["due"], result["fired"], result["blocked_or_failed"],
)
in_progress = await scheduled_maintenance_service.advance_running(db)
except Exception:
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
await asyncio.sleep(_SCHEDULED_MAINTENANCE_POLL_SECONDS)
in_progress = 0
# A handover run is stepped quickly (the partner should take over within a minute or two);
# otherwise the relaxed cadence is plenty.
await asyncio.sleep(_RUNNING_MAINTENANCE_POLL_SECONDS if in_progress else _SCHEDULED_MAINTENANCE_POLL_SECONDS)
@asynccontextmanager
@@ -289,6 +301,19 @@ async def _safe_validation_error(request: Request, exc: RequestValidationError):
)
@app.exception_handler(DBAPIError)
async def _database_text_errors(request: Request, exc: DBAPIError):
"""A NUL byte in any text field cannot be stored by Postgres and used to
surface as a 500 plus a dropped connection (found by fuzzing). That is bad
input, not a server fault. Anything else stays a normal 500."""
if type(exc.orig).__name__.endswith("CharacterNotInRepertoireError") or "0x00" in str(exc.orig):
return JSONResponse(
status_code=422,
content={"detail": {"code": ErrorCode.INVALID_CHARACTERS.value, "params": {}}},
)
raise exc
app.add_middleware(AuthMiddleware)
# Inside CORS so preflights are answered first; outside Auth so a forged
# cross-site write is rejected before any session lookup happens.
@@ -374,5 +399,9 @@ app.include_router(restore_router.router, prefix="/api")
app.include_router(dashboard_router.router, prefix="/api")
app.include_router(cluster_router.router, prefix="/api")
app.include_router(services_router.router, prefix="/api")
app.include_router(network_router.router, prefix="/api")
app.include_router(snapshots_router.router, prefix="/api")
app.include_router(network_drift_router.router, prefix="/api")
app.include_router(diagnostics_router.router, prefix="/api")
app.include_router(resource_samples_router.router, prefix="/api")
app.include_router(scheduled_maintenance_router.router, prefix="/api")
+3 -1
View File
@@ -48,7 +48,9 @@ class NotificationSettings(Base):
maintenance_unknown_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
carp_health_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
nat_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@@ -70,7 +72,7 @@ class NotificationProblem(Base):
__table_args__ = (UniqueConstraint("problem_type", "dedup_key", name="uq_notification_problem_key"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown | ... (see ProblemType)
problem_type: Mapped[str] = mapped_column(String(32))
dedup_key: Mapped[str] = mapped_column(String(300))
status: Mapped[str] = mapped_column(String(16), default="open") # open | resolved
+4 -1
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -9,3 +9,6 @@ class UserPreferences(Base):
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
theme: Mapped[str] = mapped_column(String(10), default='system')
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
# Set once the user finished or dismissed the first-run setup assistant
# (frontend /setup); it is then no longer offered automatically.
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy import Boolean, DateTime, String, false
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -29,6 +29,7 @@ class FirewallRule(Base):
destination_net: Mapped[str] = mapped_column(String(128), default="any")
destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
log: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+8 -1
View File
@@ -13,7 +13,7 @@ table only tracks the *schedule* itself, not the resulting operation.
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -40,3 +40,10 @@ class ScheduledMaintenance(Base):
# Set to the resulting HostOperation's id once fired, so the UI can
# link from the schedule to the operation it produced.
host_operation_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
# Opt-in: hand the traffic over to the CARP partner first, run the action, then switch back.
carp_handover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Only while status == "running" with a handover: handover_wait | job_wait | leave | leave_wait.
# State lives here, not in memory, so a backend restart in the middle of a run resumes cleanly.
phase: Mapped[str | None] = mapped_column(String(16), nullable=True)
phase_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
progress: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+66
View File
@@ -0,0 +1,66 @@
"""Recovery: give an account a new random password and print it once.
For a fresh install whose generated first-start password scrolled out of the
log, and for an admin who forgot theirs. Needs shell access to the server,
which is the trust boundary for this tool anyway (same as app.reset_2fa).
All sessions of the account are ended. Normally called through the host
CLI:
./opnexus password [username]
or directly:
docker compose exec backend python -m app.reset_password [username]
Without a username the configured ADMIN_USERNAME is used; if that account does
not exist and there is exactly one admin, that one.
"""
import asyncio
import secrets
import sys
from sqlalchemy import select
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser
from app.services import auth_service
async def main(username: str | None) -> int:
async with SessionLocal() as db:
if username is None:
username = settings.admin_username
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
admins = (await db.execute(select(AdminUser).where(AdminUser.role == "admin"))).scalars().all()
if len(admins) != 1:
names = ", ".join(sorted(a.username for a in admins)) or "-"
print(f"No account named '{username}'. Pass the username (admins: {names}).", file=sys.stderr)
return 1
user = admins[0]
else:
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
print(f"No such user: {username}", file=sys.stderr)
return 1
password = secrets.token_urlsafe(18)
user.password_hash = await auth_service.hash_password_async(password)
await auth_service.revoke_sessions(db, user.id, commit=False)
await db.commit()
name = user.username
has_totp = user.totp_enabled
print(f"Username: {name}")
print(f"Password: {password}")
if has_totp:
print("Two-factor sign-in is still on (lost the code? python -m app.reset_2fa).", file=sys.stderr)
return 0
if __name__ == "__main__":
if len(sys.argv) > 2:
print("usage: python -m app.reset_password [username]", file=sys.stderr)
sys.exit(2)
sys.exit(asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else None)))
+1 -1
View File
@@ -146,7 +146,7 @@ async def delete_alias(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_alias(db, alias)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(alias, attribute_names=["deployments"])
await db.delete(alias)
await db.commit()
+3 -3
View File
@@ -112,7 +112,7 @@ async def delete_managed_certificate(cert_id: uuid.UUID, db: AsyncSession = Depe
try:
await managed_certificate_service.delete_certificate(db, cert, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
# ── ACME accounts ──────────────────────────────────────────────────────
@@ -197,7 +197,7 @@ async def delete_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(
try:
await acme_service.delete_account(db, account, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
# ── ACME certificate requests ─────────────────────────────────────────
@@ -298,4 +298,4 @@ async def delete_acme_certificate_request(request_id: uuid.UUID, db: AsyncSessio
try:
await acme_service.delete_certificate_request(db, req, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+68
View File
@@ -0,0 +1,68 @@
import uuid
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import diagnostics_service
router = APIRouter(prefix="/hosts/{host_id}/diagnostics", tags=["diagnostics"])
IpProto = Literal["inet", "inet6"]
class PingRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
count: int = Field(default=4, ge=1, le=10)
packetsize: int | None = Field(default=None, ge=1, le=9000)
source: str | None = Field(default=None, max_length=64)
class TracerouteRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
protocol: Literal["udp", "icmp"] = "icmp"
source: str | None = Field(default=None, max_length=64)
class PortprobeRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
port: int = Field(ge=1, le=65535)
ipproto: IpProto = "inet"
source: str | None = Field(default=None, max_length=64)
class DnsRequest(BaseModel):
name: str = Field(min_length=1, max_length=253)
server: str | None = Field(default=None, max_length=64)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.post("/ping")
async def ping(host_id: uuid.UUID, p: PingRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.ping(await _host(db, host_id), p.target, p.ipproto, p.count, p.packetsize, p.source)
@router.post("/traceroute")
async def traceroute(host_id: uuid.UUID, p: TracerouteRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.traceroute(await _host(db, host_id), p.target, p.ipproto, p.protocol, p.source)
@router.post("/portprobe")
async def portprobe(host_id: uuid.UUID, p: PortprobeRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.portprobe(await _host(db, host_id), p.target, p.port, p.ipproto, p.source)
@router.post("/dns")
async def dns(host_id: uuid.UUID, p: DnsRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.dns_lookup(await _host(db, host_id), p.name, p.server)
+2
View File
@@ -155,6 +155,8 @@ async def update_host(host_id: uuid.UUID, payload: HostUpdate, db: AsyncSession
if existing:
raise api_error(409, ErrorCode.HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS)
host.name = payload.name
if payload.base_url is not None:
host.base_url = payload.base_url
if payload.verify_tls is not None:
host.verify_tls = payload.verify_tls
if payload.clear_tls_pin:
+1 -1
View File
@@ -174,7 +174,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
try:
await undeploy_tunnel(db, tunnel)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(tunnel, attribute_names=["deployments"])
ipsec_psk_store.delete_secret(tunnel.id)
await db.delete(tunnel)
+90
View File
@@ -0,0 +1,90 @@
import re
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.routers.hosts import _upstream_error
from app.schemas.network import (
HostNetwork, NetworkChangeExecute, NetworkChangePreview, NetworkChangeRequest, NetworkChangeResult,
)
from app.services import network_change_service, network_service
from app.services.policy_service import policy_lock
from app.services.opnsense_client import OpnsenseAPIError
router = APIRouter(prefix="/hosts/{host_id}/network", tags=["network"])
@router.get("", response_model=HostNetwork)
async def host_network(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await network_service.get_host_network(host)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
async def _host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.post("/changes/preview", response_model=NetworkChangePreview)
async def preview_change(host_id: uuid.UUID, payload: NetworkChangeRequest, db: AsyncSession = Depends(get_db)):
host = await _host_or_404(db, host_id)
try:
plan = await network_change_service.preview(db, host, payload.kind, payload.action, payload.uuid, payload.fields)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
return {k: v for k, v in plan.items() if not k.startswith("_")}
@router.post("/changes", response_model=NetworkChangeResult)
async def execute_change(host_id: uuid.UUID, payload: NetworkChangeExecute, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host_or_404(db, host_id)
return await network_change_service.execute(
db, host, payload.kind, payload.action, payload.uuid, payload.fields, payload.preview_token, payload.acknowledge)
@router.get("/template/{kind}")
async def change_template(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
"""Blank form for one kind, with the choices this firewall accepts for its select fields."""
if kind not in network_change_service.FIELDS:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.template(host, kind)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.get("/item/{kind}/{item_uuid}")
async def current_item(host_id: uuid.UUID, kind: str, item_uuid: str, db: AsyncSession = Depends(get_db)):
"""The object's current values on the firewall, for pre-filling an edit form."""
if kind not in network_change_service.FIELDS or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", item_uuid):
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.item(host, kind, item_uuid)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.get("/objects/{kind}")
async def list_objects(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
"""Rows of a generic object kind (tunables, cron jobs, LAGG, ...), reduced to their display columns."""
if kind not in network_change_service.OBJECT_COLUMNS:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.list_objects(host, kind)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
+29
View File
@@ -0,0 +1,29 @@
from collections import defaultdict
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.host import OpnsenseHost
from app.services import network_drift_service
router = APIRouter(prefix="/network", tags=["network"])
@router.get("/drift")
async def network_drift(db: AsyncSession = Depends(get_db)):
"""Per cluster: differences in the network configuration between its nodes (live read of every node)."""
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
clusters = []
for name, members in sorted(by_cluster.items()):
if len(members) < 2:
continue
differences, compared, unreachable = await network_drift_service.compute_cluster(members)
clusters.append({"name": name, "hosts": [h.name for h in members], "compared": compared,
"unreachable": unreachable, "differences": differences})
return {"clusters": clusters}
+2 -2
View File
@@ -168,7 +168,7 @@ async def delete_one_to_one_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(
try:
await undeploy_one_to_one(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -268,4 +268,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_one_to_one_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+1 -1
View File
@@ -176,7 +176,7 @@ async def delete_instance(instance_id: uuid.UUID, db: AsyncSession = Depends(get
try:
await undeploy_instance(db, instance)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(instance, attribute_names=["deployments"])
openvpn_key_store.delete_secret(instance.id)
await db.delete(instance)
+2 -2
View File
@@ -175,7 +175,7 @@ async def delete_outbound_nat_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
try:
await undeploy_outbound_nat(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -275,7 +275,7 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_outbound_nat_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
@router.get("/hosts/{host_id}/mode")
+1 -1
View File
@@ -294,7 +294,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_hierarchy_rule(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
+2 -2
View File
@@ -170,7 +170,7 @@ async def delete_port_forward_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
try:
await undeploy_port_forward(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -270,4 +270,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_port_forward_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+41 -1
View File
@@ -17,6 +17,8 @@ from app.services.alias_service import check_alias_consistency, describe_alias_c
from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment
from app.services import rule_adopt_service
from app.schemas.rule import AdoptRequest, UnmanagedRule
router = APIRouter(prefix="/rules", tags=["rules"])
@@ -59,6 +61,7 @@ def _to_read(
destination_net=rule.destination_net,
destination_port=rule.destination_port,
enabled=rule.enabled,
log=rule.log,
created_at=rule.created_at,
updated_at=rule.updated_at,
target_host_ids=[host.id for host in rule.targets],
@@ -84,6 +87,42 @@ async def list_rules(db: AsyncSession = Depends(get_db)):
return [_to_read(rule, rule.targets, rule.deployments) for rule in rules]
@router.get("/unmanaged", response_model=list[UnmanagedRule])
async def list_unmanaged_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Rules found on the host that OPNexus does not manage yet (live read)."""
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await rule_adopt_service.list_unmanaged(db, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
@router.post("/adopt", response_model=RuleRead, status_code=201)
async def adopt_rule(payload: AdoptRequest, db: AsyncSession = Depends(get_db)):
"""Takes over a hand-made rule. Writes nothing to the firewall."""
await policy_lock(db)
host = await db.get(OpnsenseHost, payload.host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid)
except rule_adopt_service.RuleNotFoundOnHost as exc:
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) from exc
except rule_adopt_service.NotAdoptable as exc:
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
# The new object sits in the session without its relationships loaded; db.get() would return it as-is.
rule = (await db.execute(
select(FirewallRule).where(FirewallRule.id == rule.id).options(*_load_options())
.execution_options(populate_existing=True)
)).scalar_one()
return _to_read(rule, rule.targets, rule.deployments)
@router.post("", response_model=RuleRead, status_code=201)
async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
@@ -101,6 +140,7 @@ async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
destination_net=payload.destination_net,
destination_port=payload.destination_port,
enabled=payload.enabled,
log=payload.log,
targets=hosts,
groups=groups,
)
@@ -172,7 +212,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_rule(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
+17 -1
View File
@@ -11,6 +11,8 @@ from app.errors import ErrorCode, api_error
from app.database import get_db
from app.models import OpnsenseHost
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services.host_maintenance_service import peers
from app.services.host_status_service import get_statuses
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"])
@@ -20,6 +22,8 @@ class Schedule(BaseModel):
action: Literal["reboot", "update", "major_update"]
run_at: datetime
acknowledge_unattended: Literal[True]
# Hand the traffic over to the CARP partner first and switch back afterwards (cluster nodes only).
carp_handover: bool = False
def _read(sched: ScheduledMaintenance) -> dict:
@@ -27,6 +31,7 @@ def _read(sched: ScheduledMaintenance) -> dict:
"id": sched.id, "host_id": sched.host_id, "action": sched.action, "run_at": sched.run_at,
"status": sched.status, "actor": sched.actor, "created_at": sched.created_at,
"result": sched.result, "host_operation_id": sched.host_operation_id,
"carp_handover": sched.carp_handover, "phase": sched.phase,
}
@@ -61,9 +66,20 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc)
if run_at <= now:
raise api_error(422, ErrorCode.ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN)
if payload.carp_handover:
if not host.cluster_name or len(await peers(db, host)) < 2:
raise api_error(409, ErrorCode.CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER)
elif host.cluster_name:
# Without a handover, fire time refuses every cluster node that is not the CARP BACKUP, so a
# plan for the current MASTER can only ever end as "failed" at 3 a.m. Say so now.
# Only a *known* MASTER is rejected here: an unreachable node or an unclear role
# is still judged by the full check when the plan fires.
statuses = await get_statuses(await peers(db, host))
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
raise api_error(409, ErrorCode.GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH)
sched = ScheduledMaintenance(
host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at,
actor=request.state.user.username, created_at=now,
actor=request.state.user.username, created_at=now, carp_handover=payload.carp_handover,
)
db.add(sched)
await db.commit()
+13
View File
@@ -31,6 +31,19 @@ async def _control(host_id: uuid.UUID, name: str, action: str, fn, db: AsyncSess
await policy_lock(db)
host = await host_or_404(db, host_id)
await ensure_no_maintenance(db, [host])
# The UI greys out locked services, but the API must not rely on that:
# unknown names are accepted by the firewall with "ok", and locked ones
# (pf, webgui, routing, ...) can be stopped through it.
try:
listing = await client.search_services(host)
except client.OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
rows = listing.get("rows", []) if isinstance(listing, dict) else []
row = next((r for r in rows if name in (r.get("id"), r.get("name"))), None)
if row is None:
raise api_error(404, ErrorCode.SERVICE_NOT_FOUND, service=name)
if row.get("locked") in (1, "1", True):
raise api_error(409, ErrorCode.SERVICE_LOCKED, service=name)
await audit_service.persist(
kind="host", action=action, status="requested",
host_id=str(host.id), host_name=host.name,
+5 -2
View File
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
class Preferences(BaseModel):
theme: Literal['light','dark','system'] | None = None
language: Literal['de','en'] | None = None
setup_done: bool | None = None
@router.get('')
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
preferences=await db.get(UserPreferences,user.id)
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
'language':preferences.language if preferences else 'de',
'setup_done':preferences.setup_done if preferences else False,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
current = await db.get(UserPreferences, user.id)
theme = payload.theme or (current.theme if current else 'system')
language = payload.language or (current.language if current else 'de')
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
await db.commit()
return {'username':username,'theme':theme,'language':language,
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
+55
View File
@@ -0,0 +1,55 @@
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.routers.hosts import _upstream_error
from app.services import snapshot_service
from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/hosts/{host_id}/snapshots", tags=["snapshots"])
class SnapshotCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
note: str = Field(default="", max_length=200)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.get("")
async def list_snapshots(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
try:
return await snapshot_service.list_snapshots(host)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.post("", status_code=201)
async def create_snapshot(host_id: uuid.UUID, payload: SnapshotCreate, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, host_id)
await ensure_no_maintenance(db, [host])
return await snapshot_service.create(host, payload.name, payload.note)
@router.delete("/{snapshot_uuid}", status_code=204)
async def delete_snapshot(host_id: uuid.UUID, snapshot_uuid: str, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, host_id)
await ensure_no_maintenance(db, [host])
if not snapshot_uuid.replace("-", "").isalnum() or len(snapshot_uuid) > 64:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
await snapshot_service.delete(host, snapshot_uuid)
+96 -2
View File
@@ -1,6 +1,10 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.services import update_check_service
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.services import update_check_service, updater_client
router = APIRouter(prefix="/system", tags=["system"])
@@ -10,3 +14,93 @@ async def version_check() -> dict:
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
return await update_check_service.check()
def _admin(user: AdminUser) -> AdminUser:
# Der Updater ist Administration der Installation selbst — auch das reine
# Lesen (Logs, Rollback-Optionen) bleibt Admins vorbehalten.
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
return user
def _major(version: str | None) -> int | None:
try:
return int((version or "").split(".")[0])
except ValueError:
return None
class UpdateRequest(BaseModel):
version: str = Field(min_length=5, max_length=20)
@router.get("/updater")
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
_admin(user)
check = await update_check_service.check()
result: dict = {
"available": False, "reason": None, "mode": None,
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
"release_url": check["release_url"], "feed_enabled": check["enabled"],
"feed_error": check["error"], "checked_at": check["checked_at"],
"can_update": False, "major_change": False,
"job": None, "phases": [], "log": [], "services": {},
"rollback_available": False, "rollback_loses_data": False, "rollback_version": None,
}
try:
updater = await updater_client.status()
except updater_client.UpdaterUnavailable as exc:
result["reason"] = str(exc)
return result
except updater_client.UpdaterRejected:
result["reason"] = "unreachable"
return result
mode = updater.get("mode")
running = (updater.get("job") or {}).get("state") == "running"
result.update(
available=True, mode=mode, job=updater.get("job"), phases=updater.get("phases") or [],
log=updater.get("log") or [], services=updater.get("services") or {},
rollback_available=bool(updater.get("rollback_available")),
rollback_loses_data=bool(updater.get("rollback_loses_data")),
rollback_version=((updater.get("last_update") or {}).get("prev_version")),
)
# Hauptversionssprünge nur bewusst über die Kommandozeile (Release-Notes lesen).
result["major_change"] = bool(check["latest"]) and _major(check["latest"]) != _major(check["current"])
result["can_update"] = (
mode == "release" and not running and bool(check["update_available"]) and not result["major_change"]
)
return result
@router.post("/updater/update", status_code=202)
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
Version. Beliebige Versionen werden bewusst nicht angenommen."""
_admin(user)
check = await update_check_service.check()
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
if _major(check["latest"]) != _major(check["current"]):
raise api_error(409, ErrorCode.UPDATE_MAJOR_CHANGE)
return await _forward(updater_client.start_update(check["latest"]))
@router.post("/updater/rollback", status_code=202)
async def updater_rollback(user: AdminUser = Depends(current_user)) -> dict:
_admin(user)
return await _forward(updater_client.start_rollback())
async def _forward(call) -> dict:
try:
return await call
except updater_client.UpdaterUnavailable:
raise api_error(503, ErrorCode.UPDATER_UNAVAILABLE)
except updater_client.UpdaterRejected as rejected:
if rejected.code == "busy":
raise api_error(409, ErrorCode.UPDATER_BUSY)
raise api_error(409, ErrorCode.UPDATER_REJECTED, reason=rejected.code)
+1 -1
View File
@@ -187,7 +187,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
try:
await undeploy_tunnel(db, tunnel)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(tunnel, attribute_names=["deployments"])
wireguard_key_store.delete_private_key(tunnel.id)
wireguard_key_store.delete_psk(tunnel.id)
+11
View File
@@ -47,6 +47,8 @@ class HostUpdate(BaseModel):
recreate is still the way to rotate a host's API key for now."""
name: str | None = Field(default=None, min_length=1, max_length=120)
# New address of the firewall, e.g. after its management IP was changed.
base_url: str | None = Field(default=None, min_length=1, max_length=255)
verify_tls: bool | None = None
tls_ca_pem: str | None = Field(default=None, max_length=32768)
clear_tls_pin: bool = False
@@ -56,6 +58,11 @@ class HostUpdate(BaseModel):
policy_hierarchy_group_id: uuid.UUID | None = None
clear_policy_hierarchy_group_id: bool = False # same ambiguity as clear_cluster_name above
@field_validator("base_url")
@classmethod
def _base_url_is_safe(cls, value: str | None) -> str | None:
return validate_base_url(value) if value is not None else None
class HostRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -123,6 +130,9 @@ CertStatus = Literal["valid", "expiring_soon", "expired", "unknown"]
class HostCertificate(BaseModel):
uuid: str
# OPNsense's own cross-references (OpenVPN cert/ca, Trust-Store caref) use
# this id, not the row UUID.
refid: str = ""
descr: str
common_name: str | None
issuer: str
@@ -135,6 +145,7 @@ class HostCertificate(BaseModel):
class HostCa(BaseModel):
uuid: str
refid: str = ""
descr: str
common_name: str | None
valid_from: datetime | None
+183
View File
@@ -0,0 +1,183 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class NetAddress(BaseModel):
address: str # CIDR, e.g. 192.168.1.2/24
# Set for CARP addresses only
vhid: str | None = None
carp_status: str | None = None
class NetInterface(BaseModel):
device: str
identifier: str | None # OPNsense's own name (wan, lan, opt3) — None when unassigned
description: str
status: str
enabled: bool
is_physical: bool
mtu: str | None
mac: str | None
media: str | None
vlan_tag: str | None
vlan_parent: str | None
ipv4_type: str | None # "Static IPv4" / "DHCP" ... (assigned interfaces only)
ipv6_type: str | None
addresses: list[NetAddress]
pending_action: str | None = None
# Listed in the firewall's interface assignment (wan, lan, optN) — only those can be edited or removed.
assigned: bool = False
# This is the interface the firewall answers OPNexus on. Changing it can cut the connection.
is_management: bool = False
class NetVlan(BaseModel):
uuid: str
parent: str
tag: str
pcp: str | None
description: str
device: str | None
class NetVip(BaseModel):
"""No CARP password here, by design: OPNsense returns it in plain text."""
uuid: str
interface: str
interface_label: str
mode: str
address: str
vhid: str | None
advbase: str | None
advskew: str | None
description: str
class NetGateway(BaseModel):
uuid: str
name: str
description: str
interface: str
interface_label: str
ip_protocol: str
address: str
is_default: bool
disabled: bool
priority: str | None
status: str
class NetRoute(BaseModel):
uuid: str
network: str
gateway: str
description: str
disabled: bool
class NetDnsHost(BaseModel):
uuid: str
hostname: str
domain: str
record_type: str
server: str
enabled: bool
description: str
class NetDhcpHost(BaseModel):
uuid: str
host: str
domain: str
ip: str
hwaddr: str
lease_time: str
description: str
class NetKeaSubnet(BaseModel):
uuid: str
subnet: str
pools: str
routers: str
dns_servers: str
description: str
class NetKeaReservation(BaseModel):
uuid: str
subnet: str
ip_address: str
hw_address: str
hostname: str
description: str
class HostNetwork(BaseModel):
host_id: str
host_name: str
interfaces: list[NetInterface]
vlans: list[NetVlan]
vips: list[NetVip]
gateways: list[NetGateway]
routes: list[NetRoute]
dns_hosts: list[NetDnsHost] = []
dhcp_hosts: list[NetDhcpHost] = []
kea_subnets: list[NetKeaSubnet] = []
kea_reservations: list[NetKeaReservation] = []
management_device: str | None
# The firewall's own API can set interface IPs/types (OPNsense >= ~26.7.6). Older 26.7.x
# only lists assignments. Detected from the data, not from a version number.
can_configure_interfaces: bool
# Sections the firewall could not deliver (e.g. older OPNsense without that API) — never silently empty.
unavailable: list[str]
class NetworkChangeRequest(BaseModel):
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation",
"tunable", "cron_job", "syslog_destination", "gateway_group", "lagg", "bridge", "loopback", "gre", "gif", "vxlan", "dns_forward", "dhcp_range"]
action: Literal["add", "set", "del"]
# Goes into a URL path, so only plain identifiers.
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
fields: dict[str, Any] = Field(default_factory=dict)
class NetworkChangeExecute(NetworkChangeRequest):
preview_token: str = Field(min_length=64, max_length=64)
acknowledge: bool = False
class NetworkChangeRisk(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class NetworkFieldChange(BaseModel):
field: str
before: str | None
after: str | None
class NetworkChangePreview(BaseModel):
token: str
kind: str
action: str
uuid: str | None
host_id: str
host_name: str
changes: list[NetworkFieldChange]
risks: list[NetworkChangeRisk]
warnings: list[NetworkChangeRisk]
blocks: list[NetworkChangeRisk]
requires_acknowledgement: bool
management_device: str | None
can_execute: bool
class NetworkChangeResult(BaseModel):
status: str
uuid: str | None
verified: bool
mismatches: list[str]
backup_id: str
+6 -1
View File
@@ -9,7 +9,8 @@ from app.config import settings
SmtpSecurity = Literal["none", "starttls", "ssl"]
ProblemType = Literal[
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
"scheduled_maintenance_failed",
]
@@ -36,7 +37,9 @@ class NotificationSettingsRead(BaseModel):
maintenance_unknown_enabled: bool
carp_health_enabled: bool
nat_drift_enabled: bool
network_drift_enabled: bool = True
carp_auto_revert_failed_enabled: bool
scheduled_maintenance_failed_enabled: bool = True
poll_interval_seconds: int
updated_at: datetime
@@ -71,7 +74,9 @@ class NotificationSettingsUpdate(BaseModel):
maintenance_unknown_enabled: bool | None = None
carp_health_enabled: bool | None = None
nat_drift_enabled: bool | None = None
network_drift_enabled: bool | None = None
carp_auto_revert_failed_enabled: bool | None = None
scheduled_maintenance_failed_enabled: bool | None = None
poll_interval_seconds: int | None = Field(
default=None, ge=settings.notification_poll_min_interval_seconds
+26
View File
@@ -17,6 +17,7 @@ class RuleCreate(BaseModel):
destination_net: str = Field(default="any", min_length=1, max_length=128)
destination_port: str | None = Field(default=None, max_length=32)
enabled: bool = True
log: bool = False
target_host_ids: list[uuid.UUID] = Field(default_factory=list)
target_group_ids: list[uuid.UUID] = Field(default_factory=list)
@@ -56,6 +57,7 @@ class RuleRead(BaseModel):
destination_net: str
destination_port: str | None
enabled: bool
log: bool = False
created_at: datetime
updated_at: datetime
target_host_ids: list[uuid.UUID]
@@ -72,3 +74,27 @@ class RuleEdit(RuleCreate):
class DeployApproval(BaseModel):
preview_token: str = Field(min_length=64, max_length=64)
class AdoptRequest(BaseModel):
host_id: uuid.UUID
rule_uuid: str = Field(min_length=1, max_length=64)
class UnmanagedRule(BaseModel):
uuid: str
description: str
interface: str
interface_label: str
direction: str
action: str
protocol: str
source_net: str
source_port: str
destination_net: str
destination_port: str
enabled: bool
log: bool
adoptable: bool
# Codes such as "negated_source" or "not_quick"; the UI turns them into text.
reasons: list[str]
+1 -1
View File
@@ -135,7 +135,7 @@ async def undeploy_alias(db: AsyncSession, alias: FirewallAlias) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+19 -2
View File
@@ -15,10 +15,27 @@ from app.models.audit import AuditEvent
context = ContextVar("audit_context", default=None)
# Column widths of audit_events. entity_id/host_id/action are taken from the
# request path BEFORE the router validated it, so a junk segment (300 chars,
# or a NUL byte which Postgres text cannot store) used to turn into a 500 or a
# dropped connection instead of a plain 404/422 (found by fuzzing, 2026-10-09).
_LIMITS = {"correlation_id": 36, "actor": 150, "kind": 32, "action": 40, "status": 32,
"entity_id": 36, "entity_name": 255, "host_id": 36, "host_name": 255}
def _clean(key, value):
if not isinstance(value, str):
return value
value = value.replace("\x00", "")
limit = _LIMITS.get(key)
return value[:limit] if limit else value
def make_event(**values):
ctx = context.get() or {}
return AuditEvent(actor=ctx.get("actor", "system:rollback"),
correlation_id=ctx.get("id", str(uuid.uuid4())), **values)
values = {k: _clean(k, v) for k, v in values.items()}
return AuditEvent(actor=_clean("actor", ctx.get("actor", "system:rollback")),
correlation_id=_clean("correlation_id", ctx.get("id", str(uuid.uuid4()))), **values)
async def persist(**values):
+2 -1
View File
@@ -367,7 +367,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
logger.warning(
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
" generated password:\n\n %s\n\n"
" Log in and change it now. This is shown only once.\n%s",
" Log in and change it now. This is shown only once.\n"
" Lost it? ./opnexus password (sets and prints a new one)\n%s",
"=" * 64, settings.admin_username, password, "=" * 64,
)
else:
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.carp_maintenance_window import CarpMaintenanceWindow
from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation
from app.services import audit_service, host_status_service
from app.services import opnsense_client as client
@@ -63,6 +64,26 @@ async def close_windows_for_manual_leave(db: AsyncSession, host: OpnsenseHost) -
await db.commit()
async def _close_enter_operations(db: AsyncSession, host: OpnsenseHost, message: str) -> None:
"""Closes the host's still-open `carp_enter` maintenance record once the
window has been resolved by the timer. Without this the record stays
"submitted" forever: acknowledge cannot succeed (the host is no longer in
maintenance) and `ensure_no_maintenance` keeps blocking every deploy and
update in the cluster until someone cancels it by hand."""
result = await db.execute(
select(HostOperation).where(
HostOperation.host_id == host.id, HostOperation.action == "carp_enter", HostOperation.status == "submitted",
)
)
for op in result.scalars().all():
op.status = "cancelled"
op.message = message
await audit_service.persist(
kind="host", action="carp_enter", status="cancelled",
host_id=str(host.id), host_name=host.name, entity_id=str(op.id), message=message,
)
async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
result = await db.execute(
select(CarpMaintenanceWindow).where(
@@ -88,6 +109,7 @@ async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
window.status = "cancelled_manual"
window.resolved_at = _now()
window.message = "Host war beim Ablauf bereits nicht mehr im CARP-Wartungsmodus."
await _close_enter_operations(db, host, window.message)
resolved.append(window)
continue
result_payload = await client.toggle_carp_maintenance(host)
@@ -96,6 +118,7 @@ async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
window.status = "reverted"
window.resolved_at = _now()
window.message = "Automatisch nach Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet."
await _close_enter_operations(db, host, window.message)
await audit_service.persist(
kind="host", action="carp_auto_revert", status="submitted",
host_id=str(host.id), host_name=host.name, entity_id=str(window.id),
+142
View File
@@ -0,0 +1,142 @@
"""Network probes run FROM a firewall: ping, traceroute, TCP port probe, DNS lookup.
Admin-only by construction (every call is a POST, which the read-only role is
refused for) and audited with the target, because a firewall that probes
arbitrary addresses on request is also a scanning tool. Inputs are checked
here before they are sent (the firewall validates them again); results are
shown and never stored. Ping runs as a short job that is always stopped and
removed again, even when polling fails.
"""
import asyncio
import ipaddress
import re
import time
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import audit_service, opnsense_client
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseDiagnosticRejected
TARGET = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.:_-]{0,251}[A-Za-z0-9])?$")
# At most a few probes at a time per OPNexus process, so the page cannot be used to hammer a firewall.
_SLOTS = asyncio.Semaphore(4)
def clean_target(value: str, field: str = "target") -> str:
value = (value or "").strip()
if not TARGET.match(value):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
return value
def clean_source(value: str | None) -> str:
value = (value or "").strip()
if not value:
return ""
try:
return str(ipaddress.ip_address(value))
except ValueError:
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="source") from None
async def _audit(host: OpnsenseHost, tool: str, target: str, ok: bool, details: dict, message: str | None = None) -> None:
await audit_service.persist(kind="diagnostic", action=f"diag.{tool}", status="ok" if ok else "failed",
entity_name=target, host_id=str(host.id), host_name=host.name, message=message, details=details)
def _rejected(exc: OpnsenseDiagnosticRejected):
field = next(iter(exc.validations), "target").split(".")[-1]
return api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
async def _guarded(host: OpnsenseHost, tool: str, target: str, params: dict, call):
async with _SLOTS:
try:
result = await call()
except OpnsenseDiagnosticRejected as exc:
await _audit(host, tool, target, False, params, "rejected: " + ", ".join(exc.validations))
raise _rejected(exc) from exc
except OpnsenseAPIError as exc:
await _audit(host, tool, target, False, params, str(exc)[:300])
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await _audit(host, tool, target, True, params)
return result
async def traceroute(host: OpnsenseHost, target: str, ipproto: str, protocol: str, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "ipproto": ipproto, "protocol": protocol, "source": source}
async def call():
data = await opnsense_client.diagnostic_set(
host, "/diagnostics/traceroute/set", "traceroute",
{"hostname": target, "ipproto": ipproto, "protocol": protocol, "source_address": source})
resp = data.get("response") or {}
hops = [{"ttl": str(r.get("ttl", "")), "address": str(r.get("address", "")), "host": str(r.get("host", "")),
"as": str(r.get("AS", "")), "probes": str(r.get("probes", ""))} for r in resp.get("rows", []) if isinstance(r, dict)]
return {"target": target, "notice": str(resp.get("notice", "")), "hops": hops}
return await _guarded(host, "traceroute", target, params, call)
async def portprobe(host: OpnsenseHost, target: str, port: int, ipproto: str, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "port": port, "ipproto": ipproto, "source": source}
async def call():
data = await opnsense_client.diagnostic_set(
host, "/diagnostics/portprobe/set", "portprobe",
{"hostname": target, "port": str(port), "ipproto": ipproto, "source_address": source, "source_port": "", "showtext": "0"})
message = str((data.get("response") or {}).get("message", ""))
# Never forward the remote payload (a service banner): only whether the connection worked.
return {"target": target, "port": port, "open": "succeeded" in message.lower(), "message": message}
return await _guarded(host, "portprobe", target, params, call)
async def dns_lookup(host: OpnsenseHost, name: str, server: str | None) -> dict:
name = clean_target(name, "name")
server = clean_source(server)
params = {"name": name, "server": server}
async def call():
data = await opnsense_client.diagnostic_set(host, "/diagnostics/dns_diagnostics/set", "dns", {"hostname": name, "server": server})
out = {}
for rtype, rec in (data.get("response") or {}).items():
if isinstance(rec, dict):
out[str(rtype)] = {"answers": [str(a) for a in rec.get("answers", [])], "query_time": str(rec.get("query_time", "")),
"server": str(rec.get("server", ""))}
return {"name": name, "records": out}
return await _guarded(host, "dns", name, params, call)
async def ping(host: OpnsenseHost, target: str, ipproto: str, count: int, packetsize: int | None, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "ipproto": ipproto, "count": count, "packetsize": packetsize, "source": source}
async def call():
job = await opnsense_client.ping_job_start(host, {
"hostname": target, "fam": "ip6" if ipproto == "inet6" else "ip", "source_address": source,
"packetsize": str(packetsize or ""), "disable_frag": "0", "interval": "1", "description": "OPNexus"})
try:
state = None
deadline = time.monotonic() + count + 6
while time.monotonic() < deadline:
await asyncio.sleep(1)
state = await opnsense_client.ping_job_state(host, job)
if state and int(state.get("send") or 0) >= count:
break
await opnsense_client.ping_job_stop(host, job)
await asyncio.sleep(0.5)
state = await opnsense_client.ping_job_state(host, job) or state
finally:
# Never leave a job running on the firewall.
try:
await opnsense_client.ping_job_stop(host, job)
await opnsense_client.ping_job_remove(host, job)
except OpnsenseAPIError:
pass
if not state:
raise OpnsenseAPIError("Ping: kein Ergebnis von der Firewall")
return {"target": target, "sent": int(state.get("send") or 0), "received": int(state.get("received") or 0),
"loss": str(state.get("loss", "")), "min": state.get("min"), "avg": state.get("avg"), "max": state.get("max"),
"stddev": state.get("std-dev"), "error": state.get("last_error")}
return await _guarded(host, "ping", target, params, call)
@@ -59,7 +59,9 @@ async def _shared_change_blockers(db, fleet):
return blocked
async def inspect_host(db, host):
async def inspect_host(db, host, *, skip_role_checks=False):
"""skip_role_checks: for the planned CARP handover, where the node is still MASTER when
it is inspected and becomes BACKUP only afterwards (the role is verified separately)."""
fleet = await peers(db, host)
statuses, info, firmware, running = await asyncio.gather(
get_statuses(fleet), client.get_firmware_info(host), client.get_firmware_status(host), client.get_firmware_running(host))
@@ -68,7 +70,7 @@ async def inspect_host(db, host):
if statuses.get(host.id, {}).get("status") != "online": blocked.append("Host ist nicht erreichbar.")
# Fail closed: only the verified ready value permits a disruptive action.
if running.get("status") != "ready": blocked.append("Firmware-Auftrag läuft oder Status ist unbekannt.")
if host.cluster_name:
if host.cluster_name and not skip_role_checks:
others = [h for h in fleet if h.id != host.id]
if statuses.get(host.id, {}).get("carp_status") != "BACKUP":
blocked.append("Cluster-Wartung nur auf dem CARP-BACKUP. Rollenwechsel separat in OPNsense durchführen.")
+4 -1
View File
@@ -254,6 +254,9 @@ async def _deploy_to_host(db: AsyncSession, tunnel: IpsecTunnel, host: OpnsenseH
try:
await _write_config(host, payloads, deployment)
# Same trap as WireGuard: with the global IPsec switch off the config
# is stored but nothing runs, and reconfigure still answers ok.
await opnsense_client.ensure_ipsec_enabled(host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
@@ -324,7 +327,7 @@ async def undeploy_tunnel(db: AsyncSession, tunnel: IpsecTunnel) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
@@ -44,6 +44,7 @@ def _row_to_certificate(row: dict) -> HostCertificate:
return HostCertificate(
uuid=row.get("uuid", ""),
refid=row.get("refid", ""),
descr=row.get("descr") or "(ohne Beschreibung)",
common_name=row.get("commonname") or None,
issuer=row.get("%caref") or "Selbstsigniert",
@@ -68,6 +69,7 @@ def _row_to_ca(row: dict) -> HostCa:
return HostCa(
uuid=row.get("uuid", ""),
refid=row.get("refid", ""),
descr=row.get("descr") or "(ohne Beschreibung)",
common_name=row.get("commonname") or None,
valid_from=valid_from,
+5 -1
View File
@@ -62,6 +62,10 @@ async def _live_rows_or_none(host: OpnsenseHost, nat_type: str) -> list[dict] |
async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> list[dict]:
return (await compute_cluster_drift_with_reachable(hosts, nat_type))[0]
async def compute_cluster_drift_with_reachable(hosts: list[OpnsenseHost], nat_type: str) -> tuple[list[dict], set]:
"""Same shape/contract as rule_drift_service.compute_cluster_drift:
one row per fingerprint missing from at least one reachable host."""
_, fingerprint_fn = _SPECS[nat_type]
@@ -78,4 +82,4 @@ async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> lis
for entry in by_fingerprint.values():
entry["reachable_host_ids"] = reachable_ids
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)]
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)], reachable_ids
@@ -0,0 +1,453 @@
"""Changes to VLANs, virtual IPs, gateways and static routes of ONE firewall.
Unlike rules, these objects are specific to a firewall (own IPs, own VLAN
layout), so there is no central copy and no multi-host push: the firewall is the
source of truth and every change goes straight to it, but only through a
preview/execute pair:
preview reads the live state, computes the diff and the risks, and returns a
token that covers exactly that state.
execute re-reads the live state, refuses if it no longer matches the token,
refuses risky changes without an explicit acknowledgement, saves a
fresh config backup, writes the object, applies it, and reads it back
to check that the firewall really holds what was asked for.
The main risk is cutting the connection OPNexus itself uses (the interface or
address the firewall is reached on, the default route, a route that swallows the
management address). Those cases are detected from the firewall's own
interface list and need the acknowledgement. OPNexus cannot undo a change that
cuts it off, so there is deliberately NO automatic rollback here — the backup
taken before the change is the way back.
Secrets (the CARP password) are never returned, hashed into the token only, and
never written to the audit log.
"""
import hashlib
import ipaddress
import json
import re
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import audit_service, backup_store, network_service, opnsense_client
from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.opnsense_client import OpnsenseAPIError
# Only these fields can be sent; anything else is rejected, never forwarded.
FIELDS: dict[str, set[str]] = {
"vlan": {"if", "tag", "pcp", "proto", "descr"},
"vip": {"interface", "mode", "network", "vhid", "advbase", "advskew", "password", "descr", "nosync", "peer", "peer6"},
"gateway": {"disabled", "name", "descr", "interface", "ipprotocol", "gateway", "defaultgw", "fargw",
"monitor_disable", "monitor", "priority", "weight"},
"route": {"network", "gateway", "descr", "enabled"},
"dns_host": {"enabled", "hostname", "domain", "rr", "server", "mx", "mxprio", "addptr", "description"},
"dhcp_host": {"host", "domain", "local", "ip", "hwaddr", "client_id", "lease_time", "ignore", "descr"},
"kea_subnet": {"subnet", "pools", "valid_lifetime", "next_server", "description", "option_data.routers",
"option_data.domain_name_servers", "option_data.domain_name", "option_data.ntp_servers"},
"kea_reservation": {"subnet", "ip_address", "hw_address", "client_id", "hostname", "description", "next_server"},
"tunable": {"tunable", "value", "descr"},
"cron_job": {"enabled", "minutes", "hours", "days", "months", "weekdays", "who", "command", "parameters", "description"},
"syslog_destination": {"enabled", "transport", "program", "level", "facility", "hostname", "port", "rfc5424", "description"},
"gateway_group": {"name", "item", "item2", "item3", "item4", "item5", "trigger", "poolopts", "descr"},
"lagg": {"members", "primary_member", "proto", "lacp_fast_timeout", "use_flowid", "lagghash", "lacp_strict", "mtu", "descr"},
"bridge": {"members", "linklocal", "enablestp", "proto", "descr"},
"loopback": {"deviceId", "description"},
"gre": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
"gif": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
"vxlan": {"deviceId", "vxlanid", "vxlanlocal", "vxlanlocalport", "vxlanremote", "vxlanremoteport", "vxlangroup", "vxlandev"},
"dns_forward": {"enabled", "type", "domain", "server", "port", "verify", "forward_tcp_upstream", "forward_first", "description"},
"dhcp_range": {"interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "domain_type", "domain", "description"},
"interface": {"if", "descr", "enable", "type4", "ipaddr", "gateway", "type6", "ipaddrv6", "gatewayv6", "mtu",
"blockpriv", "blockbogons", "lock"},
}
SECRET_FIELDS: dict[str, set[str]] = {"vip": {"password"}}
# Section of HostNetwork that must be readable for the kind to be managed on this firewall.
SECTION = {"vlan": "vlans", "vip": "vips", "gateway": "gateways", "route": "routes", "interface": "assignments",
"dns_host": "dns_hosts", "dhcp_host": "dhcp_hosts", "kea_subnet": "kea_subnets",
"kea_reservation": "kea_reservations"}
# Columns the generic object lists show (the firewall's own field names). Lists never carry fields outside FIELDS.
OBJECT_COLUMNS: dict[str, list[str]] = {
"tunable": ["tunable", "value", "default_value", "descr"],
"cron_job": ["enabled", "minutes", "hours", "days", "months", "weekdays", "command", "description"],
"syslog_destination": ["enabled", "transport", "hostname", "port", "program", "level", "description"],
"gateway_group": ["name", "item", "item2", "item3", "trigger", "descr"],
"lagg": ["laggif", "members", "proto", "descr"],
"bridge": ["bridgeif", "members", "proto", "descr"],
"loopback": ["deviceId", "description"],
"gre": ["greif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
"gif": ["gifif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
"vxlan": ["deviceId", "vxlanid", "vxlanlocal", "vxlanremote", "vxlangroup", "vxlandev"],
"dns_forward": ["enabled", "type", "domain", "server", "port", "description"],
"dhcp_range": ["interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "description"],
}
# These change how the network is wired; the others (DNS/DHCP data) cannot cut the connection to the firewall.
WIRING_KINDS = {"vlan", "vip", "gateway", "route", "interface", "lagg", "bridge", "gre", "gif", "vxlan"}
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
def validate_fields(kind: str, fields: dict) -> dict[str, str]:
allowed = FIELDS[kind]
clean: dict[str, str] = {}
for key, value in fields.items():
if key not in allowed:
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=str(key)[:40])
if isinstance(value, bool):
value = "1" if value else "0"
if value is None:
value = ""
if not isinstance(value, (str, int)) or isinstance(value, bool):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
text = str(value).strip()
if len(text) > 255 or _CONTROL.search(text):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
clean[key] = text
return clean
def _explode(node: dict) -> dict:
"""Kea nests its DHCP options one level deep; treat them as "option_data.<name>" like the whitelist does."""
out: dict = {}
for key, value in node.items():
if key == "option_data" and isinstance(value, dict):
for child, inner in value.items():
out[f"option_data.{child}"] = inner
else:
out[key] = value
return out
def flatten(kind: str, node: dict) -> dict[str, str]:
"""OPNsense answers select fields as {option: {selected: 0|1}}; reduce to the selected value(s)."""
out: dict[str, str] = {}
for key, value in _explode(node).items():
if key in SECRET_FIELDS.get(kind, set()):
continue
if isinstance(value, dict):
selected = [k for k, o in value.items() if isinstance(o, dict) and o.get("selected") and k != ""]
if value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
out[key] = ",".join(selected)
continue
out[key] = "" if value is None else str(value)
return out
def _ip(text: str):
try:
return ipaddress.ip_interface(text.strip()).ip
except ValueError:
return None
def _network(text: str):
try:
return ipaddress.ip_network(text.strip(), strict=False)
except ValueError:
return None
def _risk(code: str, **params) -> dict:
return {"code": code, "params": params}
def assess(kind: str, action: str, uuid: str | None, before: dict, after: dict,
net, mgmt_addresses: set, peers: list[str], context: dict | None = None) -> tuple[list[dict], list[dict], list[dict]]:
"""Returns (risks needing acknowledgement, plain warnings, hard blocks)."""
risks: list[dict] = []
warnings: list[dict] = []
blocks: list[dict] = []
mgmt_device = net.management_device
mgmt_iface = next((i for i in net.interfaces if i.device == mgmt_device), None)
mgmt_ident = mgmt_iface.identifier if mgmt_iface else None
if SECTION.get(kind) in net.unavailable:
blocks.append(_risk("api_unavailable", section=SECTION[kind]))
if mgmt_device is None and kind in WIRING_KINDS:
# We cannot tell what this change could cut, so treat every change to existing objects as risky.
if action in ("set", "del"):
risks.append(_risk("management_unknown"))
context = context or {}
if kind == "interface":
if not net.can_configure_interfaces:
blocks.append(_risk("interface_api_unavailable"))
if context.get("pending"):
blocks.append(_risk("interface_changes_pending"))
if action in ("set", "del") and mgmt_ident and uuid == mgmt_ident:
risks.append(_risk("management_interface", device=mgmt_device))
if action == "del" and before.get("lock") == "1":
# OPNsense refuses to remove a locked interface; say so now instead of failing at apply time.
blocks.append(_risk("interface_locked"))
if action == "del":
rules = context.get("rules_using") or []
if rules:
risks.append(_risk("interface_rules_removed", count=len(rules), names=", ".join(rules[:5])))
if action in ("set", "del") and uuid in ("wan", "lan") and uuid != mgmt_ident:
warnings.append(_risk("core_interface", name=uuid))
if kind in ("lagg", "bridge"):
members = {m for side in (before, after) for m in side.get("members", "").split(",") if m}
if mgmt_device and mgmt_device in members:
risks.append(_risk("management_member", device=mgmt_device))
elif mgmt_device is None and action in ("set", "del"):
pass # already covered by management_unknown above
if kind == "tunable":
# sysctl values can change how the whole system behaves (and some only apply after a reboot).
risks.append(_risk("tunable_change"))
if action in ("set", "del") and uuid and "-" not in uuid:
# A built-in default (its id is not a UUID): the firewall would create a new entry instead of
# changing it, so overriding one is an "add", not an edit.
blocks.append(_risk("tunable_default_row"))
if kind == "gateway_group" and action == "del":
warnings.append(_risk("gateway_group_in_use"))
if kind == "vlan" and action in ("set", "del"):
device = next((v.device for v in net.vlans if v.uuid == uuid), None)
if device and device == mgmt_device:
risks.append(_risk("management_interface", device=device))
if kind == "vip":
for side in (before, after) if action != "add" else (after,):
ip = _ip(side.get("network", "")) if side.get("network") else None
if ip is not None and ip in mgmt_addresses:
risks.append(_risk("management_address", address=str(ip)))
break
if "carp" in (before.get("mode"), after.get("mode")):
warnings.append(_risk("carp_change"))
if kind == "gateway":
current = next((g for g in net.gateways if g.uuid == uuid), None)
if current is not None and action in ("set", "del"):
if current.is_default:
risks.append(_risk("default_gateway", name=current.name))
if mgmt_ident and current.interface == mgmt_ident:
risks.append(_risk("management_gateway", name=current.name))
if action in ("add", "set") and after.get("defaultgw") == "1" and (current is None or not current.is_default):
risks.append(_risk("default_gateway_change"))
if kind == "route":
for side in (before, after) if action != "add" else (after,):
network = _network(side.get("network", "")) if side.get("network") else None
if network is None:
continue
if network.prefixlen == 0:
risks.append(_risk("default_route", network=str(network)))
break
if any(a.version == network.version and a in network for a in mgmt_addresses):
risks.append(_risk("route_covers_management", network=str(network)))
break
if peers:
warnings.append(_risk("cluster_peers", hosts=", ".join(peers)))
# De-duplicate while keeping order.
seen, unique = set(), []
for r in risks:
key = json.dumps(r, sort_keys=True)
if key not in seen:
seen.add(key)
unique.append(r)
return unique, warnings, blocks
def _token(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict, before: dict, mgmt_device) -> str:
state = {
"host": str(host.id), "url": host.base_url, "kind": kind, "action": action, "uuid": uuid,
# hash, so a secret never sits in the token's input in the clear
"fields": hashlib.sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest(),
"before": before, "mgmt": mgmt_device,
}
return hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest()
def _public(kind: str, fields: dict) -> dict:
return {k: ("(gesetzt)" if k in SECRET_FIELDS.get(kind, set()) and v else v) for k, v in fields.items()
if not (k in SECRET_FIELDS.get(kind, set()) and not v)}
async def _peers(db: AsyncSession, host: OpnsenseHost) -> list[str]:
if not host.cluster_name:
return []
rows = await db.scalars(select(OpnsenseHost.name).where(
OpnsenseHost.cluster_name == host.cluster_name, OpnsenseHost.id != host.id).order_by(OpnsenseHost.name))
return list(rows.all())
async def list_objects(host: OpnsenseHost, kind: str) -> dict:
"""Rows of one generic object kind, reduced to the columns above (plus uuid) — never whole rows,
so nothing the list does not need (or must not show) leaves the server."""
columns = OBJECT_COLUMNS[kind]
try:
rows = await opnsense_client.search_network_objects(host, kind)
except OpnsenseAPIError as exc:
if "404" in str(exc):
return {"available": False, "columns": columns, "rows": []} # this OPNsense version lacks the API
raise
out = []
for row in rows:
item = {"uuid": str(row.get("uuid", ""))}
for col in columns:
value = row.get(col)
item[col] = "" if value is None or isinstance(value, (dict, list)) else str(value)
out.append(item)
return {"available": True, "columns": columns, "rows": out}
async def _rules_using(host: OpnsenseHost, identifier: str) -> list[str]:
"""Descriptions of the user-made rules that name this interface (OPNsense deletes them with it)."""
data = await opnsense_client.search_rules(host, all_rows=True)
rows = data.get("rows", []) if isinstance(data, dict) else []
return [str(r.get("description") or r.get("uuid")) for r in rows
if isinstance(r, dict) and not r.get("is_automatic")
and identifier in str(r.get("interface") or "").split(",")]
async def preview(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> dict:
fields = validate_fields(kind, fields)
if action in ("set", "del") and not uuid:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
net = await network_service.get_host_network(host)
before: dict[str, str] = {}
if action in ("set", "del"):
node = await opnsense_client.get_network_item(host, kind, uuid)
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
# Only the fields that may be sent: the rest (e.g. Kea's DDNS key secret) never reaches the token or the audit log.
before = {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
after = {} if action == "del" else ({**before, **fields} if action == "set" else dict(fields))
mgmt_addresses = await network_service.management_addresses(host)
context: dict = {}
if kind == "interface":
if net.can_configure_interfaces:
context["pending"] = await opnsense_client.interface_changes_pending(host)
if action == "del":
context["rules_using"] = await _rules_using(host, uuid)
risks, warnings, blocks = assess(kind, action, uuid, before, after, net, mgmt_addresses, await _peers(db, host), context)
secret = SECRET_FIELDS.get(kind, set())
changes = []
for key in sorted(set(before) | set(fields)):
old, new = before.get(key), fields.get(key) if action != "del" else None
if action == "del":
if old not in (None, "") and key in FIELDS[kind]:
changes.append({"field": key, "before": old, "after": None})
elif key in fields and (action == "add" or old != new):
if key in secret:
changes.append({"field": key, "before": None, "after": "(gesetzt)" if new else None})
else:
changes.append({"field": key, "before": old, "after": new})
token = _token(host, kind, action, uuid, fields, before, net.management_device)
return {
"token": token, "kind": kind, "action": action, "uuid": uuid, "host_id": str(host.id), "host_name": host.name,
"changes": changes, "risks": risks, "warnings": warnings, "blocks": blocks,
"requires_acknowledgement": bool(risks), "management_device": net.management_device,
"can_execute": not blocks and (action == "del" or bool(changes)),
"_fields": fields, "_before": before,
}
def _matches(expected: dict[str, str], actual: dict[str, str], secret: set[str]) -> list[str]:
return [k for k, v in expected.items() if k not in secret and actual.get(k, "") != v]
async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> tuple[bool, list[str]]:
try:
if action == "del":
present = await opnsense_client.list_network_item_uuids(host, kind)
return (uuid not in present), ([] if uuid not in present else ["still_present"])
actual = flatten(kind, await opnsense_client.get_network_item(host, kind, uuid))
mismatches = _matches(fields, actual, SECRET_FIELDS.get(kind, set()))
return (not mismatches), mismatches
except OpnsenseAPIError:
return False, ["unreadable"]
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
token: str, acknowledged: bool) -> dict:
await ensure_no_maintenance(db, [host])
try:
plan = await preview(db, host, kind, action, uuid, fields)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
if plan["token"] != token:
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
if plan["blocks"]:
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons=", ".join(b["code"] for b in plan["blocks"]))
if not plan["can_execute"]:
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="nothing_to_change")
if plan["requires_acknowledgement"] and not acknowledged:
raise api_error(409, ErrorCode.NETWORK_ACK_REQUIRED, reasons=", ".join(r["code"] for r in plan["risks"]))
fields = plan["_fields"]
send = dict(fields)
if kind == "vip" and action == "set" and plan["_before"].get("network"):
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
# the request, so every VIP update must repeat it (found live: a partial set was rejected / could wipe it).
send.setdefault("network", plan["_before"]["network"])
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
expected = {**plan["_before"], **send} if action == "set" else dict(send)
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
audit = {"kind": "network", "action": f"{kind}.{action}"[:40], "entity_id": uuid, "entity_name": entity,
"host_id": str(host.id), "host_name": host.name}
details = {"before": plan["_before"], "after": _public(kind, fields) if action != "del" else None,
"risks": [r["code"] for r in plan["risks"]], "acknowledged": acknowledged}
# Way back: a config backup taken right before the change. No backup, no change.
try:
backup_id = backup_store.save_snapshot(host.id, await opnsense_client.download_backup(host))
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="aborted", message="Backup vor der Änderung fehlgeschlagen", details=details)
raise api_error(502, ErrorCode.NETWORK_BACKUP_FAILED, host=host.name) from exc
details["backup_id"] = backup_id
await audit_service.persist(**audit, status="started", message=None, details=details)
try:
result = await opnsense_client.write_network_item(host, kind, action, uuid, send)
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
try:
await opnsense_client.reconfigure_network(host, kind)
except OpnsenseAPIError as exc:
# Written but not (provably) applied: say so instead of reporting success.
await audit_service.persist(**audit, status="apply_uncertain", message=str(exc)[:500], details=details)
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id=backup_id) from exc
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
await audit_service.persist(**audit, status="applied" if verified else "unverified",
message=None if verified else ", ".join(mismatches)[:500], details=details)
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
def _options(value: dict) -> list[dict]:
return [{"value": k, "label": str(o.get("value", k)) if isinstance(o, dict) else str(k), "selected": bool(isinstance(o, dict) and o.get("selected"))}
for k, o in value.items()]
async def template(host: OpnsenseHost, kind: str) -> dict:
"""The firewall's own blank form for this kind: field defaults and, for select fields, the choices it
accepts right now (e.g. only the gateways it considers valid for a route)."""
node = _explode(await opnsense_client.get_network_item(host, kind, None))
out: dict[str, dict] = {}
for key in sorted(FIELDS[kind]):
if key not in node:
continue
value = node[key]
if isinstance(value, dict) and value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
options = _options(value)
# Free-text list fields (IPs, MACs, DNS servers) arrive in this shape too; the form decides how to show them.
out[key] = {"value": ",".join(o["value"] for o in options if o["selected"] and o["value"] != ""), "options": options}
elif key in SECRET_FIELDS.get(kind, set()):
out[key] = {"value": "", "options": None}
else:
out[key] = {"value": "" if value is None or isinstance(value, dict) else str(value), "options": None}
return out
async def item(host: OpnsenseHost, kind: str, uuid: str) -> dict[str, str]:
"""One object as the firewall holds it right now, reduced to the fields that may be sent (no secrets)."""
node = await opnsense_client.get_network_item(host, kind, uuid)
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
return {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
@@ -0,0 +1,86 @@
"""Compares the network configuration of the nodes of an HA cluster.
Interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP objects should
be the same on both nodes of a pair; a VLAN or gateway that exists on one node
only is exactly the kind of difference that breaks a failover months later.
Per-node values are deliberately NOT compared: interface addresses, CARP
advskew/password and the active default gateway legitimately differ between
the nodes. What is compared is the identity of each object (its key) and the
settings that must agree (its signature). A category is skipped when any
reachable node cannot deliver it (older OPNsense), instead of reporting that
as a difference.
"""
from app.models.host import OpnsenseHost
from app.schemas.network import HostNetwork
from app.services import network_service
from app.services.opnsense_client import OpnsenseAPIError
CATEGORIES = ["interfaces", "vlans", "vips", "gateways", "routes", "dns_hosts", "dhcp_hosts", "kea_subnets", "kea_reservations"]
def signatures(net: HostNetwork) -> dict[str, dict[str, str]]:
out: dict[str, dict[str, str]] = {c: {} for c in CATEGORIES}
for i in net.interfaces:
if i.assigned and i.identifier:
out["interfaces"][i.identifier] = f"{i.description}|{i.vlan_tag or ''}|{i.ipv4_type or ''}"
for v in net.vlans:
out["vlans"][f"{v.tag}@{v.parent}"] = v.description
for v in net.vips:
if v.mode == "carp":
out["vips"][f"carp:{v.vhid}"] = f"{v.address}|{v.advbase or ''}|{v.description}"
else:
out["vips"][f"{v.mode}:{v.address}"] = v.description
for g in net.gateways:
out["gateways"][g.name] = f"{g.interface}|{g.ip_protocol}|{g.address}|{g.disabled}"
for r in net.routes:
out["routes"][r.network] = f"{r.gateway}|{r.disabled}"
for d in net.dns_hosts:
out["dns_hosts"][f"{d.hostname}.{d.domain}|{d.record_type}"] = f"{d.server}|{d.enabled}"
for h in net.dhcp_hosts:
out["dhcp_hosts"][f"{h.host}.{h.domain}"] = f"{h.ip}|{h.hwaddr}"
for s in net.kea_subnets:
out["kea_subnets"][s.subnet] = f"{s.pools}|{s.routers}|{s.dns_servers}"
for k in net.kea_reservations:
out["kea_reservations"][f"{k.ip_address}|{k.hw_address}"] = k.hostname
return out
# Which HostNetwork.unavailable entry makes a category incomparable.
_UNAVAILABLE_KEY = {"interfaces": "assignments"}
def compare(per_host: dict[str, HostNetwork]) -> list[dict]:
"""per_host: host name -> its network. Returns one row per object that is missing on a node or differs."""
names = sorted(per_host)
sigs = {n: signatures(per_host[n]) for n in names}
rows: list[dict] = []
for category in CATEGORIES:
skip_key = _UNAVAILABLE_KEY.get(category, category)
if any(skip_key in per_host[n].unavailable for n in names):
continue
keys = sorted({k for n in names for k in sigs[n][category]})
for key in keys:
present = {n: sigs[n][category][key] for n in names if key in sigs[n][category]}
if len(present) < len(names):
rows.append({"category": category, "key": key, "status": "missing",
"present_on": sorted(present), "missing_from": sorted(set(names) - set(present)),
"signatures": present})
elif len(set(present.values())) > 1:
rows.append({"category": category, "key": key, "status": "different",
"present_on": names, "missing_from": [], "signatures": present})
return rows
async def compute_cluster(hosts: list[OpnsenseHost]) -> tuple[list[dict], list[str], list[str]]:
"""Returns (differences, compared host names, unreachable host names)."""
per_host: dict[str, HostNetwork] = {}
unreachable: list[str] = []
for host in hosts:
try:
per_host[host.name] = await network_service.get_host_network(host)
except OpnsenseAPIError:
unreachable.append(host.name)
if len(per_host) < 2:
return [], sorted(per_host), sorted(unreachable)
return compare(per_host), sorted(per_host), sorted(unreachable)
+250
View File
@@ -0,0 +1,250 @@
"""Read-only network inventory of one firewall: interfaces, VLANs, virtual
IPs (CARP), gateways and static routes, straight from the firewall.
Also works out which interface the firewall answers OPNexus on. Every later
write feature in this area (VLAN/IP/gateway changes) must refuse or escalate
changes to that interface — an apply there can cut the very connection OPNexus
would need to undo it.
Shapes verified live on OPNsense 26.7.1 and 26.7.6 (see opnsense_client).
"""
import asyncio
import ipaddress
import socket
from urllib.parse import urlparse
from app.models.host import OpnsenseHost
from app.schemas.network import (
HostNetwork,
NetAddress,
NetGateway,
NetDhcpHost,
NetDnsHost,
NetInterface,
NetKeaReservation,
NetKeaSubnet,
NetRoute,
NetVip,
NetVlan,
)
from app.services import opnsense_client
from app.services.opnsense_client import OpnsenseAPIError
def _rows(data) -> list[dict]:
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict)]
def _flag(value) -> bool:
return value in ("1", 1, True)
def _text(value) -> str:
return "" if value is None else str(value)
def _opt(value) -> str | None:
"""OPNsense is inconsistent about number vs. string (a gateway priority came back as 254, not "254")."""
return None if value in (None, "") else str(value)
async def management_addresses(host: OpnsenseHost) -> set:
"""IP addresses OPNexus uses to reach this firewall (literal or resolved)."""
hostname = urlparse(host.base_url).hostname
if not hostname:
return set()
try:
return {ipaddress.ip_address(hostname)}
except ValueError:
pass
try:
infos = await asyncio.get_running_loop().getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except OSError:
return set()
found = set()
for info in infos:
try:
found.add(ipaddress.ip_address(info[4][0].split("%")[0]))
except ValueError:
continue
return found
def _addresses(row: dict) -> list[NetAddress]:
result = []
for key in ("ipv4", "ipv6"):
for entry in row.get(key) or []:
if isinstance(entry, dict) and entry.get("ipaddr"):
result.append(NetAddress(
address=str(entry["ipaddr"]),
vhid=_opt(entry.get("vhid")),
carp_status=_opt(entry.get("status")),
))
return result
def _address_ip(address: str):
try:
return ipaddress.ip_interface(address).ip
except ValueError:
return None
def _interface(row: dict, assignments: dict[str, dict], management: set) -> NetInterface:
identifier = row.get("identifier") or None
assigned = assignments.get(identifier or "") or {}
addresses = _addresses(row)
vlan = row.get("vlan") or {}
return NetInterface(
device=_text(row.get("device")),
identifier=identifier,
description=_text(row.get("description")),
status=_text(row.get("status")),
enabled=_flag(row.get("enabled", True)) if "enabled" in row else True,
is_physical=bool(row.get("is_physical")),
mtu=_opt(row.get("mtu")),
mac=_opt(row.get("macaddr")),
media=_opt(row.get("media")),
vlan_tag=_opt((vlan.get("tag") if isinstance(vlan, dict) else None) or row.get("vlan_tag")),
vlan_parent=_opt(vlan.get("parent")) if isinstance(vlan, dict) else None,
ipv4_type=assigned.get("%type4") or None,
ipv6_type=assigned.get("%type6") or None,
addresses=addresses,
pending_action=assigned.get("pending_action") or None,
assigned=bool(identifier and identifier in assignments),
is_management=any(_address_ip(a.address) in management for a in addresses if not a.vhid),
)
def _vlan(row: dict) -> NetVlan:
return NetVlan(
uuid=_text(row.get("uuid")),
parent=_text(row.get("if")),
tag=_text(row.get("tag")),
pcp=_opt(row.get("pcp")),
description=_text(row.get("descr")),
device=(row.get("vlanif") or "").split(" ")[0] or None,
)
def _vip(row: dict) -> NetVip:
# Deliberately picks fields by name: the row also carries the CARP password.
return NetVip(
uuid=_text(row.get("uuid")),
interface=_text(row.get("interface")),
interface_label=_text(row.get("%interface") or row.get("interface")),
mode=_text(row.get("mode")),
address=_text(row.get("address") or row.get("subnet")),
vhid=_opt(row.get("vhid")),
advbase=_opt(row.get("advbase")),
advskew=_opt(row.get("advskew")),
description=_text(row.get("descr")),
)
def _gateway(row: dict) -> NetGateway:
return NetGateway(
uuid=_text(row.get("uuid")),
name=_text(row.get("name")),
description=_text(row.get("descr")),
interface=_text(row.get("interface")),
interface_label=_text(row.get("interface_descr") or row.get("interface")),
ip_protocol=_text(row.get("ipprotocol")),
address=_text(row.get("gateway")),
is_default=bool(row.get("defaultgw")),
disabled=bool(row.get("disabled")),
priority=_opt(row.get("priority")),
status=_text(row.get("status")),
)
def _route(row: dict) -> NetRoute:
return NetRoute(
uuid=_text(row.get("uuid")),
network=_text(row.get("network")),
gateway=_text(row.get("gateway")),
description=_text(row.get("descr")),
# list rows carry "enabled" ("1"/"0"); older shapes a "disabled" flag
disabled=_flag(row.get("disabled")) or row.get("enabled") in ("0", 0, False),
)
def _dns_host(row: dict) -> NetDnsHost:
return NetDnsHost(
uuid=_text(row.get("uuid")), hostname=_text(row.get("hostname")), domain=_text(row.get("domain")),
record_type=_text(row.get("rr")), server=_text(row.get("server")), enabled=_flag(row.get("enabled")),
description=_text(row.get("description")),
)
def _dhcp_host(row: dict) -> NetDhcpHost:
return NetDhcpHost(
uuid=_text(row.get("uuid")), host=_text(row.get("host")), domain=_text(row.get("domain")),
ip=_text(row.get("ip")), hwaddr=_text(row.get("hwaddr")), lease_time=_text(row.get("lease_time")),
description=_text(row.get("descr")),
)
def _kea_subnet(row: dict) -> NetKeaSubnet:
# Picked by name on purpose: the row also carries the DDNS key secret.
return NetKeaSubnet(
uuid=_text(row.get("uuid")), subnet=_text(row.get("subnet")), pools=_text(row.get("pools")),
routers=_text(row.get("option_data.routers")), dns_servers=_text(row.get("option_data.domain_name_servers")),
description=_text(row.get("description")),
)
def _kea_reservation(row: dict) -> NetKeaReservation:
return NetKeaReservation(
uuid=_text(row.get("uuid")), subnet=_text(row.get("%subnet") or row.get("subnet")),
ip_address=_text(row.get("ip_address")), hw_address=_text(row.get("hw_address")),
hostname=_text(row.get("hostname")), description=_text(row.get("description")),
)
async def _optional(name: str, call, unavailable: list[str]) -> list[dict]:
try:
return _rows(await call)
except OpnsenseAPIError:
unavailable.append(name)
return []
async def get_host_network(host: OpnsenseHost) -> HostNetwork:
"""Raises OpnsenseAPIError when the interface list itself is unavailable
(host down); every other section degrades to `unavailable` instead."""
info_rows = _rows(await opnsense_client.get_interfaces_info(host))
unavailable: list[str] = []
(assignment_rows, vlan_rows, vip_rows, gateway_rows, route_rows,
dns_rows, dhcp_rows, kea_subnet_rows, kea_reservation_rows) = await asyncio.gather(
_optional("assignments", opnsense_client.search_interface_assignments(host), unavailable),
_optional("vlans", opnsense_client.search_vlans(host), unavailable),
_optional("vips", opnsense_client.search_vips(host), unavailable),
_optional("gateways", opnsense_client.search_gateways(host), unavailable),
_optional("routes", opnsense_client.search_static_routes(host), unavailable),
_optional("dns_hosts", opnsense_client.search_dns_hosts(host), unavailable),
_optional("dhcp_hosts", opnsense_client.search_dhcp_hosts(host), unavailable),
_optional("kea_subnets", opnsense_client.search_kea_subnets(host), unavailable),
_optional("kea_reservations", opnsense_client.search_kea_reservations(host), unavailable),
)
management = await management_addresses(host)
assignments = {_text(r.get("identifier")): r for r in assignment_rows if r.get("identifier")}
interfaces = [_interface(r, assignments, management) for r in info_rows]
management_device = next((i.device for i in interfaces if i.is_management), None)
return HostNetwork(
host_id=str(host.id),
host_name=host.name,
interfaces=interfaces,
vlans=[_vlan(r) for r in vlan_rows],
vips=[_vip(r) for r in vip_rows],
gateways=[_gateway(r) for r in gateway_rows],
routes=[_route(r) for r in route_rows],
dns_hosts=[_dns_host(r) for r in dns_rows if not r.get("isAlias")],
dhcp_hosts=[_dhcp_host(r) for r in dhcp_rows],
kea_subnets=[_kea_subnet(r) for r in kea_subnet_rows],
kea_reservations=[_kea_reservation(r) for r in kea_reservation_rows],
management_device=management_device,
can_configure_interfaces=any("type4" in r for r in assignment_rows),
unavailable=sorted(unavailable),
)
+82 -7
View File
@@ -3,6 +3,7 @@ whatever's currently bad. Pure detection — no delivery, no state tracking
here (see notification_dedup.py for the open/resolved diffing that turns
these snapshots into actual notifications)."""
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,7 +15,9 @@ from app.models.one_to_one_deployment import OneToOneDeployment
from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.port_forward_deployment import PortForwardDeployment
from app.models.rule_deployment import RuleDeployment
from app.services import nat_drift_service, rule_drift_service
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import nat_drift_service, network_drift_service, rule_drift_service
from app.services.notification_dedup import Problems
from app.services.host_status_service import get_statuses
from app.services.live_certificates_service import get_live_certificates
from app.services.opnsense_client import OpnsenseAPIError
@@ -53,13 +56,15 @@ async def check_host_offline(db: AsyncSession) -> dict[str, dict]:
async def check_certificates(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
problems: dict[str, dict] = {}
problems = Problems()
for host in hosts:
try:
certs = await get_live_certificates(host)
except OpnsenseAPIError:
# Unreachable — already reported by check_host_offline, no need
# to double-report it here as a certificate problem.
# to double-report it here as a certificate problem. Its open
# certificate problems stay as they are (not evaluable now).
problems.preserve_prefixes.add(f"{host.id}:")
continue
for cert in certs:
if cert.status not in ("expiring_soon", "expired"):
@@ -83,12 +88,18 @@ async def check_rule_drift(db: AsyncSession) -> dict[str, dict]:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems: dict[str, dict] = {}
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
names_by_id = {h.id: h.name for h in cluster_hosts}
for row in await rule_drift_service.compute_cluster_drift(cluster_hosts):
rows, reachable = await rule_drift_service.compute_cluster_drift_with_reachable(cluster_hosts)
if len(reachable) < 2:
# A member is down (reboot/update): drift cannot be judged, so keep
# what is open instead of resolving it and re-opening it later.
problems.preserve_prefixes.add(f"{cluster_name}:")
continue
for row in rows:
key = f"{cluster_name}:{row['fingerprint']}"
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
missing_names = sorted(
@@ -113,13 +124,17 @@ async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems: dict[str, dict] = {}
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
names_by_id = {h.id: h.name for h in cluster_hosts}
for nat_type in _NAT_TYPE_LABELS:
for row in await nat_drift_service.compute_cluster_drift(cluster_hosts, nat_type):
rows, reachable = await nat_drift_service.compute_cluster_drift_with_reachable(cluster_hosts, nat_type)
if len(reachable) < 2:
problems.preserve_prefixes.add(f"{cluster_name}:{nat_type}:")
continue
for row in rows:
key = f"{cluster_name}:{nat_type}:{row['fingerprint']}"
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
missing_names = sorted(
@@ -135,6 +150,40 @@ async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
return problems
_NETWORK_LABELS = {
"interfaces": "Schnittstelle", "vlans": "VLAN", "vips": "Virtuelle IP", "gateways": "Gateway", "routes": "Route",
"dns_hosts": "DNS-Eintrag", "dhcp_hosts": "DHCP-Host", "kea_subnets": "DHCP-Subnetz", "kea_reservations": "DHCP-Reservierung",
}
async def check_network_drift(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
differences, compared, _unreachable = await network_drift_service.compute_cluster(cluster_hosts)
if len(compared) < 2:
# Not evaluable right now (a node is down, which check_host_offline reports): keep what is open.
problems.preserve_prefixes.add(f"{cluster_name}:")
continue
for row in differences:
key = f"{cluster_name}:{row['category']}:{row['key']}"
problems[key] = {
"cluster_name": cluster_name,
"object": f"{_NETWORK_LABELS.get(row['category'], row['category'])} {row['key']}",
"status": "fehlt auf einem Knoten" if row["status"] == "missing" else "Einstellungen weichen ab",
"present_on": row["present_on"],
"missing_from": row["missing_from"],
}
return problems
async def check_revert_failed(db: AsyncSession) -> dict[str, dict]:
problems: dict[str, dict] = {}
for deployment_type, model, id_attr in _DEPLOYMENT_MODELS:
@@ -215,3 +264,29 @@ async def check_carp_auto_revert_failed(db: AsyncSession) -> dict[str, dict]:
"message": window.message,
}
return problems
# A failed scheduled run never turns "fixed" by itself, so it is reported as an event:
# open for this long after its planned time, then closed without a "resolved" message
# (see notification_service._NO_RESOLVED_NOTICE).
SCHEDULED_FAILURE_WINDOW = timedelta(days=3)
async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict]:
since = datetime.now(timezone.utc) - SCHEDULED_FAILURE_WINDOW
result = await db.execute(
select(ScheduledMaintenance).where(
ScheduledMaintenance.status == "failed", ScheduledMaintenance.run_at >= since
)
)
problems: dict[str, dict] = {}
for sched in result.scalars().all():
problems[str(sched.id)] = {
"schedule_id": str(sched.id),
"host_id": str(sched.host_id),
"host_name": sched.host_name,
"action": sched.action,
"run_at": sched.run_at.isoformat(),
"message": (sched.result or {}).get("error"),
}
return problems
+13 -1
View File
@@ -13,6 +13,17 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
class Problems(dict):
"""The result of a check ({dedup_key: context}) plus the key prefixes the
check could NOT evaluate this round (e.g. a cluster member is offline, so
drift is not computable). Open problems under such a prefix are left alone
instead of being resolved and re-opened on the next round."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.preserve_prefixes: set[str] = set()
async def diff_and_update(
db: AsyncSession, problem_type: str, current: dict[str, dict]
) -> tuple[list[NotificationProblem], list[NotificationProblem]]:
@@ -74,8 +85,9 @@ async def diff_and_update(
existing.last_seen_at = now
existing.context = context
preserve = tuple(getattr(current, "preserve_prefixes", ()))
for key, row in open_rows.items():
if key not in current:
if key not in current and not (preserve and key.startswith(preserve)):
row.status = "resolved"
row.resolved_at = now
resolved.append(row)
@@ -31,7 +31,9 @@ _LABELS = {
"maintenance_unknown": "Wartungsergebnis unklar",
"carp_split_brain": "CARP-Zustand kritisch",
"nat_drift": "NAT-Drift im Cluster",
"network_drift": "Netzwerk-Abweichung im Cluster",
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
}
@@ -28,14 +28,23 @@ _CHECKS = [
("maintenance_unknown", "maintenance_unknown_enabled", notification_checks.check_maintenance_unknown),
("carp_split_brain", "carp_health_enabled", notification_checks.check_carp_health),
("nat_drift", "nat_drift_enabled", notification_checks.check_nat_drift),
("network_drift", "network_drift_enabled", notification_checks.check_network_drift),
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
]
# Event-like problems: when they drop out of the window nothing was "fixed", so no
# "behoben" message goes out for them.
_NO_RESOLVED_NOTICE = {"scheduled_maintenance_failed"}
async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None:
"""Best-effort, not an audit trail: attempted once per transition and
never retried, even on failure — avoids retry-storming a broken webhook
URL or SMTP relay every poll cycle."""
if transition == "resolved" and problem.problem_type in _NO_RESOLVED_NOTICE:
problem.resolved_notified_at = _now()
return
if settings_row.webhook_enabled:
try:
await notification_delivery.send_webhook(settings_row, problem, transition)
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
"smtp_from_address", "smtp_to_addresses",
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
"carp_auto_revert_failed_enabled",
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled",
"poll_interval_seconds",
]
for field in simple_fields:
@@ -140,7 +140,7 @@ async def undeploy_one_to_one(db: AsyncSession, rule: OneToOneRule) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+21 -3
View File
@@ -15,7 +15,8 @@ citations):
Instances.Instance: vpnid (VPNIdField, Required, MinimumValue 1, NO
<Default> — never sent by this app; OPNsense's own UI never prompts for
one either, so its field-type presumably auto-assigns one the same way
on addAction — NOT independently verified, flagged rather than guessed),
on addAction — VERIFIED LIVE 2026-10-09 on 26.7.5: it is NOT auto-assigned, add() fails
with "instance.vpnid: A value is required." — see _next_vpnid),
enabled (BooleanField, Default 1), dev_type (OptionValues
tun/tap/ovpn(DCO), Default "tun" — never sent, relies on the model
default exactly like IPsec relies on Child.mode defaulting to "tunnel"),
@@ -27,7 +28,8 @@ citations):
section below, this is the key finding that reshaped v1), server
(NetworkField, NetMaskRequired Y — server-role tunnel network), cert /
ca (CertificateField, i.e. ModelRelationField into OPNsense's own
Trust\\Cert store), tls_key (ModelRelationField -> this SAME model's
Trust\\Cert store; values are the Trust-Store REFIDs, not row UUIDs —
verified live 2026-10-09), tls_key (ModelRelationField -> this SAME model's
StaticKeys.StaticKey, display "description,mode"), auth/data-ciphers
(cipher suite OptionFields — v1 always omits these, relying on
OPNsense's own defaults, exactly like IPsec's proposals default to
@@ -238,6 +240,18 @@ def build_payloads(instance: OpenVpnInstance, host_id: uuid.UUID) -> dict:
}
async def _next_vpnid(host: OpnsenseHost) -> str:
data = await opnsense_client.search_openvpn_instances(host)
rows = data.get("rows", []) if isinstance(data, dict) else []
used = []
for row in rows:
try:
used.append(int(row.get("vpnid")))
except (TypeError, ValueError):
continue
return str(max(used, default=0) + 1)
async def _write_config(host: OpnsenseHost, payloads: dict, deployment: OpenVpnInstanceDeployment) -> None:
"""Writes the StaticKey (if enabled) then the Instance, in that
dependency order, persisting each UUID onto `deployment` as soon as
@@ -270,6 +284,10 @@ async def _write_config(host: OpnsenseHost, payloads: dict, deployment: OpenVpnI
if deployment.opnsense_instance_uuid:
await opnsense_client.set_openvpn_instance(host, deployment.opnsense_instance_uuid, instance_wire)
else:
# vpnid is Required and the firewall does NOT assign it (verified live
# on 26.7: "instance.vpnid: A value is required."), so pick the next
# free one. It stays untouched on later set() calls.
instance_wire["vpnid"] = await _next_vpnid(host)
result = await opnsense_client.add_openvpn_instance(host, instance_wire)
deployment.opnsense_instance_uuid = result.get("uuid") if isinstance(result, dict) else None
if not deployment.opnsense_instance_uuid:
@@ -370,7 +388,7 @@ async def undeploy_instance(db: AsyncSession, instance: OpenVpnInstance) -> None
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+305 -22
View File
@@ -557,6 +557,19 @@ async def del_ipsec_psk(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/ipsec/preSharedKeys/delItem/{uuid_}"), "IPsec Pre-Shared-Key entfernen", {"deleted", "not found"})
async def ensure_ipsec_enabled(host: OpnsenseHost) -> bool:
"""Switch the service-wide IPsec flag on (`ipsec/connections/isEnabled` /
`toggle/1`, ConnectionsController — it is NOT part of the service
controller). Without it the connections are stored but no daemon runs
(live 26.7.6: is_enabled=false, service status "disabled", yet
reconfigure answered ok). Returns True when it had to be switched on."""
data = await _request(host, "GET", "/ipsec/connections/is_enabled")
if isinstance(data, dict) and data.get("enabled") is True:
return False
_mutation_result(await _request(host, "POST", "/ipsec/connections/toggle/1"), "IPsec aktivieren", {"ok"})
return True
async def reconfigure_ipsec(host: OpnsenseHost) -> dict:
"""`ipsec/service/reconfigure` (ServiceController::reconfigureAction,
inherited from ApiMutableServiceControllerBase) — soft-reloads a
@@ -756,19 +769,12 @@ async def reconfigure_openvpn(host: OpnsenseHost) -> dict:
# ServiceController's own `$internalServiceEnabled = 'enabled'` /
# `$internalServiceClass = General::class` tie together for the
# framework's generic service-status plumbing. NOT INDEPENDENTLY
# VERIFIED: whether `wireguard configure` (the configd action
# reconfigureAction shells out to) actually brings up any wg-quick
# interface end-to-end while General.enabled=0 — reconfigureAction's
# OWN PHP body never reads $internalServiceEnabled at all (it always
# runs unconditionally), so this depends on configd's own action
# script content, which is out of the controller/model source layer
# this app's verification standard covers (same boundary IPsec/
# OpenVPN draw). Flagged as a real, source-confirmed structural
# difference from IPsec/OpenVPN (neither of which have an equivalent
# separate master switch) rather than silently ignored — see README:
# operators should confirm "VPN > WireGuard > General" is enabled at
# least once via the OPNsense UI before relying on OPNexus-pushed
# tunnels.
# VERIFIED LIVE (26.7.5, 2026-10-09): with General.enabled=0 a deploy
# writes Server+Peer correctly but NO wg interface comes up and
# `wireguard/service/show` stays empty, while OPNexus used to report
# "success". Enabling it (wireguard/general/set + reconfigure) brings
# wg0 up. wireguard_deploy_service therefore switches it on before the
# reconfigure (ensure_wireguard_enabled below); undeploy never touches it.
#
# Field names verified against Server.xml/Client.xml (fetched directly,
# not summarized) — see wireguard_deploy_service.py for the exact
@@ -821,6 +827,21 @@ async def del_wireguard_peer(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/wireguard/client/delClient/{uuid_}"), "WireGuard-Peer entfernen", {"deleted", "not found"})
async def ensure_wireguard_enabled(host: OpnsenseHost) -> bool:
"""Turn the service-wide WireGuard switch (General.enabled, default 0) on
if it is off. Returns True when it had to be switched on."""
data = await _request(host, "GET", "/wireguard/general/get")
general = data.get("general", {}) if isinstance(data, dict) else {}
if str(general.get("enabled", "0")) == "1":
return False
_mutation_result(
await _request(host, "POST", "/wireguard/general/set", json={"general": {"enabled": "1"}}),
"WireGuard-Dienst aktivieren",
{"saved"},
)
return True
async def reconfigure_wireguard(host: OpnsenseHost) -> dict:
"""`wireguard/service/reconfigure` (ServiceController::reconfigureAction)
— runs interface registration + template reload + `wireguard configure`.
@@ -838,6 +859,10 @@ async def reconfigure_wireguard(host: OpnsenseHost) -> dict:
# CertController.php directly) — see managed_certificate_service.py's
# module docstring for the full field-mapping writeup.
#
# URL SEGMENTS ARE NOT PHP METHOD NAMES: the router strips the `Action`
# suffix, so addAction() is POST /trust/cert/add (the `...Action` URL is a
# 404 — found live 2026-10-09 on 26.7.5, the respx mocks had hidden it).
#
# CertController extends ApiMutableModelControllerBase exactly like
# FilterController/ConnectionsController/etc: searchAction (already used
# via search_certificates() above) / getAction / addAction / setAction /
@@ -866,11 +891,11 @@ def _trust_cert_result(data: Any, operation: str) -> dict:
async def add_trust_cert(host: OpnsenseHost, payload: dict) -> dict:
return _trust_cert_result(await _request(host, "POST", "/trust/cert/addAction", json={"cert": payload}), "Zertifikat anlegen")
return _trust_cert_result(await _request(host, "POST", "/trust/cert/add", json={"cert": payload}), "Zertifikat anlegen")
async def set_trust_cert(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
return _trust_cert_result(await _request(host, "POST", f"/trust/cert/setAction/{uuid_}", json={"cert": payload}), "Zertifikat ändern")
return _trust_cert_result(await _request(host, "POST", f"/trust/cert/set/{uuid_}", json={"cert": payload}), "Zertifikat ändern")
async def del_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
@@ -879,7 +904,7 @@ async def del_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
# matching every other del* call in this module (add_alias_item's
# del_alias_item sibling etc.), so this reuses the same
# {"deleted", "not found"} results set they all share.
return _mutation_result(await _request(host, "POST", f"/trust/cert/delAction/{uuid_}"), "Zertifikat entfernen", {"deleted", "not found"})
return _mutation_result(await _request(host, "POST", f"/trust/cert/del/{uuid_}"), "Zertifikat entfernen", {"deleted", "not found"})
async def get_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
@@ -940,15 +965,15 @@ def _acme_result(data: Any, operation: str) -> dict:
async def add_acme_account(host: OpnsenseHost, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", "/acmeclient/accounts/addAction", json={"account": payload}), "ACME-Account anlegen")
return _acme_result(await _request(host, "POST", "/acmeclient/accounts/add", json={"account": payload}), "ACME-Account anlegen")
async def update_acme_account(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", f"/acmeclient/accounts/updateAction/{uuid_}", json={"account": payload}), "ACME-Account ändern")
return _acme_result(await _request(host, "POST", f"/acmeclient/accounts/update/{uuid_}", json={"account": payload}), "ACME-Account ändern")
async def del_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/acmeclient/accounts/delAction/{uuid_}"), "ACME-Account entfernen", {"deleted", "not found"})
return _mutation_result(await _request(host, "POST", f"/acmeclient/accounts/del/{uuid_}"), "ACME-Account entfernen", {"deleted", "not found"})
async def get_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
@@ -956,15 +981,15 @@ async def get_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
async def add_acme_certificate(host: OpnsenseHost, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", "/acmeclient/certificates/addAction", json={"certificate": payload}), "ACME-Zertifikatsanfrage anlegen")
return _acme_result(await _request(host, "POST", "/acmeclient/certificates/add", json={"certificate": payload}), "ACME-Zertifikatsanfrage anlegen")
async def update_acme_certificate(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", f"/acmeclient/certificates/updateAction/{uuid_}", json={"certificate": payload}), "ACME-Zertifikatsanfrage ändern")
return _acme_result(await _request(host, "POST", f"/acmeclient/certificates/update/{uuid_}", json={"certificate": payload}), "ACME-Zertifikatsanfrage ändern")
async def del_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/acmeclient/certificates/delAction/{uuid_}"), "ACME-Zertifikatsanfrage entfernen", {"deleted", "not found"})
return _mutation_result(await _request(host, "POST", f"/acmeclient/certificates/del/{uuid_}"), "ACME-Zertifikatsanfrage entfernen", {"deleted", "not found"})
async def get_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
@@ -1002,3 +1027,261 @@ async def sign_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
against anything but a respx mock / fully-mocked write path in
tests/verification."""
return _acme_action_result(await _request(host, "POST", f"/acmeclient/certificates/sign/{uuid_}"), "ACME-Zertifikat ausstellen")
# ── Network inventory (read-only) ────────────────────────────────────────
# Interfaces\Api\{Overview,Assignment,VlanSettings,VipSettings}Controller,
# Routing\Api\SettingsController and Routes\Api\RoutesController, verified
# against core source (github.com/opnsense/core, master) AND live with real
# read-only GETs on OPNsense 26.7.1/26.7.6 (2026-10-09). Shapes: the search*
# calls return {"rows": [...]} (flat dicts with "%"-prefixed display
# counterparts); interfacesInfo rows carry device/identifier/status/ipv4[].
# Caution: vip_settings rows contain the CARP password in plain text — callers
# must drop it (see network_service).
async def get_interfaces_info(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/overview/interfacesInfo", params={"rowCount": -1})
async def search_interface_assignments(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/assignment/searchItem", params={"rowCount": -1})
async def search_vlans(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/vlan_settings/searchItem", params={"rowCount": -1})
async def search_vips(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/interfaces/vip_settings/searchItem", params={"rowCount": -1})
async def search_gateways(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/routing/settings/searchGateway", params={"rowCount": -1})
async def search_static_routes(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/routes/routes/searchroute", params={"rowCount": -1})
# ── Network objects (write) ──────────────────────────────────────────────
# One table for the four config objects handled by network_change_service.
# Sources (core master, read 2026-10-09): Interfaces/Api/VlanSettingsController,
# VipSettingsController, Routing/Api/SettingsController (gateways) and
# Routes/Api/RoutesController. Each add/set takes {<body_key>: {fields}}; set/del
# address the item by UUID; nothing takes effect before the object's reconfigure
# call, which answers {"status": "ok"}. The VIP form posts address+prefix as ONE
# field called "network".
# The interface assignment (Interfaces/Api/AssignmentController, model NetworkInterface) is two-step:
# add/set/del only stage the change (a todo file on the firewall); reconfigure ("interface apply") makes
# it real. Its items are addressed by the interface identifier (wan, lan, opt1), not a UUID, and
# deleting one makes OPNsense itself drop every rule/NAT entry that references it.
NETWORK_ITEM_APIS = {
"vlan": {"body_key": "vlan", "base": "/interfaces/vlan_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem",
"reconfigure": "/interfaces/vlan_settings/reconfigure"},
"vip": {"body_key": "vip", "base": "/interfaces/vip_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem",
"reconfigure": "/interfaces/vip_settings/reconfigure"},
"gateway": {"body_key": "gateway_item", "base": "/routing/settings", "add": "addGateway", "set": "setGateway",
"del": "delGateway", "get": "getGateway", "search": "searchGateway",
"reconfigure": "/routing/settings/reconfigure"},
"route": {"body_key": "route", "base": "/routes/routes", "add": "addroute", "set": "setroute",
"del": "delroute", "get": "getroute", "search": "searchroute",
"reconfigure": "/routes/routes/reconfigure"},
"interface": {"body_key": "interface", "base": "/interfaces/assignment", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem",
"reconfigure": "/interfaces/assignment/reconfigure"},
# DNS / DHCP (Unbound/Api/SettingsController, Dnsmasq/Api/SettingsController, Kea/Api/Dhcpv4Controller;
# reconfigure is the service controller's inherited action). Read 2026-10-09.
"dns_host": {"body_key": "host", "base": "/unbound/settings", "add": "addHostOverride", "set": "setHostOverride",
"del": "delHostOverride", "get": "getHostOverride", "search": "searchHostOverride",
"reconfigure": "/unbound/service/reconfigure"},
"dhcp_host": {"body_key": "host", "base": "/dnsmasq/settings", "add": "addHost", "set": "setHost",
"del": "delHost", "get": "getHost", "search": "searchHost",
"reconfigure": "/dnsmasq/service/reconfigure"},
"kea_subnet": {"body_key": "subnet4", "base": "/kea/dhcpv4", "add": "addSubnet", "set": "setSubnet",
"del": "delSubnet", "get": "getSubnet", "search": "searchSubnet",
"reconfigure": "/kea/service/reconfigure"},
# System / interface devices / DNS+DHCP extras (same controllers pattern; read 2026-10-09).
"tunable": {"body_key": "sysctl", "base": "/core/tunables", "add": "addItem", "set": "setItem", "del": "delItem",
"get": "getItem", "search": "searchItem", "reconfigure": "/core/tunables/reconfigure"},
"cron_job": {"body_key": "job", "base": "/cron/settings", "add": "addJob", "set": "setJob", "del": "delJob",
"get": "getJob", "search": "searchJobs", "reconfigure": "/cron/service/reconfigure"},
"syslog_destination": {"body_key": "destination", "base": "/syslog/settings", "add": "addDestination",
"set": "setDestination", "del": "delDestination", "get": "getDestination",
"search": "searchDestinations", "reconfigure": "/syslog/service/reconfigure"},
"gateway_group": {"body_key": "gateway_group", "base": "/routing/group_settings", "add": "add", "set": "set",
"del": "del", "get": "get", "search": "search", "reconfigure": "/routing/group_settings/reconfigure"},
"lagg": {"body_key": "lagg", "base": "/interfaces/lagg_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/lagg_settings/reconfigure"},
"bridge": {"body_key": "bridge", "base": "/interfaces/bridge_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/bridge_settings/reconfigure"},
"loopback": {"body_key": "loopback", "base": "/interfaces/loopback_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/loopback_settings/reconfigure"},
"gre": {"body_key": "gre", "base": "/interfaces/gre_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gre_settings/reconfigure"},
"vxlan": {"body_key": "vxlan", "base": "/interfaces/vxlan_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/vxlan_settings/reconfigure"},
"gif": {"body_key": "gif", "base": "/interfaces/gif_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gif_settings/reconfigure"},
"dns_forward": {"body_key": "dot", "base": "/unbound/settings", "add": "addForward", "set": "setForward",
"del": "delForward", "get": "getForward", "search": "searchForward",
"reconfigure": "/unbound/service/reconfigure"},
"dhcp_range": {"body_key": "range", "base": "/dnsmasq/settings", "add": "addRange", "set": "setRange",
"del": "delRange", "get": "getRange", "search": "searchRange",
"reconfigure": "/dnsmasq/service/reconfigure"},
"kea_reservation": {"body_key": "reservation", "base": "/kea/dhcpv4", "add": "addReservation",
"set": "setReservation", "del": "delReservation", "get": "getReservation",
"search": "searchReservation", "reconfigure": "/kea/service/reconfigure"},
}
def _nest(fields: dict) -> dict:
"""{"option_data.routers": "x"} -> {"option_data": {"routers": "x"}} (the wire form of Kea's option block)."""
out: dict = {}
for key, value in fields.items():
head, _, tail = key.partition(".")
if tail:
out.setdefault(head, {})[tail] = value
else:
out[key] = value
return out
async def get_network_item(host: OpnsenseHost, kind: str, uuid: str | None) -> dict:
api = NETWORK_ITEM_APIS[kind]
path = f"{api['base']}/{api['get']}" + (f"/{uuid}" if uuid else "")
data = await _request(host, "GET", path)
item = data.get(api["body_key"]) if isinstance(data, dict) else None
return item if isinstance(item, dict) else {}
async def write_network_item(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> dict:
api = NETWORK_ITEM_APIS[kind]
if action == "add":
return _mutation_result(
await _request(host, "POST", f"{api['base']}/{api['add']}", json={api["body_key"]: _nest(fields)}),
"Objekt anlegen", {"saved"})
if action == "set":
return _mutation_result(
await _request(host, "POST", f"{api['base']}/{api['set']}/{uuid}", json={api["body_key"]: _nest(fields)}),
"Objekt ändern", {"saved"})
return _mutation_result(
await _request(host, "POST", f"{api['base']}/{api['del']}/{uuid}"), "Objekt entfernen", {"deleted"})
async def reconfigure_network(host: OpnsenseHost, kind: str) -> dict:
return _mutation_result(
await _request(host, "POST", NETWORK_ITEM_APIS[kind]["reconfigure"]), "Änderung anwenden", set())
async def list_network_item_uuids(host: OpnsenseHost, kind: str) -> list[str]:
api = NETWORK_ITEM_APIS[kind]
data = await _request(host, "GET", f"{api['base']}/{api['search']}", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return [str(r.get("uuid")) for r in rows if isinstance(r, dict) and r.get("uuid")]
async def interface_changes_pending(host: OpnsenseHost) -> bool:
"""True while staged interface changes wait for an apply (AssignmentController::pendingAction).
Our own apply would apply those too, so a change must not start on top of them."""
data = await _request(host, "GET", "/interfaces/assignment/pending")
return isinstance(data, dict) and data.get("status") == "pending"
async def _search_all(host: OpnsenseHost, path: str) -> dict:
return await _request(host, "GET", path, params={"rowCount": -1})
async def search_dns_hosts(host: OpnsenseHost) -> dict:
return await _search_all(host, "/unbound/settings/searchHostOverride")
async def search_dhcp_hosts(host: OpnsenseHost) -> dict:
return await _search_all(host, "/dnsmasq/settings/searchHost")
async def search_kea_subnets(host: OpnsenseHost) -> dict:
return await _search_all(host, "/kea/dhcpv4/searchSubnet")
async def search_kea_reservations(host: OpnsenseHost) -> dict:
return await _search_all(host, "/kea/dhcpv4/searchReservation")
async def search_network_objects(host: OpnsenseHost, kind: str) -> list[dict]:
api = NETWORK_ITEM_APIS[kind]
data = await _request(host, "GET", f"{api['base']}/{api['search']}", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict)]
# ── ZFS snapshots (Core/Api/SnapshotsController, read 2026-10-09) ────────
# Not a config object: flat form fields instead of {body_key: {...}}, no reconfigure, and the answers are
# free-form ("OK" strings / {"status": ...}), so callers confirm by listing again.
async def list_snapshots(host: OpnsenseHost) -> list[dict]:
data = await _request(host, "GET", "/core/snapshots/search", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict)]
async def create_snapshot(host: OpnsenseHost, name: str, note: str = "") -> None:
data = await _request(host, "POST", "/core/snapshots/add", json={"name": name, "note": note})
if isinstance(data, dict) and (data.get("validations") or str(data.get("status", "")).lower() == "failed"):
raise OpnsenseAPIError(f"Snapshot anlegen von Firewall nicht bestätigt: {redact_data(data)}")
async def delete_snapshot(host: OpnsenseHost, uuid: str) -> None:
data = await _request(host, "POST", f"/core/snapshots/del/{uuid}")
if isinstance(data, dict) and (data.get("validations") or str(data.get("status", "")).lower() == "failed"):
raise OpnsenseAPIError(f"Snapshot löschen von Firewall nicht bestätigt: {redact_data(data)}")
# ── Diagnostics (Diagnostics/Api/{Ping,Traceroute,Portprobe,DnsDiagnostics}Controller, read + live 2026-10-09) ──
# Traceroute/portprobe/DNS answer synchronously to the same "set" call that validates the form:
# {"result": "ok", "response": {...}} or {"result": "failed", "validations": {"<model>.settings.<field>": "..."}}.
# Ping is a job: set (returns a uuid) -> start -> searchJobs shows live counters -> stop -> remove.
# A probe to a closed/blackholed port takes the firewall 10 s, so these calls need a longer timeout.
DIAGNOSTIC_TIMEOUT = 40.0
async def diagnostic_set(host: OpnsenseHost, path: str, model: str, settings: dict) -> dict:
data = await _request(host, "POST", path, json={model: {"settings": settings}}, timeout=DIAGNOSTIC_TIMEOUT)
if not isinstance(data, dict):
raise OpnsenseAPIError("Diagnose: keine auswertbare Antwort der Firewall")
if data.get("result") != "ok":
raise OpnsenseDiagnosticRejected(data.get("validations") or {})
return data
class OpnsenseDiagnosticRejected(OpnsenseAPIError):
"""The firewall refused the input (its own validation); carries {field: message}."""
def __init__(self, validations: dict):
super().__init__("Eingabe von der Firewall abgelehnt")
self.validations = validations
async def ping_job_start(host: OpnsenseHost, settings: dict) -> str:
data = await diagnostic_set(host, "/diagnostics/ping/set", "ping", settings)
job = data.get("uuid")
if not job:
raise OpnsenseAPIError("Ping: die Firewall hat keine Job-ID geliefert")
started = await _request(host, "POST", f"/diagnostics/ping/start/{job}", timeout=DIAGNOSTIC_TIMEOUT)
if not isinstance(started, dict) or started.get("status") != "ok":
await ping_job_remove(host, job)
raise OpnsenseAPIError("Ping: der Job konnte nicht gestartet werden")
return job
async def ping_job_state(host: OpnsenseHost, job: str) -> dict | None:
data = await _request(host, "GET", "/diagnostics/ping/searchJobs", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return next((r for r in rows if isinstance(r, dict) and r.get("id") == job), None)
async def ping_job_stop(host: OpnsenseHost, job: str) -> None:
await _request(host, "POST", f"/diagnostics/ping/stop/{job}")
async def ping_job_remove(host: OpnsenseHost, job: str) -> None:
await _request(host, "POST", f"/diagnostics/ping/remove/{job}")
@@ -152,7 +152,7 @@ async def undeploy_outbound_nat(db: AsyncSession, rule: OutboundNatRule) -> None
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
@@ -311,7 +311,7 @@ async def undeploy_hierarchy_rule(db: AsyncSession, rule: PolicyHierarchyRule) -
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+1 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import text
from app.models.policy import RuleRevision
from app.services.rule_deploy_service import _build_payload
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled")
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log")
async def policy_lock(db):
@@ -152,7 +152,7 @@ async def undeploy_port_forward(db: AsyncSession, rule: PortForwardRule) -> None
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+19 -4
View File
@@ -25,12 +25,27 @@ _PEM = re.compile(r"-----BEGIN [A-Z0-9 ]+-----.*?(?:-----END [A-Z0-9 ]+-----|$)"
_PAIR = re.compile(r"(?i)\b([\w.\-]*(?:pass|secret|psk|token|private|api_?key|key)[\w.\-]*)\b([\"']?\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&]+)")
def _scrub_json(value: Any, key: str = "") -> Any:
# Keys under which OPNsense lists per-field validation errors
# ({"validations": {"instance.cert": "Please select a valid certificate"}}).
# There the field name says WHICH field failed. "cert", "crt" and "auth" in it
# do not make the message secret, so those three alone stop triggering the
# redaction (otherwise every certificate/auth error from the firewall reads
# "[redacted]"). Real secret names (psk, key, password, ...) still do.
_VALIDATION_KEYS = {"validations", "validation"}
_SECRET_KEY_IN_VALIDATIONS = re.compile(
r"pass|secret|psk|pre_?shared|private|prv|token|api_?key|apikey|credential|\bkey\b|_key|key_|tls_?key",
re.IGNORECASE,
)
def _scrub_json(value: Any, key: str = "", in_validations: bool = False) -> Any:
if isinstance(value, dict):
return {k: _scrub_json(v, str(k)) for k, v in value.items()}
nested = in_validations or key in _VALIDATION_KEYS
return {k: _scrub_json(v, str(k), nested) for k, v in value.items()}
if isinstance(value, list):
return [_scrub_json(v, key) for v in value]
if isinstance(value, str) and key and _SECRET_KEY.search(key):
return [_scrub_json(v, key, in_validations) for v in value]
pattern = _SECRET_KEY_IN_VALIDATIONS if in_validations else _SECRET_KEY
if isinstance(value, str) and key and pattern.search(key):
return _REDACTED
if isinstance(value, str):
return _scrub_text(value)
+158
View File
@@ -0,0 +1,158 @@
"""Takes over a firewall rule that was created by hand on a host, so it can be
edited, versioned and deployed from OPNexus like any other rule.
Adoption itself writes NOTHING to the firewall: it records the rule as it is
right now (read fresh from the host at adoption time, never from what the
browser sent) together with its OPNsense UUID. The first change afterwards goes
through the normal preview → push → confirm/auto-revert flow.
Only rules OPNexus can describe without distortion are adopted. A rule using
something the model cannot express (several interfaces, an inverted source, a
non-quick rule, ...) is refused with the reason instead of being imported in a
way that would silently change its meaning on the next edit. Fields the model
does not know about (gateway, state options, schedules, tags, ...) are left
alone on the firewall: setRule only overwrites the fields it is sent.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment
from app.services import opnsense_client
from app.services.policy_service import revision
from app.services.rule_deploy_service import _build_payload
class RuleNotFoundOnHost(Exception):
pass
class NotAdoptable(Exception):
def __init__(self, reasons: list[str]):
super().__init__(", ".join(reasons))
self.reasons = reasons
def _on(value) -> bool:
return value in ("1", 1, True)
def _clean(value) -> str:
return "" if value is None else str(value).strip()
def reasons_not_adoptable(row: dict, managed_uuids: set[str]) -> list[str]:
"""Machine-readable reasons (the UI translates them); empty = adoptable."""
reasons = []
if row.get("is_automatic"):
reasons.append("automatic")
if _clean(row.get("uuid")) in managed_uuids:
reasons.append("already_managed")
interface = _clean(row.get("interface"))
if not interface or "," in interface:
reasons.append("interface_not_single")
if _on(row.get("interfacenot")):
reasons.append("negated_interface")
if _on(row.get("source_not")):
reasons.append("negated_source")
if _on(row.get("destination_not")):
reasons.append("negated_destination")
if _clean(row.get("received-on")):
reasons.append("received_on")
if _clean(row.get("quick")) != "1":
reasons.append("not_quick")
if _clean(row.get("direction")) not in ("in", "out"):
reasons.append("direction_unsupported")
if _clean(row.get("action")) not in ("pass", "block", "reject"):
reasons.append("action_unsupported")
if _clean(row.get("ipprotocol")) not in ("inet", "inet6"):
reasons.append("ipprotocol_unsupported")
if not _clean(row.get("description")):
reasons.append("no_description")
return reasons
async def _managed_uuids(db: AsyncSession, host: OpnsenseHost) -> set[str]:
rows = await db.scalars(
select(RuleDeployment.opnsense_rule_uuid).where(
RuleDeployment.host_id == host.id, RuleDeployment.opnsense_rule_uuid.is_not(None)
)
)
return set(rows.all())
async def _live_rows(host: OpnsenseHost) -> list[dict]:
data = await opnsense_client.search_rules(host, all_rows=True)
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict) and r.get("uuid")]
async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
managed = await _managed_uuids(db, host)
result = []
for row in await _live_rows(host):
if row.get("is_automatic"):
continue # built-in/system rules never show up here at all
reasons = reasons_not_adoptable(row, managed)
if "already_managed" in reasons:
continue
result.append({
"uuid": _clean(row.get("uuid")),
"description": _clean(row.get("description")),
"interface": _clean(row.get("interface")),
"interface_label": _clean(row.get("%interface")) or _clean(row.get("interface")),
"direction": _clean(row.get("direction")),
"action": _clean(row.get("action")),
"protocol": _clean(row.get("protocol")) or "any",
"source_net": _clean(row.get("source_net")) or "any",
"source_port": _clean(row.get("source_port")),
"destination_net": _clean(row.get("destination_net")) or "any",
"destination_port": _clean(row.get("destination_port")),
"enabled": _on(row.get("enabled")),
"log": _on(row.get("log")),
"adoptable": not reasons,
"reasons": reasons,
})
return result
async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str) -> FirewallRule:
managed = await _managed_uuids(db, host)
row = next((r for r in await _live_rows(host) if _clean(r.get("uuid")) == rule_uuid), None)
if row is None:
raise RuleNotFoundOnHost(rule_uuid)
reasons = reasons_not_adoptable(row, managed)
if reasons:
raise NotAdoptable(reasons)
rule = FirewallRule(
description=_clean(row.get("description")),
interface=_clean(row.get("interface")),
direction=_clean(row.get("direction")),
ipprotocol=_clean(row.get("ipprotocol")),
protocol=_clean(row.get("protocol")) or "any",
action=_clean(row.get("action")),
source_net=_clean(row.get("source_net")) or "any",
source_port=_clean(row.get("source_port")) or None,
destination_net=_clean(row.get("destination_net")) or "any",
destination_port=_clean(row.get("destination_port")) or None,
enabled=_on(row.get("enabled")),
log=_on(row.get("log")),
targets=[host],
groups=[],
)
db.add(rule)
await db.flush()
revision(db, rule, "Von Firewall übernommen")
db.add(RuleDeployment(
rule_id=rule.id,
host_id=host.id,
status="success",
message="Von der Firewall übernommen",
opnsense_rule_uuid=rule_uuid,
last_payload=_build_payload(rule),
previous_payload=None,
))
await db.flush()
return rule
+2 -2
View File
@@ -50,7 +50,7 @@ def _build_payload(rule: FirewallRule) -> dict:
"destination_net": rule.destination_net,
"destination_port": rule.destination_port or "",
"description": rule.description,
"log": "0",
"log": "1" if rule.log else "0",
}
@@ -160,7 +160,7 @@ async def undeploy_rule(db: AsyncSession, rule: FirewallRule) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+5 -1
View File
@@ -38,6 +38,10 @@ async def _live_rules_or_none(host: OpnsenseHost) -> list[LiveRule] | None:
async def compute_cluster_drift(hosts: list[OpnsenseHost]) -> list[dict]:
return (await compute_cluster_drift_with_reachable(hosts))[0]
async def compute_cluster_drift_with_reachable(hosts: list[OpnsenseHost]) -> tuple[list[dict], set]:
"""Returns one row per fingerprint that isn't present on every reachable
host in the cluster: {"fingerprint": str, "sample": LiveRule,
"present_on": set[host_id], "reachable_host_ids": set[host_id]}.
@@ -58,4 +62,4 @@ async def compute_cluster_drift(hosts: list[OpnsenseHost]) -> list[dict]:
for entry in by_fingerprint.values():
entry["reachable_host_ids"] = reachable_ids
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)]
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)], reachable_ids
@@ -6,6 +6,9 @@ action dispatch table) rather than duplicating that logic differently,
since this is the exact same remote action, just fired by a timer instead
of a click.
Updates take a fresh config backup right before firing (a plan for the day after tomorrow
would otherwise always trip over the 24h freshness rule).
v1 scope decision: a blocked or failed scheduled fire is logged and
recorded on the ScheduledMaintenance row's own `result` field (visible in
the UI) but is NOT wired into the NotificationSettings webhook/email
@@ -15,7 +18,7 @@ for this is a bigger change than this feature's own scope. Flagged here,
not silently dropped, in case it's wanted later.
"""
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException
from sqlalchemy import select
@@ -24,12 +27,30 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import audit_service, opnsense_client as client
from app.services.host_maintenance_service import ensure_no_maintenance, inspect_host
from app.services import audit_service, backup_store, opnsense_client as client
from app.services.host_maintenance_service import ACTIVE, ensure_no_maintenance, inspect_carp_switch, inspect_host, peers
from app.services.host_status_service import get_statuses
logger = logging.getLogger(__name__)
_ACTION_FN = {"reboot": client.reboot_host, "update": client.update_firmware, "major_update": client.upgrade_firmware}
# Actions that need a config backup no older than 24h (see host_maintenance_service.inspect_host).
# Unattended, nobody can take it by hand beforehand, so it is taken right before firing.
_BACKUP_BEFORE = {"update", "major_update"}
# ── CARP handover run (opt-in per plan) ─────────────────────────────────────
# MASTER -> maintenance mode -> partner must become MASTER -> action -> wait until the node is
# back -> leave maintenance mode. One step per poll tick, state in the schedule row.
# Rules that keep it safe:
# * the CARP toggle is a toggle: it is only ever sent against freshly read state and never
# replayed after an unclear answer (see routers/maintenance.py carp_execute);
# * until the action was submitted, any problem restores normal operation (nothing happened yet);
# * after that, any problem leaves the node demoted — the partner keeps carrying traffic, which is
# the safe side — and the run ends as failed (=> notification) with an "unknown" operation.
HANDOVER_TIMEOUT = timedelta(minutes=3)
LEAVE_TIMEOUT = timedelta(minutes=5)
JOB_GRACE = timedelta(minutes=5) # no sign of a job after this long: treat a package update as already done
JOB_TIMEOUT = {"reboot": timedelta(minutes=20), "update": timedelta(minutes=60), "major_update": timedelta(minutes=150)}
def _now() -> datetime:
@@ -79,6 +100,16 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
await db.commit()
logger.warning("Geplante Wartung %s blockiert: %s", sched.id, exc.detail)
return
if sched.carp_handover and host.cluster_name:
try:
statuses = await get_statuses(await peers(db, host))
except client.OpnsenseAPIError as exc:
statuses = {}
logger.warning("Geplante Wartung %s: Rolle nicht lesbar: %s", sched.id, exc)
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
await _start_handover(db, sched, host)
return
# Already BACKUP (or role unclear): no handover needed, the normal path below decides.
try:
state = await inspect_host(db, host)
except client.OpnsenseAPIError as exc:
@@ -87,6 +118,18 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
await db.commit()
logger.warning("Geplante Wartung %s: Host nicht erreichbar", sched.id)
return
if sched.action in _BACKUP_BEFORE and not state["blocked"]:
# Read-only on the firewall. Fail closed: no backup, no update. Taken only after the
# other blockers passed, so a plan that is going to fail anyway does not pile up backups.
try:
backup_store.save_snapshot(host.id, await client.download_backup(host))
state = await inspect_host(db, host)
except client.OpnsenseAPIError as exc:
sched.status = "failed"
sched.result = {"error": f"Automatisches Backup vor dem Update fehlgeschlagen: {str(exc)[:300]}"}
await db.commit()
logger.warning("Geplante Wartung %s: Backup vor dem Update fehlgeschlagen", sched.id)
return
if state["actions"][sched.action]:
sched.status = "failed"
sched.result = {"error": " ".join(state["actions"][sched.action])}
@@ -125,3 +168,248 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
host_id=str(host.id), host_name=host.name, entity_id=str(op.id),
message=op.message, details={"scheduled": True, "schedule_id": str(sched.id), "response": result},
)
# ── handover run ─────────────────────────────────────────────────────────────
def _progress(sched: ScheduledMaintenance, **changes) -> dict:
"""JSON columns only notice reassignment, so always hand back a new dict."""
sched.progress = {**(sched.progress or {}), **changes}
return sched.progress
async def _audit(host, sched, op, action, status, message=None, **details):
await audit_service.persist(
kind="host", action=action, status=status, host_id=str(host.id), host_name=host.name,
entity_id=str(op.id) if op else None, message=message,
details={"scheduled": True, "schedule_id": str(sched.id), "handover": True, **details},
)
async def _end(db, sched, host, op, *, ok, message, op_status=None, **result):
sched.status = "succeeded" if ok else "failed"
sched.phase = None
sched.result = {"handover": True, **result} if ok else {"error": message[:900], **result}
if op is not None:
op.status = op_status or ("acknowledged" if ok else "unknown")
op.message = message[:1000]
await db.commit()
await _audit(host, sched, op, sched.action, "completed" if ok else (op_status or "unknown"), message)
if not ok:
logger.warning("Geplante Wartung %s (CARP-Übergabe) beendet: %s", sched.id, message)
async def _toggle(host, expected: str) -> None:
"""One CARP maintenance toggle; raises OpnsenseAPIError for an unclear or wrong-direction answer."""
result = await client.toggle_carp_maintenance(host)
if result.get("action") != expected:
raise client.OpnsenseAPIError("CARP-Antwort passt nicht zur angeforderten Richtung")
async def _start_handover(db: AsyncSession, sched: ScheduledMaintenance, host: OpnsenseHost) -> None:
"""The node is MASTER now. All preconditions are checked while nothing has been changed yet."""
def fail(message: str):
sched.status = "failed"
sched.result = {"error": message[:900]}
return db.commit()
try:
carp = await inspect_carp_switch(db, host)
state = await inspect_host(db, host, skip_role_checks=True)
blocked = list(dict.fromkeys(carp["blocked"] + state["blocked"]))
if carp["action"] != "carp_enter": # MASTER but already in maintenance mode: unclear, do not toggle blindly
blocked.append(f"{host.name} steht bereits im CARP-Wartungsmodus. Rollen prüfen.")
if not blocked and sched.action in _BACKUP_BEFORE:
backup_store.save_snapshot(host.id, await client.download_backup(host))
state = await inspect_host(db, host, skip_role_checks=True)
blocked = list(dict.fromkeys(blocked + state["actions"][sched.action]))
except client.OpnsenseAPIError as exc:
await fail(f"Vorbereitung der CARP-Übergabe fehlgeschlagen, nichts wurde verändert: {str(exc)[:300]}")
return
if blocked:
await fail(" ".join(blocked))
return
now = _now()
op = HostOperation(
host_id=host.id, host_name=host.name, actor=sched.actor, action=sched.action,
expires_at=now, fingerprint="", snapshot=state, status="starting", started_at=now,
)
db.add(op)
sched.status = "running"
await db.commit()
sched.host_operation_id = op.id
await _audit(host, sched, op, "carp_enter", "requested", snapshot=state)
try:
await _toggle(host, "enter_maintenance")
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"CARP-Umschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
f"Rollen von {host.name} und Partner prüfen.")
return
sched.phase, sched.phase_since = "handover_wait", _now()
_progress(sched, version_before=state.get("version"))
op.message = "CARP-Übergabe angefordert, warte auf Bestätigung."
await db.commit()
await _audit(host, sched, op, "carp_enter", "submitted")
async def advance_running(db: AsyncSession) -> int:
"""One step for every handover run in progress. Returns how many are still running."""
rows = list((await db.scalars(
select(ScheduledMaintenance).where(ScheduledMaintenance.status == "running", ScheduledMaintenance.phase.is_not(None))
)).all())
for sched in rows:
host = await db.get(OpnsenseHost, sched.host_id)
op = await db.get(HostOperation, sched.host_operation_id) if sched.host_operation_id else None
try:
if host is None:
raise RuntimeError("Host nicht mehr vorhanden")
if op is not None and op.status not in ACTIVE:
# A human closed or cancelled the operation: stop here and touch nothing more.
sched.status, sched.phase = "failed", None
sched.result = {"error": f"Vorgang von Hand beendet, Ablauf gestoppt. CARP-Rolle von {host.name} prüfen."}
await db.commit()
continue
await _STEPS[sched.phase](db, sched, host, op)
except Exception as exc: # a stuck run must not loop forever or take the poll loop down
logger.exception("Geplante Wartung %s: Schritt %s fehlgeschlagen", sched.id, sched.phase)
if host is not None:
await _end(db, sched, host, op, ok=False,
message=f"Unerwarteter Fehler während der CARP-Übergabe ({type(exc).__name__}). "
f"{host.name} kann im CARP-Wartungsmodus verblieben sein — Rollen prüfen.")
return sum(1 for s in rows if s.status == "running")
def _since(sched: ScheduledMaintenance) -> timedelta:
return _now() - (sched.phase_since or _now())
async def _roles(db, host) -> tuple[dict, bool]:
"""(own status, a partner is online and MASTER)."""
fleet = await peers(db, host)
statuses = await get_statuses(fleet)
partner_master = any(
statuses.get(h.id, {}).get("status") == "online" and statuses.get(h.id, {}).get("carp_status") == "MASTER"
for h in fleet if h.id != host.id
)
return statuses.get(host.id, {}), partner_master
async def _step_handover_wait(db, sched, host, op):
me, partner_master = await _roles(db, host)
if me.get("status") == "online" and me.get("carp_maintenance_mode") and me.get("carp_status") == "BACKUP" and partner_master:
try:
await _ACTION_FN[sched.action](host)
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"Auftrag „{sched.action}“ mit unklarem Ergebnis: {str(exc)[:300]}. Nicht wiederholt. "
f"{host.name} bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr.")
return
sched.phase, sched.phase_since = "job_wait", _now()
_progress(sched, job_started_at=_now().isoformat(), seen_down=False, seen_running=False)
op.status, op.message = "submitted", "Übergabe bestätigt, Auftrag gestartet."
await db.commit()
await _audit(host, sched, op, sched.action, "submitted")
return
if _since(sched) < HANDOVER_TIMEOUT:
return
# The partner never took over. Nothing has run yet, so put the node back to normal.
if me.get("status") == "online" and me.get("carp_maintenance_mode"):
try:
await _toggle(host, "leave_maintenance")
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"CARP-Übergabe nicht bestätigt, und der Normalbetrieb konnte nicht wiederhergestellt werden "
f"({str(exc)[:200]}). {host.name} prüfen.")
return
await _end(db, sched, host, op, ok=False, op_status="cancelled",
message="CARP-Übergabe nicht bestätigt (der Partner wurde nicht MASTER). Normalbetrieb wiederhergestellt, nichts aktualisiert.")
elif me.get("status") == "online":
await _end(db, sched, host, op, ok=False, op_status="cancelled",
message="CARP-Übergabe hat nicht stattgefunden (Wartungsmodus nicht aktiv). Nichts aktualisiert.")
else:
await _end(db, sched, host, op, ok=False,
message=f"CARP-Übergabe nicht bestätigt und {host.name} nicht erreichbar. Rollen prüfen.")
async def _step_job_wait(db, sched, host, op):
prog = dict(sched.progress or {})
started = datetime.fromisoformat(prog["job_started_at"])
elapsed = _now() - started
try:
running = (await client.get_firmware_running(host)).get("status")
except client.OpnsenseAPIError:
_progress(sched, seen_down=True) # rebooting: exactly what a reboot or base update looks like
running = None
else:
if running != "ready":
_progress(sched, seen_running=True)
prog = sched.progress
if running == "ready":
if sched.action == "reboot":
done = bool(prog.get("seen_down"))
else:
done = bool(prog.get("seen_down") or prog.get("seen_running")) or elapsed >= JOB_GRACE
if done:
sched.phase, sched.phase_since = "leave", _now()
await db.commit()
return
if elapsed >= JOB_TIMEOUT[sched.action]:
await _end(db, sched, host, op, ok=False,
message=f"Auftrag „{sched.action}“ auf {host.name} nicht rechtzeitig abgeschlossen. {host.name} bleibt im "
f"CARP-Wartungsmodus, der Partner trägt den Verkehr. Bitte prüfen und von Hand zurückschalten.")
return
await db.commit()
async def _step_leave(db, sched, host, op):
me, _partner = await _roles(db, host)
if me.get("status") != "online":
if _since(sched) >= LEAVE_TIMEOUT:
await _end(db, sched, host, op, ok=False,
message=f"{host.name} ist nach dem Auftrag nicht wieder erreichbar. Der Partner trägt den Verkehr.")
return
if me.get("carp_maintenance_mode"):
try:
await _toggle(host, "leave_maintenance")
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"Zurückschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
f"{host.name} prüfen, ggf. „CARP-Normalbetrieb herstellen“.")
return
await _audit(host, sched, op, "carp_leave", "submitted")
sched.phase, sched.phase_since = "leave_wait", _now()
await db.commit()
async def _step_leave_wait(db, sched, host, op):
fleet = await peers(db, host)
statuses = await get_statuses(fleet)
me = statuses.get(host.id, {})
online = [h for h in fleet if statuses.get(h.id, {}).get("status") == "online"]
masters = [h for h in online if statuses.get(h.id, {}).get("carp_status") == "MASTER"]
if me.get("status") == "online" and not me.get("carp_maintenance_mode") and len(masters) == 1 and len(online) == len(fleet):
extra = {}
try:
extra["version_after"] = (await client.get_firmware_info(host)).get("product_version")
extra["reboot_required"] = str((await client.get_firmware_status(host)).get("status_reboot", "0")) == "1"
except client.OpnsenseAPIError:
pass
extra["version_before"] = (sched.progress or {}).get("version_before")
text = "Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt."
if extra.get("reboot_required"):
text += " Ein Neustart ist noch erforderlich."
await _end(db, sched, host, op, ok=True, message=text, **extra)
return
if _since(sched) >= LEAVE_TIMEOUT:
await _end(db, sched, host, op, ok=False,
message=f"Normalbetrieb nach dem Zurückschalten nicht bestätigt. Rollen von {host.name} und Partner prüfen.")
_STEPS = {
"handover_wait": _step_handover_wait,
"job_wait": _step_job_wait,
"leave": _step_leave,
"leave_wait": _step_leave_wait,
}
+73
View File
@@ -0,0 +1,73 @@
"""ZFS snapshots (boot environments) of one firewall: list, create, delete.
Snapshots are a safety net before bigger changes, not a rollback button:
activating one needs a reboot and discards everything since, so that action is
deliberately NOT offered here. Deleting refuses the active/next-boot snapshot
(the firewall refuses too; we say so up front). Every step is audited.
"""
import re
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import audit_service, opnsense_client
from app.services.opnsense_client import OpnsenseAPIError
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
def _row(row: dict) -> dict:
return {
"uuid": str(row.get("uuid", "")), "name": str(row.get("name", "")), "active": str(row.get("active", "")),
"size": str(row.get("size", "")), "created": str(row.get("created_str", "")),
"mountpoint": str(row.get("mountpoint", "")),
}
async def list_snapshots(host: OpnsenseHost) -> dict:
try:
supported = (await opnsense_client._request(host, "GET", "/core/snapshots/isSupported")).get("supported")
except OpnsenseAPIError as exc:
if "404" in str(exc):
return {"supported": False, "rows": []}
raise
if not supported:
return {"supported": False, "rows": []}
return {"supported": True, "rows": [_row(r) for r in await opnsense_client.list_snapshots(host)]}
async def create(host: OpnsenseHost, name: str, note: str) -> dict:
if not NAME.match(name):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="name")
audit = {"kind": "snapshot", "action": "snapshot.add", "entity_name": name, "host_id": str(host.id), "host_name": host.name}
await audit_service.persist(**audit, status="started", message=None, details={"note": note[:200]})
try:
await opnsense_client.create_snapshot(host, name, note[:200])
created = next((r for r in await opnsense_client.list_snapshots(host) if r.get("name") == name), None)
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
await audit_service.persist(**audit, status="applied" if created else "unverified", message=None, details={})
if not created:
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
return _row(created)
async def delete(host: OpnsenseHost, uuid: str) -> None:
rows = await opnsense_client.list_snapshots(host)
target = next((r for r in rows if str(r.get("uuid")) == uuid), None)
if target is None:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
if str(target.get("active", "")).strip() not in ("", "-"):
# N = active now, R = active after reboot: removing either would break the boot environment.
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="snapshot_active")
audit = {"kind": "snapshot", "action": "snapshot.del", "entity_name": str(target.get("name")), "host_id": str(host.id), "host_name": host.name}
await audit_service.persist(**audit, status="started", message=None, details={})
try:
await opnsense_client.delete_snapshot(host, uuid)
still = any(str(r.get("uuid")) == uuid for r in await opnsense_client.list_snapshots(host))
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
await audit_service.persist(**audit, status="unverified" if still else "applied", message=None, details={})
if still:
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
+70
View File
@@ -0,0 +1,70 @@
"""Client für den optionalen OPNexus-Updater-Container (updater/server.py).
Das Backend kann selbst nichts am Host ändern und soll das auch nicht können:
Es fragt den Updater-Dienst nach dem Zustand und reicht eine bereits
geprüfte Update-/Rollback-Anfrage weiter. Ist der Dienst nicht aktiviert oder
nicht erreichbar, ist das ein normaler Zustand ("nicht verfügbar"), kein Fehler.
"""
import logging
from pathlib import Path
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
_STATUS_TIMEOUT = 50.0 # der Updater fragt docker ab (bis zu 45 s)
_ACTION_TIMEOUT = 15.0
class UpdaterUnavailable(Exception):
"""Updater nicht aktiviert (kein Token) oder nicht erreichbar."""
class UpdaterRejected(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
def _token() -> str:
try:
token = Path(settings.updater_token_file).read_text(encoding="utf-8").strip()
except OSError:
token = ""
if not token:
raise UpdaterUnavailable("not_enabled")
return token
async def _call(method: str, path: str, payload: dict | None, timeout: float) -> dict:
headers = {"Authorization": f"Bearer {_token()}"}
try:
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.request(method, settings.updater_url.rstrip("/") + path, headers=headers, json=payload)
except httpx.HTTPError as exc:
logger.info("Updater nicht erreichbar: %s", exc.__class__.__name__)
raise UpdaterUnavailable("unreachable") from exc
try:
body = response.json()
except ValueError:
body = {}
if response.status_code == 401:
# Token passt nicht (Volume neu angelegt?) — wie "nicht verfügbar" behandeln.
raise UpdaterUnavailable("unauthorized")
if response.status_code >= 400:
raise UpdaterRejected(str(body.get("error") or "rejected"))
return body
async def status() -> dict:
return await _call("GET", "/status", None, _STATUS_TIMEOUT)
async def start_update(version: str) -> dict:
return await _call("POST", "/update", {"version": version}, _ACTION_TIMEOUT)
async def start_rollback() -> dict:
return await _call("POST", "/rollback", {}, _ACTION_TIMEOUT)
@@ -324,6 +324,9 @@ async def _deploy_to_host(db: AsyncSession, tunnel: WireguardTunnel, host: Opnse
try:
await _write_config(host, payloads, deployment)
# Without the global switch the config is stored but no interface
# ever comes up (live-verified), so a "success" would be a lie.
await opnsense_client.ensure_wireguard_enabled(host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
@@ -388,7 +391,7 @@ async def undeploy_tunnel(db: AsyncSession, tunnel: WireguardTunnel) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+2 -2
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung").
"""
APP_VERSION = "1.11.0"
APP_CODENAME = "Merlin"
APP_VERSION = "1.17.0"
APP_CODENAME = "Imperial"
@@ -0,0 +1,24 @@
"""Regeln: Protokollieren-Flag (log).
Bisher schrieb jeder Push fest log=0. Damit eine von Hand angelegte Regel mit
Protokollierung übernommen und danach bearbeitet werden kann, ohne dass das
Flag still verloren geht, ist es jetzt ein Feld der Regel.
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-09
"""
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE firewall_rules ADD COLUMN IF NOT EXISTS log boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE firewall_rules DROP COLUMN IF EXISTS log")
@@ -0,0 +1,20 @@
"""Benachrichtigung bei Netzwerk-Abweichungen zwischen den Knoten eines Clusters.
Revision ID: 0004
Revises: 0003
Create Date: 2026-10-09
"""
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS network_drift_enabled boolean NOT NULL DEFAULT true")
def downgrade() -> None:
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS network_drift_enabled")
@@ -0,0 +1,20 @@
"""Einrichtungsassistent: Merker, dass ein Nutzer ihn abgeschlossen oder übersprungen hat.
Revision ID: 0005
Revises: 0004
Create Date: 2026-10-10
"""
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS setup_done boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE user_preferences DROP COLUMN IF EXISTS setup_done")
@@ -0,0 +1,23 @@
"""Benachrichtigung, wenn eine geplante Wartung nicht ausgeführt werden konnte.
Revision ID: 0006
Revises: 0005
Create Date: 2026-10-10
"""
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS "
"scheduled_maintenance_failed_enabled boolean NOT NULL DEFAULT true"
)
def downgrade() -> None:
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS scheduled_maintenance_failed_enabled")
@@ -0,0 +1,26 @@
"""Geplante Wartung mit automatischer CARP-Übergabe: Option, Phase und Fortschritt.
Revision ID: 0007
Revises: 0006
Create Date: 2026-10-19
"""
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS carp_handover boolean NOT NULL DEFAULT false")
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS phase varchar(16)")
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS phase_since timestamp with time zone")
# The default only fills existing rows; like every JSON column here the model has no server default.
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS progress json NOT NULL DEFAULT '{}'::json")
op.execute("ALTER TABLE scheduled_maintenance ALTER COLUMN progress DROP DEFAULT")
def downgrade() -> None:
for column in ("progress", "phase_since", "phase", "carp_handover"):
op.execute(f"ALTER TABLE scheduled_maintenance DROP COLUMN IF EXISTS {column}")
+14 -14
View File
@@ -75,7 +75,7 @@ async def test_push_acme_account_with_eab(client, respx_mock):
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]), )
account_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
resp = await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -97,7 +97,7 @@ async def test_register_account_regression_response_shape(client, respx_mock):
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -118,7 +118,7 @@ async def test_register_account_failed_precheck_shape(client, respx_mock):
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -175,7 +175,7 @@ async def test_push_certificate_request_injects_account_uuid(client, respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -183,7 +183,7 @@ async def test_push_certificate_request_injects_account_uuid(client, respx_mock)
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
resp = await client.post(f"/api/acme/certificates/{request_id}/push")
@@ -202,14 +202,14 @@ async def test_push_certificate_request_is_noop_when_unchanged(client, respx_moc
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
resp1 = await client.post(f"/api/acme/certificates/{request_id}/push")
@@ -230,7 +230,7 @@ async def test_push_certificate_request_repushes_after_account_switch(client, re
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"], name="Account A"))
account_a = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
side_effect=[
httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-a"}),
httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-b"}),
@@ -243,7 +243,7 @@ async def test_push_certificate_request_repushes_after_account_switch(client, re
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_a))
request = resp.json()
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
push_resp = await client.post(f"/api/acme/certificates/{request['id']}/push")
@@ -257,7 +257,7 @@ async def test_push_certificate_request_repushes_after_account_switch(client, re
resp = await client.put(f"/api/acme/certificates/{request['id']}", json=edit_payload)
assert resp.status_code == 200, resp.text
set_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/updateAction/.*").mock(
set_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/update/.*").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
resp = await client.post(f"/api/acme/certificates/{request['id']}/push")
@@ -272,14 +272,14 @@ async def test_sign_is_async_trigger_not_a_success_claim(client, respx_mock):
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
await client.post(f"/api/acme/certificates/{request_id}/push")
@@ -302,14 +302,14 @@ async def test_poll_certificate_status_updates_remote_fields(client, respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
await client.post(f"/api/acme/certificates/{request_id}/push")
+21
View File
@@ -128,6 +128,27 @@ async def test_delete_alias(client: AsyncClient, respx_mock):
assert (await client.get("/api/aliases")).json() == []
@pytest.mark.asyncio
async def test_delete_alias_refused_by_firewall_names_the_reason(client: AsyncClient, respx_mock):
"""Live 26.7: an alias still used by a rule cannot be removed; the user must
see why (it used to be a bare "request failed")."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
r = (await client.post("/api/aliases", json=_alias_payload([host["id"]]))).json()
_mock_alias_push(respx_mock, "https://fw1.test")
assert (await client.post(f"/api/aliases/{r['id']}/deploy")).status_code == 200
assert (await client.post(f"/api/aliases/{r['id']}/confirm")).status_code == 200
respx_mock.post(url__regex=r".*/firewall/alias/delItem/.*").mock(
return_value=httpx.Response(200, json={"status": "failed", "message": "Alias is used by rule FW3 test block"})
)
resp = await client.delete(f"/api/aliases/{r['id']}")
assert resp.status_code == 502, resp.text
detail = resp.json()["detail"]
assert detail["code"] == "UPSTREAM_REJECTED"
assert "used by rule" in detail["params"]["reason"]
@pytest.mark.asyncio
async def test_deploy_alias_without_targets_rejected(client: AsyncClient):
r = (await client.post("/api/aliases", json=_alias_payload([]))).json()
+51
View File
@@ -157,6 +157,57 @@ async def test_sweep_reverts_expired_window(client, respx_mock):
assert await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"])) is None
async def _enter_with_expired_window(client, primary):
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True, "auto_revert_minutes": 30},
)
async with SessionLocal() as db:
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
window.auto_revert_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
return plan["preview_id"]
async def test_sweep_revert_closes_the_enter_operation_and_unblocks_deploys(client, respx_mock):
"""Nach dem Auto-Revert darf die carp_enter-Aktion nicht offen bleiben:
sie ließe sich nie mehr bestätigen und blockiert sonst alle Deploys im Cluster."""
from app.services.host_maintenance_service import ACTIVE, ensure_no_maintenance
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
op_id = await _enter_with_expired_window(client, primary)
async with SessionLocal() as db:
op = await db.get(HostOperation, uuid.UUID(op_id))
assert op.status == "submitted"
await carp_maintenance_window_service.sweep_expired(db)
async with SessionLocal() as db:
op = await db.get(HostOperation, uuid.UUID(op_id))
assert op.status == "cancelled" and op.status not in ACTIVE
assert "Automatisch" in op.message
host = await db.get(__import__("app.models.host", fromlist=["OpnsenseHost"]).OpnsenseHost, uuid.UUID(primary["id"]))
await ensure_no_maintenance(db, [host]) # raises if still blocked
async def test_sweep_failed_revert_keeps_the_enter_operation_open(client, respx_mock):
"""Schlägt der automatische Rückschalt-Versuch fehl, muss ein Mensch nachsehen:
die Aktion bleibt offen."""
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
op_id = await _enter_with_expired_window(client, primary)
switch.mock(side_effect=httpx.ConnectError("boom"))
async with SessionLocal() as db:
resolved = await carp_maintenance_window_service.sweep_expired(db)
assert resolved[0].status == "revert_failed"
async with SessionLocal() as db:
assert (await db.get(HostOperation, uuid.UUID(op_id))).status == "submitted"
@pytest.mark.asyncio
async def test_sweep_marks_revert_failed_on_toggle_error(client, respx_mock):
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
+13 -6
View File
@@ -95,7 +95,7 @@ async def test_push_managed_certificate(client, respx_mock):
resp = await client.post("/api/certificates/managed", json=_cert_payload(host["id"]))
cert_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/trust/cert/addAction$").mock(
respx_mock.post(url__regex=r".*/trust/cert/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-uuid-1"})
)
resp = await client.post(f"/api/certificates/managed/{cert_id}/push")
@@ -112,7 +112,7 @@ async def test_push_is_noop_when_nothing_changed(client, respx_mock):
resp = await client.post("/api/certificates/managed", json=_cert_payload(host["id"]))
cert_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/trust/cert/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/trust/cert/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-uuid-1"})
)
resp1 = await client.post(f"/api/certificates/managed/{cert_id}/push")
@@ -148,14 +148,21 @@ async def test_delete_in_use_certificate_returns_stable_error_code(client, respx
resp = await client.post("/api/certificates/managed", json=_cert_payload(host["id"]))
cert_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/trust/cert/addAction$").mock(
respx_mock.post(url__regex=r".*/trust/cert/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-uuid-1"})
)
await client.post(f"/api/certificates/managed/{cert_id}/push")
respx_mock.post(url__regex=r".*/trust/cert/delAction/.*").mock(
return_value=httpx.Response(500, json={"message": "OpenVPN - roadwarrior {openvpn.instance.abc}"})
respx_mock.post(url__regex=r".*/trust/cert/del/.*").mock(
# shape captured from a real OPNsense 26.7.5 (cert used by an OpenVPN instance)
return_value=httpx.Response(
500,
json={"errorMessage": "OpenVPN - roadwarrior {OpenVPN.Instances.Instance.abc}\n", "errorTitle": "Item in use by", "errorLevel": "error"},
)
)
resp = await client.delete(f"/api/certificates/managed/{cert_id}")
assert resp.status_code == 502
assert resp.json()["detail"] == {"code": "UPSTREAM_REQUEST_FAILED", "params": {}}
detail = resp.json()["detail"]
assert detail["code"] == "UPSTREAM_REJECTED"
assert "Item in use by" in detail["params"]["reason"]
assert "roadwarrior" in detail["params"]["reason"]
+14
View File
@@ -0,0 +1,14 @@
"""OPNsense routes drop the `Action` suffix of the PHP method name
(addAction() -> /add). A URL such as /trust/cert/addAction is a 404 on a real
firewall, but a respx mock written with the same typo happily accepts it
(live-found 2026-10-09), so guard the client source itself."""
import re
from pathlib import Path
import app.services.opnsense_client as client
def test_no_request_path_ends_in_action_suffix():
source = Path(client.__file__).read_text()
offenders = re.findall(r'"/[A-Za-z0-9_/{}$.]*?[a-z]Action(?:/[^"]*)?"', source)
assert offenders == [], offenders
+143
View File
@@ -0,0 +1,143 @@
"""Ping / traceroute / port probe / DNS lookup run from a firewall."""
import asyncio
import json
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
BASE = "https://10.20.30.85"
async def _host(client, respx_mock, name="fw-diag"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
return (await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})).json()["id"]
def _url(path):
return f"{BASE}/api{path}"
async def _events(host_id):
async with SessionLocal() as db:
rows = await db.scalars(select(AuditEvent).where(AuditEvent.host_id == host_id, AuditEvent.kind == "diagnostic").order_by(AuditEvent.created_at))
return list(rows.all())
@pytest.fixture
def fast_sleep(monkeypatch):
async def instant(_seconds):
return None
monkeypatch.setattr(asyncio, "sleep", instant)
@pytest.mark.asyncio
async def test_traceroute_is_parsed_and_audited(client, respx_mock):
host_id = await _host(client, respx_mock)
sent = respx_mock.post(_url("/diagnostics/traceroute/set")).mock(return_value=httpx.Response(200, json={
"result": "ok", "response": {"notice": "traceroute to x", "rows": [
{"address": "10.0.0.1", "ttl": "1", "AS": "AS0", "host": "gw.lan", "probes": "0.4 ms"}]}}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/traceroute", json={"target": "example.com", "protocol": "icmp"})
assert resp.status_code == 200, resp.text
assert resp.json()["hops"] == [{"ttl": "1", "address": "10.0.0.1", "host": "gw.lan", "as": "AS0", "probes": "0.4 ms"}]
assert json.loads(sent.calls.last.request.content)["traceroute"]["settings"]["hostname"] == "example.com"
events = await _events(host_id)
assert [(e.action, e.status, e.entity_name) for e in events] == [("diag.traceroute", "ok", "example.com")]
@pytest.mark.asyncio
async def test_bad_targets_never_reach_the_firewall(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag2")
route = respx_mock.post(_url("/diagnostics/traceroute/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {}}))
for bad in ("a;b", "x y", "$(reboot)", "-h", "a" * 300, "host/../x"):
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/traceroute", json={"target": bad})
assert resp.status_code == 422, bad
assert not route.called
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "x.test", "port": 70000})).status_code == 422
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "x.test", "count": 500})).status_code == 422
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "x.test", "source": "not-an-ip"})).status_code == 422
@pytest.mark.asyncio
async def test_the_firewalls_own_validation_is_reported_as_422_and_audited(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag3")
respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={
"result": "failed", "validations": {"dns.settings.server": "Provide a valid server."}}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com", "server": "1.2.3.4"})
assert resp.status_code == 422 and resp.json()["detail"]["params"]["field"] == "server"
assert [(e.status) for e in await _events(host_id)] == ["failed"]
@pytest.mark.asyncio
async def test_portprobe_reports_open_or_closed_and_never_the_banner(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag4")
answers = iter([
{"result": "ok", "response": {"message": "Connection to 10.0.0.1 443 port [tcp/https] succeeded!", "payload": "SSH-2.0-SECRET-BANNER"}},
{"result": "ok", "response": {"message": "nc: connect to 10.0.0.1 port 1 (tcp) failed: Operation timed out", "payload": ""}},
])
respx_mock.post(_url("/diagnostics/portprobe/set")).mock(side_effect=lambda r: httpx.Response(200, json=next(answers)))
opened = await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "10.0.0.1", "port": 443})
closed = await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "10.0.0.1", "port": 1})
assert opened.json()["open"] is True and closed.json()["open"] is False
assert "SECRET-BANNER" not in opened.text
@pytest.mark.asyncio
async def test_dns_lookup(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag5")
respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {
"A": {"answers": ["example.com.\t300\tIN\tA\t192.0.2.1"], "query_time": "42 msec", "server": "10.0.0.1"}}}))
body = (await client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com"})).json()
assert body["records"]["A"]["answers"][0].endswith("192.0.2.1") and body["records"]["A"]["query_time"] == "42 msec"
@pytest.mark.asyncio
async def test_ping_runs_a_job_and_always_cleans_it_up(client, respx_mock, fast_sleep):
host_id = await _host(client, respx_mock, "fw-diag6")
respx_mock.post(_url("/diagnostics/ping/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "uuid": "job-1"}))
start = respx_mock.post(_url("/diagnostics/ping/start/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
row = {"id": "job-1", "status": "running", "send": 4, "received": 4, "loss": "0.0%", "min": 0.3, "avg": 0.4, "max": 0.5, "std-dev": 0.05, "last_error": None}
respx_mock.get(_url("/diagnostics/ping/searchJobs")).mock(return_value=httpx.Response(200, json={"rows": [row]}))
stop = respx_mock.post(_url("/diagnostics/ping/stop/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
remove = respx_mock.post(_url("/diagnostics/ping/remove/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "10.0.0.1", "count": 4})
assert resp.status_code == 200, resp.text
body = resp.json()
assert (body["sent"], body["received"], body["loss"], body["avg"]) == (4, 4, "0.0%", 0.4)
assert start.called and stop.called and remove.called
@pytest.mark.asyncio
async def test_ping_job_is_removed_even_when_polling_fails(client, respx_mock, fast_sleep):
host_id = await _host(client, respx_mock, "fw-diag7")
respx_mock.post(_url("/diagnostics/ping/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "uuid": "job-2"}))
respx_mock.post(_url("/diagnostics/ping/start/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
respx_mock.get(_url("/diagnostics/ping/searchJobs")).mock(side_effect=httpx.ConnectError("gone"))
stop = respx_mock.post(_url("/diagnostics/ping/stop/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
remove = respx_mock.post(_url("/diagnostics/ping/remove/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "10.0.0.1"})
assert resp.status_code == 502
assert stop.called and remove.called # no orphaned job on the firewall
assert [e.status for e in await _events(host_id)] == ["failed"]
@pytest.mark.asyncio
async def test_only_admins_can_run_probes(anon_client, client, respx_mock):
from httpx import ASGITransport, AsyncClient
from app.main import app
host_id = await _host(client, respx_mock, "fw-diag8")
assert (await anon_client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com"})).status_code == 401
created = await client.post("/api/users", json={"username": "diag_ro", "password": "Diag-Read-Only-2026", "role": "read_only"})
assert created.status_code == 201, created.text
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ro:
assert (await ro.post("/api/auth/login", json={"username": "diag_ro", "password": "Diag-Read-Only-2026"})).status_code == 200
probe = respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {}}))
for tool, body in (("dns", {"name": "example.com"}), ("ping", {"target": "10.0.0.1"}), ("traceroute", {"target": "10.0.0.1"}), ("portprobe", {"target": "10.0.0.1", "port": 22})):
assert (await ro.post(f"/api/hosts/{host_id}/diagnostics/{tool}", json=body)).status_code == 403, tool
assert not probe.called
finally:
await client.delete(f"/api/users/{created.json()['id']}")
+15
View File
@@ -467,6 +467,8 @@ async def test_cas_parses_real_shape(client, respx_mock):
"rows": [
{
"uuid": "ca-uuid-1",
# OpenVPN cert/ca and Trust-Store caref reference this id, not the uuid
"refid": "6ac8a49fa1589",
"descr": "Internal CA",
"commonname": "Internal Root CA",
"valid_from": str(now - 300 * 86400),
@@ -482,6 +484,7 @@ async def test_cas_parses_real_shape(client, respx_mock):
cas = resp.json()
assert len(cas) == 1
assert cas[0]["uuid"] == "ca-uuid-1"
assert cas[0]["refid"] == "6ac8a49fa1589"
assert cas[0]["common_name"] == "Internal Root CA"
assert cas[0]["status"] == "valid"
@@ -572,3 +575,15 @@ async def test_firmware_info_broken_http_framing_gives_actionable_message(client
await opnsense_client.get_firmware_info(host)
assert "kern.ipc.tls.enable=0" in str(info.value)
assert "malformed chunk footer" in str(info.value)
@pytest.mark.asyncio
async def test_host_address_can_be_changed_after_the_firewall_moved(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = (await client.post("/api/hosts", json={"name": "fw-moved", "base_url": "https://10.9.9.1", "api_key": "k", "api_secret": "s"})).json()
resp = await client.patch(f"/api/hosts/{host['id']}", json={"base_url": "https://10.9.9.2"})
assert resp.status_code == 200, resp.text
assert resp.json()["base_url"] == "https://10.9.9.2" and resp.json()["id"] == host["id"]
for bad in ("ftp://x", "http://10.9.9.3", "https://user:[email protected]", "https://10.9.9.3/?a=b"):
assert (await client.patch(f"/api/hosts/{host['id']}", json={"base_url": bad})).status_code == 422, bad
assert next(h for h in (await client.get("/api/hosts")).json() if h["id"] == host["id"])["base_url"] == "https://10.9.9.2"
+28 -1
View File
@@ -18,7 +18,16 @@ from tests.test_ipsec import _create_host, _tunnel_payload
from tests.helpers import deploy_approved
def _mock_full_success(respx_mock, base_url: str):
def _mock_ipsec_enabled(respx_mock, base_url: str, enabled: bool = True):
# The service-wide switch is checked before every deploy (live 26.7.6:
# off by default, and reconfigure still answers ok while it is off).
respx_mock.get(f"{base_url}/api/ipsec/connections/is_enabled").mock(
return_value=httpx.Response(200, json={"enabled": enabled})
)
def _mock_full_success(respx_mock, base_url: str, enabled: bool = True):
_mock_ipsec_enabled(respx_mock, base_url, enabled)
respx_mock.post(f"{base_url}/api/ipsec/connections/addConnection").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "conn-1"})
)
@@ -134,6 +143,7 @@ async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, r
respx_mock.post("https://fw1.test/api/ipsec/preSharedKeys/addItem").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "psk-1"})
)
_mock_ipsec_enabled(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/ipsec/service/reconfigure").mock(
side_effect=httpx.ConnectError("connection refused")
)
@@ -203,3 +213,20 @@ async def test_undeploy_deletes_child_local_remote_connection_then_psk(client, r
resp = await client.delete(f"/api/ipsec/tunnels/{tunnel_id}")
assert resp.status_code == 204, resp.text
assert del_child.called and del_local.called and del_remote.called and del_conn.called and del_psk.called
@pytest.mark.asyncio
async def test_deploy_switches_on_the_global_ipsec_service_when_disabled(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test", enabled=False)
toggle = respx_mock.post("https://fw1.test/api/ipsec/connections/toggle/1").mock(
return_value=httpx.Response(200, json={"status": "ok"})
)
resp = await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
assert resp.json()["deployments"][0]["status"] == "success"
assert toggle.call_count == 1
+178
View File
@@ -0,0 +1,178 @@
"""Read-only network inventory. Response shapes copied from real OPNsense
26.7.x test firewalls (CARP password replaced)."""
import httpx
import pytest
BASE = "https://fw-net.test"
INTERFACES_INFO = {"total": 3, "rowCount": 3, "current": 1, "rows": [
{"device": "vtnet0", "identifier": "wan", "description": "WAN", "status": "up", "is_physical": True,
"mtu": "1500", "macaddr": "bc:24:11:25:7e:b8", "media": "10Gbase-T <full-duplex>",
"ipv4": [{"ipaddr": "10.20.30.57/24"}], "ipv6": []},
{"device": "vtnet1_vlan10", "identifier": "lan", "description": "LAN", "status": "up", "is_physical": False,
"mtu": "1500", "ipv4": [{"ipaddr": "192.168.1.2/24"},
{"ipaddr": "192.168.1.1/24", "vhid": "10", "status": "MASTER"}],
"vlan": {"tag": "10", "proto": "802.1q", "pcp": "0", "parent": "vtnet1"}},
{"device": "vtnet1", "identifier": "", "description": "Unassigned Interface", "status": "up",
"is_physical": True, "ipv4": []},
]}
ASSIGNMENTS = {"rows": [
{"uuid": "wan", "identifier": "wan", "pending_action": "", "type4": "staticv4", "%type4": "Static IPv4", "%type6": "DHCPv6"},
{"uuid": "lan", "identifier": "lan", "pending_action": "", "type4": "staticv4", "%type4": "Static IPv4", "%type6": "Identity association"},
]}
VLANS = {"rows": [{"uuid": "v1", "if": "vtnet1", "tag": "10", "pcp": "0", "descr": "", "vlanif": "vtnet1_vlan10 [LAN]"}]}
VIPS = {"rows": [{"uuid": "vip1", "interface": "lan", "%interface": "LAN", "mode": "carp", "address": "192.168.1.1/24",
"vhid": "10", "advbase": "1", "advskew": "0", "descr": "CARP LAN", "password": "SUPER-SECRET-CARP"}]}
GATEWAYS = {"rows": [{"uuid": "g1", "name": "WAN_GW", "descr": "", "interface": "wan", "interface_descr": "WAN",
"ipprotocol": "inet", "gateway": "10.20.30.1", "defaultgw": True, "disabled": False,
"priority": "255", "status": "Online"}]}
DNS_HOSTS = {"rows": [
{"uuid": "d1", "enabled": "1", "hostname": "nas", "domain": "lan.example", "rr": "A", "server": "192.168.1.5", "description": "NAS", "isAlias": False},
{"uuid": "d1-a", "enabled": "1", "hostname": "files", "domain": "lan.example", "rr": "A", "server": "192.168.1.5", "isAlias": True}]}
DHCP_HOSTS = {"rows": [{"uuid": "h1", "host": "printer", "domain": "lan.example", "ip": "192.168.1.30", "hwaddr": "02:00:00:aa:bb:cc",
"lease_time": "", "descr": "Printer"}]}
KEA_SUBNETS = {"rows": [{"uuid": "s1", "subnet": "192.168.77.0/24", "pools": "192.168.77.100-192.168.77.150",
"option_data.routers": "192.168.77.1", "option_data.domain_name_servers": "",
"description": "lab", "ddns_domain_key_secret": "KEA-DDNS-SECRET"}]}
KEA_RESERVATIONS = {"rows": [{"uuid": "k1", "subnet": "s1", "%subnet": "192.168.77.0/24", "ip_address": "192.168.77.120",
"hw_address": "02:00:00:aa:bb:dd", "hostname": "kearsv", "description": "Reservation"}]}
ROUTES = {"rows": [{"uuid": "r1", "network": "10.9.0.0/16", "gateway": "WAN_GW", "descr": "lab", "disabled": "0"}]}
async def _host(client, respx_mock, base=BASE, name="fw-net"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
resp = await client.post("/api/hosts", json={"name": name, "base_url": base, "api_key": "k", "api_secret": "s"})
return resp.json()["id"]
def _mock(respx_mock, base=BASE, info=INTERFACES_INFO, **overrides):
routes = {
"/interfaces/overview/interfacesInfo": info,
"/interfaces/assignment/searchItem": ASSIGNMENTS,
"/interfaces/vlan_settings/searchItem": VLANS,
"/interfaces/vip_settings/searchItem": VIPS,
"/routing/settings/searchGateway": GATEWAYS,
"/routes/routes/searchroute": ROUTES,
"/unbound/settings/searchHostOverride": DNS_HOSTS,
"/dnsmasq/settings/searchHost": DHCP_HOSTS,
"/kea/dhcpv4/searchSubnet": KEA_SUBNETS,
"/kea/dhcpv4/searchReservation": KEA_RESERVATIONS,
}
for path, body in routes.items():
reply = overrides.get(path, httpx.Response(200, json=body))
respx_mock.get(f"{base}/api{path}").mock(return_value=reply)
@pytest.mark.asyncio
async def test_network_inventory_parses_real_shapes(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.57")
_mock(respx_mock, base="https://10.20.30.57")
resp = await client.get(f"/api/hosts/{host_id}/network")
assert resp.status_code == 200, resp.text
data = resp.json()
by_device = {i["device"]: i for i in data["interfaces"]}
assert by_device["vtnet0"]["identifier"] == "wan"
assert by_device["vtnet0"]["ipv4_type"] == "Static IPv4"
assert by_device["vtnet1"]["identifier"] is None
assert by_device["vtnet0"]["assigned"] is True and by_device["vtnet1"]["assigned"] is False
lan = by_device["vtnet1_vlan10"]
assert lan["vlan_tag"] == "10" and lan["vlan_parent"] == "vtnet1"
assert [a["carp_status"] for a in lan["addresses"]] == [None, "MASTER"]
assert data["vlans"][0]["device"] == "vtnet1_vlan10"
assert data["gateways"][0]["is_default"] is True
assert data["routes"][0]["network"] == "10.9.0.0/16"
assert data["unavailable"] == []
assert data["can_configure_interfaces"] is True
@pytest.mark.asyncio
async def test_network_marks_management_interface(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.57")
_mock(respx_mock, base="https://10.20.30.57")
data = (await client.get(f"/api/hosts/{host_id}/network")).json()
assert data["management_device"] == "vtnet0"
assert [i["device"] for i in data["interfaces"] if i["is_management"]] == ["vtnet0"]
@pytest.mark.asyncio
async def test_network_carp_address_is_not_management(client, respx_mock):
# Reached over the shared CARP address: that is not one interface's own IP, so no guess.
host_id = await _host(client, respx_mock, base="https://192.168.1.1", name="fw-carp")
_mock(respx_mock, base="https://192.168.1.1")
data = (await client.get(f"/api/hosts/{host_id}/network")).json()
assert data["management_device"] is None
@pytest.mark.asyncio
async def test_network_never_returns_carp_password(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.58", name="fw-pw")
_mock(respx_mock, base="https://10.20.30.58")
resp = await client.get(f"/api/hosts/{host_id}/network")
assert "SUPER-SECRET-CARP" not in resp.text
assert "password" not in resp.json()["vips"][0]
@pytest.mark.asyncio
async def test_network_missing_section_is_reported_not_hidden(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.59", name="fw-old")
_mock(respx_mock, base="https://10.20.30.59", **{
"/interfaces/assignment/searchItem": httpx.Response(404, json={"errorMessage": "Endpoint not found"}),
})
resp = await client.get(f"/api/hosts/{host_id}/network")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["unavailable"] == ["assignments"]
assert len(data["interfaces"]) == 3
@pytest.mark.asyncio
async def test_network_host_unreachable_returns_502(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.60", name="fw-down")
respx_mock.get(url__regex=r".*/interfaces/overview/interfacesInfo").mock(side_effect=httpx.ConnectError("refused"))
assert (await client.get(f"/api/hosts/{host_id}/network")).status_code == 502
@pytest.mark.asyncio
async def test_network_unknown_host_404(client):
resp = await client.get("/api/hosts/00000000-0000-0000-0000-000000000000/network")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_network_tolerates_numbers_where_strings_were_expected(client, respx_mock):
# Seen live on 26.7.x: a gateway's priority arrived as the integer 254.
host_id = await _host(client, respx_mock, base="https://10.20.30.61", name="fw-int")
gateways = {"rows": [{**GATEWAYS["rows"][0], "priority": 254}]}
_mock(respx_mock, base="https://10.20.30.61", **{
"/routing/settings/searchGateway": httpx.Response(200, json=gateways),
})
resp = await client.get(f"/api/hosts/{host_id}/network")
assert resp.status_code == 200, resp.text
assert resp.json()["gateways"][0]["priority"] == "254"
@pytest.mark.asyncio
async def test_network_older_26_7_assignment_list_cannot_configure(client, respx_mock):
# OPNsense 26.7.1: the assignment list only has identifier/descr/if, no type4/ipaddr.
host_id = await _host(client, respx_mock, base="https://10.20.30.62", name="fw-2671")
thin = {"rows": [{"uuid": "wan", "identifier": "wan", "descr": "", "if": "vtnet0"}]}
_mock(respx_mock, base="https://10.20.30.62", **{
"/interfaces/assignment/searchItem": httpx.Response(200, json=thin),
})
data = (await client.get(f"/api/hosts/{host_id}/network")).json()
assert data["can_configure_interfaces"] is False
assert data["unavailable"] == []
@pytest.mark.asyncio
async def test_network_lists_dns_and_dhcp_objects_without_secrets(client, respx_mock):
host_id = await _host(client, respx_mock, base="https://10.20.30.63", name="fw-dns")
_mock(respx_mock, base="https://10.20.30.63")
resp = await client.get(f"/api/hosts/{host_id}/network")
assert "KEA-DDNS-SECRET" not in resp.text
data = resp.json()
assert [(d["hostname"], d["server"]) for d in data["dns_hosts"]] == [("nas", "192.168.1.5")] # alias children are not rows of their own
assert data["dhcp_hosts"][0]["hwaddr"] == "02:00:00:aa:bb:cc"
assert (data["kea_subnets"][0]["subnet"], data["kea_subnets"][0]["routers"]) == ("192.168.77.0/24", "192.168.77.1")
assert data["kea_reservations"][0]["subnet"] == "192.168.77.0/24"
assert data["unavailable"] == []
+559
View File
@@ -0,0 +1,559 @@
"""Writes to VLANs / virtual IPs / gateways / routes, and the safety around them."""
import json
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from tests.test_network import GATEWAYS, INTERFACES_INFO, _host, _mock
MGMT = "https://10.20.30.57"
def _url(path, base=MGMT):
return f"{base}/api{path}"
def _backup(respx_mock, base=MGMT, status=200):
return respx_mock.get(_url("/core/backup/download/this", base)).mock(
return_value=httpx.Response(status, content=b"<opnsense/>"))
def _item(respx_mock, path, key, node, base=MGMT):
respx_mock.get(_url(path, base)).mock(return_value=httpx.Response(200, json={key: node}))
async def _setup(client, respx_mock, name, base=MGMT, **overrides):
host_id = await _host(client, respx_mock, base=base, name=name)
_mock(respx_mock, base=base, **overrides)
return host_id
def _body(call):
return json.loads(call.request.content.decode())
async def _preview(client, host_id, **payload):
return await client.post(f"/api/hosts/{host_id}/network/changes/preview", json=payload)
async def _execute(client, host_id, token, ack=False, **payload):
return await client.post(f"/api/hosts/{host_id}/network/changes",
json={**payload, "preview_token": token, "acknowledge": ack})
async def _audit(host_id):
async with SessionLocal() as db:
rows = await db.scalars(select(AuditEvent).where(AuditEvent.host_id == host_id, AuditEvent.kind == "network")
.order_by(AuditEvent.created_at))
return list(rows.all())
VLAN_ADD = {"kind": "vlan", "action": "add", "fields": {"if": "vtnet1", "tag": "20", "descr": "guests"}}
@pytest.mark.asyncio
async def test_preview_add_is_read_only_and_shows_diff(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c1")
resp = await _preview(client, host_id, **VLAN_ADD)
assert resp.status_code == 200, resp.text
plan = resp.json()
assert plan["can_execute"] is True and plan["requires_acknowledgement"] is False
assert {c["field"] for c in plan["changes"]} == {"if", "tag", "descr"}
assert len(plan["token"]) == 64
assert not [c for c in respx_mock.calls if c.request.method == "POST" and "/api/hosts" not in str(c.request.url)
and "vlan_settings" in str(c.request.url)]
@pytest.mark.asyncio
async def test_execute_add_backs_up_writes_applies_and_verifies(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c2")
backup = _backup(respx_mock)
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "new-vlan"}))
reconf = respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(
return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/interfaces/vlan_settings/getItem/new-vlan", "vlan",
{"if": {"vtnet1": {"selected": 1}}, "tag": "20", "descr": "guests"})
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 200, resp.text
result = resp.json()
assert result["uuid"] == "new-vlan" and result["verified"] is True and result["backup_id"].endswith(".xml")
assert backup.called and reconf.called
assert _body(add.calls.last) == {"vlan": {"if": "vtnet1", "tag": "20", "descr": "guests"}}
assert [e.status for e in await _audit(host_id)] == ["started", "applied"]
@pytest.mark.asyncio
async def test_stale_token_is_refused_and_nothing_is_written(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c3")
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
resp = await _execute(client, host_id, "0" * 64, **VLAN_ADD)
assert resp.status_code == 409
assert not add.called
@pytest.mark.asyncio
async def test_token_changes_when_the_fields_change(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c4")
one = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
other = dict(VLAN_ADD, fields={**VLAN_ADD["fields"], "tag": "21"})
two = (await _preview(client, host_id, **other)).json()["token"]
assert one != two
# a token for the first request cannot be used for the second
assert (await _execute(client, host_id, one, **other)).status_code == 409
@pytest.mark.asyncio
async def test_backup_failure_aborts_before_any_write(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c5")
_backup(respx_mock, status=500)
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 502
assert resp.json()["detail"]["code"] == "NETWORK_BACKUP_FAILED"
assert not add.called
assert [e.status for e in await _audit(host_id)] == ["aborted"]
@pytest.mark.asyncio
async def test_firewall_rejection_is_reported_and_not_applied(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c6")
_backup(respx_mock)
respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "failed", "validations": {"vlan.tag": "bad"}}))
reconf = respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(
return_value=httpx.Response(200, json={"status": "ok"}))
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "UPSTREAM_REJECTED"
assert not reconf.called
assert (await _audit(host_id))[-1].status == "rejected"
@pytest.mark.asyncio
async def test_apply_failure_is_never_reported_as_success(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c7")
_backup(respx_mock)
respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(side_effect=httpx.ReadTimeout("slow"))
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
resp = await _execute(client, host_id, token, **VLAN_ADD)
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "NETWORK_APPLY_UNCERTAIN"
assert resp.json()["detail"]["params"]["backup_id"].endswith(".xml")
assert (await _audit(host_id))[-1].status == "apply_uncertain"
@pytest.mark.asyncio
async def test_unknown_field_is_rejected_not_forwarded(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c8")
resp = await _preview(client, host_id, kind="vlan", action="add", fields={"if": "vtnet1", "tag": "9", "evil": "x"})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "NETWORK_FIELD_INVALID"
@pytest.mark.asyncio
async def test_changing_the_management_vlan_needs_acknowledgement(client, respx_mock):
# fw reached on 192.168.1.2 = the LAN VLAN device
base = "https://192.168.1.2"
vlans = {"rows": [{"uuid": "v-mgmt", "if": "vtnet1", "tag": "10", "pcp": "0", "descr": "", "vlanif": "vtnet1_vlan10 [LAN]"}]}
host_id = await _setup(client, respx_mock, "fw-c9", base=base, **{
"/interfaces/vlan_settings/searchItem": httpx.Response(200, json=vlans)})
_item(respx_mock, "/interfaces/vlan_settings/getItem/v-mgmt", "vlan",
{"if": {"vtnet1": {"selected": 1}}, "tag": "10", "descr": "", "vlanif": "vtnet1_vlan10"}, base=base)
_backup(respx_mock, base)
respx_mock.post(_url("/interfaces/vlan_settings/setItem/v-mgmt", base)).mock(
return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/interfaces/vlan_settings/reconfigure", base)).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "vlan", "action": "set", "uuid": "v-mgmt", "fields": {"descr": "renamed"}}
plan = (await _preview(client, host_id, **change)).json()
assert plan["requires_acknowledgement"] is True
assert plan["risks"][0]["code"] == "management_interface"
refused = await _execute(client, host_id, plan["token"], **change)
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "NETWORK_ACK_REQUIRED"
allowed = await _execute(client, host_id, plan["token"], ack=True, **change)
assert allowed.status_code == 200, allowed.text
assert (await _audit(host_id))[0].details["acknowledged"] is True
@pytest.mark.asyncio
async def test_vip_password_never_leaves_the_server(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c10")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.1/24",
"vhid": "10", "password": "STORED-SECRET", "descr": "CARP LAN", "advskew": "0"})
_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.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)
assert "STORED-SECRET" not in resp.text and "NEW-SECRET" not in resp.text
plan = resp.json()
assert {"field": "password", "before": None, "after": "(gesetzt)"} in plan["changes"]
assert [w["code"] for w in plan["warnings"]] == ["carp_change"]
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200, done.text
assert "NEW-SECRET" not in done.text
assert _body(set_route.calls.last)["vip"]["password"] == "NEW-SECRET" # but it does reach the firewall
for event in await _audit(host_id):
assert "NEW-SECRET" not in json.dumps(event.details) and "STORED-SECRET" not in json.dumps(event.details)
@pytest.mark.asyncio
async def test_default_gateway_and_default_route_need_acknowledgement(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c11")
_item(respx_mock, "/routing/settings/getGateway/g1", "gateway_item",
{"name": "WAN_GW", "interface": {"wan": {"selected": 1}}, "gateway": "10.20.30.1", "priority": "255"})
plan = (await _preview(client, host_id, kind="gateway", action="del", uuid="g1", fields={})).json()
assert {r["code"] for r in plan["risks"]} >= {"default_gateway", "management_gateway"}
route = (await _preview(client, host_id, kind="route", action="add",
fields={"network": "0.0.0.0/0", "gateway": "WAN_GW"})).json()
assert [r["code"] for r in route["risks"]] == ["default_route"]
covers = (await _preview(client, host_id, kind="route", action="add",
fields={"network": "10.20.0.0/16", "gateway": "WAN_GW"})).json()
assert [r["code"] for r in covers["risks"]] == ["route_covers_management"]
harmless = (await _preview(client, host_id, kind="route", action="add",
fields={"network": "172.16.0.0/16", "gateway": "WAN_GW"})).json()
assert harmless["risks"] == [] and harmless["requires_acknowledgement"] is False
@pytest.mark.asyncio
async def test_delete_is_verified_by_absence(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c12")
_item(respx_mock, "/routes/routes/getroute/r1", "route", {"network": "10.9.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "descr": "lab", "enabled": "1"})
_backup(respx_mock)
respx_mock.post(_url("/routes/routes/delroute/r1")).mock(return_value=httpx.Response(200, json={"result": "deleted"}))
respx_mock.post(_url("/routes/routes/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
# after the delete the route list no longer contains it
respx_mock.get(_url("/routes/routes/searchroute")).mock(return_value=httpx.Response(200, json={"rows": []}))
change = {"kind": "route", "action": "del", "uuid": "r1", "fields": {}}
plan = (await _preview(client, host_id, **change)).json()
assert plan["risks"] == [] # 10.9.0.0/16 does not touch the management address
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200 and done.json()["verified"] is True
@pytest.mark.asyncio
async def test_missing_object_is_404(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c13")
respx_mock.get(_url("/interfaces/vlan_settings/getItem/ghost")).mock(return_value=httpx.Response(200, json={"vlan": {}}))
resp = await _preview(client, host_id, kind="vlan", action="del", uuid="ghost", fields={})
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_unknown_management_path_is_treated_as_risky(client, respx_mock):
# The address OPNexus uses belongs to no interface (NAT, or a CARP address): be careful, not optimistic.
base = "https://10.20.30.99"
host_id = await _setup(client, respx_mock, "fw-c14", base=base)
_item(respx_mock, "/routes/routes/getroute/r2", "route", {"network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, base=base)
plan = (await _preview(client, host_id, kind="route", action="set", uuid="r2", fields={"descr": "x"}, )).json()
assert [r["code"] for r in plan["risks"]] == ["management_unknown"]
add = (await _preview(client, host_id, kind="route", action="add", fields={"network": "172.17.0.0/16", "gateway": "WAN_GW"})).json()
assert add["risks"] == [] # a new, harmless object cannot cut anything
@pytest.mark.asyncio
async def test_template_returns_the_firewalls_own_choices(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c15")
_item(respx_mock, "/routes/routes/getroute", "route", {
"network": "", "descr": "", "enabled": "1", "secretless": "x",
"gateway": {"Null4": {"value": "Null4", "selected": 0}, "WAN_GW": {"value": "WAN_GW - 10.20.30.1", "selected": 1}}})
resp = await client.get(f"/api/hosts/{host_id}/network/template/route")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["gateway"]["value"] == "WAN_GW"
assert [o["value"] for o in body["gateway"]["options"]] == ["Null4", "WAN_GW"]
assert "secretless" not in body # only fields that may be sent
assert (await client.get(f"/api/hosts/{host_id}/network/template/bogus")).status_code == 404
@pytest.mark.asyncio
async def test_inventory_reads_route_enabled_flag(client, respx_mock):
# Real 26.7.x route rows say enabled "1"/"0", not disabled.
rows = {"rows": [{"uuid": "r1", "network": "10.9.0.0/16", "gateway": "WAN_GW", "descr": "on", "enabled": "1"},
{"uuid": "r2", "network": "10.8.0.0/16", "gateway": "WAN_GW", "descr": "off", "enabled": "0"}]}
host_id = await _setup(client, respx_mock, "fw-c16", **{"/routes/routes/searchroute": httpx.Response(200, json=rows)})
routes = (await client.get(f"/api/hosts/{host_id}/network")).json()["routes"]
assert [(r["network"], r["disabled"]) for r in routes] == [("10.9.0.0/16", False), ("10.8.0.0/16", True)]
@pytest.mark.asyncio
async def test_delete_preview_lists_only_meaningful_fields(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c17")
_item(respx_mock, "/interfaces/vlan_settings/getItem/v9", "vlan",
{"if": {"vtnet1": {"selected": 1}}, "tag": "40", "descr": "x", "vlanif": "vlan01", "pcp": ""})
plan = (await _preview(client, host_id, kind="vlan", action="del", uuid="v9", fields={})).json()
assert {c["field"] for c in plan["changes"]} == {"if", "tag", "descr"}
@pytest.mark.asyncio
async def test_item_endpoint_returns_current_values_without_secrets(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-c18")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.1/24",
"vhid": "10", "password": "STORED-SECRET", "address": "192.168.1.1/24", "descr": "CARP LAN"})
resp = await client.get(f"/api/hosts/{host_id}/network/item/vip/vip1")
assert resp.status_code == 200, resp.text
assert "STORED-SECRET" not in resp.text
assert resp.json() == {"interface": "lan", "mode": "carp", "network": "192.168.1.1/24", "vhid": "10", "descr": "CARP LAN"}
assert (await client.get(f"/api/hosts/{host_id}/network/item/vip/..%2Fx")).status_code == 404
# ── interface assignment / IP configuration ──────────────────────────────
def _pending(respx_mock, status="ok", base=MGMT):
respx_mock.get(_url("/interfaces/assignment/pending", base)).mock(return_value=httpx.Response(200, json={"status": status}))
IF_ADD = {"kind": "interface", "action": "add", "fields": {"if": "vtnet1_vlan20", "descr": "GUEST", "enable": "1",
"type4": "staticv4", "ipaddr": "10.20.0.1/24"}}
@pytest.mark.asyncio
async def test_interface_add_stages_applies_and_verifies(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i1")
_pending(respx_mock)
_backup(respx_mock)
add = respx_mock.post(_url("/interfaces/assignment/addItem")).mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "opt1"}))
reconf = respx_mock.post(_url("/interfaces/assignment/reconfigure")).mock(
return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface", {
"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1",
"type4": {"staticv4": {"selected": 1}}, "ipaddr": "10.20.0.1/24"})
plan = (await _preview(client, host_id, **IF_ADD)).json()
assert plan["can_execute"] is True and plan["risks"] == []
done = await _execute(client, host_id, plan["token"], **IF_ADD)
assert done.status_code == 200, done.text
assert done.json()["uuid"] == "opt1" and done.json()["verified"] is True
assert _body(add.calls.last)["interface"]["ipaddr"] == "10.20.0.1/24"
assert reconf.called
@pytest.mark.asyncio
async def test_interface_changes_are_blocked_on_firewalls_without_the_api(client, respx_mock):
thin = {"rows": [{"uuid": "wan", "identifier": "wan", "descr": "", "if": "vtnet0"}]} # 26.7.1: no type4
host_id = await _setup(client, respx_mock, "fw-i2", **{"/interfaces/assignment/searchItem": httpx.Response(200, json=thin)})
plan = (await _preview(client, host_id, **IF_ADD)).json()
assert plan["can_execute"] is False
assert [b["code"] for b in plan["blocks"]] == ["interface_api_unavailable"]
resp = await _execute(client, host_id, plan["token"], **IF_ADD)
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "NETWORK_CHANGE_BLOCKED"
@pytest.mark.asyncio
async def test_staged_changes_from_someone_else_block_a_new_change(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i3")
_pending(respx_mock, status="pending")
plan = (await _preview(client, host_id, **IF_ADD)).json()
assert [b["code"] for b in plan["blocks"]] == ["interface_changes_pending"]
add = respx_mock.post(_url("/interfaces/assignment/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
assert (await _execute(client, host_id, plan["token"], **IF_ADD)).status_code == 409
assert not add.called
@pytest.mark.asyncio
async def test_deleting_an_interface_lists_the_rules_that_will_vanish(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i4")
_pending(respx_mock)
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface", {
"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1"})
respx_mock.get(_url("/firewall/filter/searchRule")).mock(return_value=httpx.Response(200, json={"rows": [
{"uuid": "a", "description": "guest out", "interface": "opt1", "is_automatic": False},
{"uuid": "b", "description": "multi", "interface": "lan,opt1", "is_automatic": False},
{"uuid": "c", "description": "other", "interface": "lan", "is_automatic": False},
{"uuid": "d", "description": "system", "interface": "opt1", "is_automatic": True}]}))
plan = (await _preview(client, host_id, kind="interface", action="del", uuid="opt1", fields={})).json()
risk = next(r for r in plan["risks"] if r["code"] == "interface_rules_removed")
assert risk["params"]["count"] == 2 and "guest out" in risk["params"]["names"] and "other" not in risk["params"]["names"]
assert plan["requires_acknowledgement"] is True
@pytest.mark.asyncio
async def test_changing_the_management_interface_needs_acknowledgement(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i5") # reached on 10.20.30.57 = wan
_pending(respx_mock)
_item(respx_mock, "/interfaces/assignment/getItem/wan", "interface", {
"if": {"vtnet0": {"selected": 1}}, "descr": "WAN", "enable": "1", "type4": {"staticv4": {"selected": 1}},
"ipaddr": "10.20.30.57/24"})
plan = (await _preview(client, host_id, kind="interface", action="set", uuid="wan", fields={"ipaddr": "10.20.30.77/24"})).json()
assert [r["code"] for r in plan["risks"]] == ["management_interface"]
assert plan["requires_acknowledgement"] is True
@pytest.mark.asyncio
async def test_locked_interface_cannot_be_deleted_and_says_why(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-i6")
_pending(respx_mock)
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface",
{"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1", "lock": "1"})
respx_mock.get(_url("/firewall/filter/searchRule")).mock(return_value=httpx.Response(200, json={"rows": []}))
plan = (await _preview(client, host_id, kind="interface", action="del", uuid="opt1", fields={})).json()
assert [b["code"] for b in plan["blocks"]] == ["interface_locked"] and plan["can_execute"] is False
# ── DNS / DHCP objects ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_kea_subnet_options_are_sent_nested_and_read_back_flat(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d1")
_backup(respx_mock)
add = respx_mock.post(_url("/kea/dhcpv4/addSubnet")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "sn1"}))
respx_mock.post(_url("/kea/service/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/kea/dhcpv4/getSubnet/sn1", "subnet4", {
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
"option_data": {"routers": {"192.168.50.1": {"value": "192.168.50.1", "selected": 1}, "": {"value": "", "selected": 0}},
"domain_name": "lab.example"}})
change = {"kind": "kea_subnet", "action": "add", "fields": {
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
"option_data.routers": "192.168.50.1", "option_data.domain_name": "lab.example"}}
plan = (await _preview(client, host_id, **change)).json()
assert plan["risks"] == [] # DHCP data cannot cut the connection, even with an unknown management path
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200 and done.json()["verified"] is True, done.text
assert _body(add.calls.last) == {"subnet4": {
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
"option_data": {"routers": "192.168.50.1", "domain_name": "lab.example"}}}
@pytest.mark.asyncio
async def test_dns_override_on_unknown_management_path_is_not_treated_as_risky(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d2", base="https://10.20.30.98")
_item(respx_mock, "/unbound/settings/getHostOverride/h1", "host",
{"hostname": "nas", "domain": "lan.example", "server": "192.168.1.5", "rr": {"A": {"value": "A", "selected": 1}}},
base="https://10.20.30.98")
plan = (await _preview(client, host_id, kind="dns_host", action="set", uuid="h1", fields={"server": "192.168.1.6"})).json()
assert plan["risks"] == [] and plan["can_execute"] is True
assert plan["changes"] == [{"field": "server", "before": "192.168.1.5", "after": "192.168.1.6"}]
@pytest.mark.asyncio
async def test_template_turns_tokenizer_fields_into_comma_text(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d3")
_item(respx_mock, "/dnsmasq/settings/getHost", "host", {
"host": "", "domain": "", "local": "0",
"ip": {"": {"value": "", "selected": 1}},
"hwaddr": {"aa:bb:cc:dd:ee:ff": {"value": "aa:bb:cc:dd:ee:ff", "selected": 1}, "11:22:33:44:55:66": {"value": "11:22:33:44:55:66", "selected": 1}}})
body = (await client.get(f"/api/hosts/{host_id}/network/template/dhcp_host")).json()
assert body["ip"]["value"] == "" and body["hwaddr"]["value"] == "aa:bb:cc:dd:ee:ff,11:22:33:44:55:66"
@pytest.mark.asyncio
async def test_unlisted_secret_fields_never_reach_the_audit_log(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-d4")
_backup(respx_mock)
_item(respx_mock, "/kea/dhcpv4/getSubnet/sn9", "subnet4", {
"subnet": "192.168.9.0/24", "description": "old", "ddns_domain_key_secret": "KEA-DDNS-SECRET"})
respx_mock.post(_url("/kea/dhcpv4/setSubnet/sn9")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/kea/service/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "kea_subnet", "action": "set", "uuid": "sn9", "fields": {"description": "new"}}
plan = (await _preview(client, host_id, **change)).json()
assert (await _execute(client, host_id, plan["token"], **change)).status_code == 200
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
@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).
host_id = await _setup(client, respx_mock, "fw-v1")
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
"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.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"}
@pytest.mark.asyncio
async def test_verification_catches_a_field_that_changed_without_being_asked(client, respx_mock):
# The firewall dropped an untouched field while saving: that must not be reported as a clean success.
host_id = await _setup(client, respx_mock, "fw-v2")
state = iter([
{"descr": "old", "network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # preview (before)
{"descr": "old", "network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # execute re-preview
{"descr": "new", "network": "", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # read-back: network wiped
])
respx_mock.get(_url("/routes/routes/getroute/r5")).mock(side_effect=lambda req: httpx.Response(200, json={"route": next(state)}))
_backup(respx_mock)
respx_mock.post(_url("/routes/routes/setroute/r5")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(_url("/routes/routes/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
change = {"kind": "route", "action": "set", "uuid": "r5", "fields": {"descr": "new"}}
plan = (await _preview(client, host_id, **change)).json()
done = (await _execute(client, host_id, plan["token"], **change)).json()
assert done["verified"] is False and done["mismatches"] == ["network"]
# ── generic object kinds ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_objects_list_returns_only_the_display_columns(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o1")
respx_mock.get(_url("/syslog/settings/searchDestinations")).mock(return_value=httpx.Response(200, json={"rows": [
{"uuid": "d1", "enabled": "1", "transport": "udp4", "hostname": "192.0.2.10", "port": "514", "program": "", "level": "info",
"facility": "daemon", "certificate": "SECRET-CERT-REF", "description": "central"}]}))
body = (await client.get(f"/api/hosts/{host_id}/network/objects/syslog_destination")).json()
assert body["available"] is True and body["rows"][0]["hostname"] == "192.0.2.10"
assert "certificate" not in body["rows"][0] and "facility" not in body["rows"][0]
assert (await client.get(f"/api/hosts/{host_id}/network/objects/vlan")).status_code == 404 # typed kinds have their own lists
@pytest.mark.asyncio
async def test_objects_list_reports_a_missing_api_instead_of_failing(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o2")
respx_mock.get(_url("/interfaces/gre_settings/searchItem")).mock(return_value=httpx.Response(404, json={"errorMessage": "Endpoint not found"}))
body = (await client.get(f"/api/hosts/{host_id}/network/objects/gre")).json()
assert body == {"available": False, "columns": body["columns"], "rows": []}
@pytest.mark.asyncio
async def test_tunables_always_need_acknowledgement_and_defaults_cannot_be_edited(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o3")
add = (await _preview(client, host_id, kind="tunable", action="add", fields={"tunable": "vfs.read_max", "value": "128"})).json()
assert [r["code"] for r in add["risks"]] == ["tunable_change"] and add["requires_acknowledgement"] is True
_item(respx_mock, "/core/tunables/getItem/0add3c58db981d0d24d5d4ab322e3695", "sysctl",
{"tunable": "net.inet.tcp.drop_synfin", "value": "1", "descr": "built in"})
default = (await _preview(client, host_id, kind="tunable", action="set", uuid="0add3c58db981d0d24d5d4ab322e3695", fields={"value": "0"})).json()
assert [b["code"] for b in default["blocks"]] == ["tunable_default_row"] and default["can_execute"] is False
@pytest.mark.asyncio
async def test_lagg_or_bridge_containing_the_management_device_needs_acknowledgement(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o4") # reached on vtnet0
mine = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet0,vtnet2", "proto": "failover"})).json()
assert [r["code"] for r in mine["risks"]] == ["management_member"]
other = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet2,vtnet3", "proto": "failover"})).json()
assert other["risks"] == []
@pytest.mark.asyncio
async def test_vxlan_and_gif_are_listed_and_changed_like_the_other_devices(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o5")
respx_mock.get(_url("/interfaces/vxlan_settings/searchItem")).mock(return_value=httpx.Response(200, json={"rows": [
{"uuid": "x1", "vxlanif": "vxlan1", "vxlanid": "100", "vxlanlocal": "10.20.30.57", "vxlanremote": "10.20.30.58",
"vxlangroup": "", "vxlandev": "lan", "secret_like": "dropped"}]}))
body = (await client.get(f"/api/hosts/{host_id}/network/objects/vxlan")).json()
assert body["rows"][0]["vxlanid"] == "100" and "secret_like" not in body["rows"][0]
_backup(respx_mock)
add = respx_mock.post(_url("/interfaces/gif_settings/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "g1"}))
respx_mock.post(_url("/interfaces/gif_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/interfaces/gif_settings/getItem/g1", "gif", {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"})
change = {"kind": "gif", "action": "add", "fields": {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"}}
plan = (await _preview(client, host_id, **change)).json()
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200 and done.json()["verified"] is True, done.text
assert _body(add.calls.last) == {"gif": change["fields"]}
+94
View File
@@ -0,0 +1,94 @@
"""Network configuration differences between the nodes of an HA cluster."""
import httpx
import pytest
from app.database import SessionLocal
from app.schemas.network import HostNetwork
from app.services import network_drift_service, notification_checks
from tests.test_network import DHCP_HOSTS, DNS_HOSTS, GATEWAYS, INTERFACES_INFO, KEA_RESERVATIONS, KEA_SUBNETS, ROUTES, VIPS, VLANS, ASSIGNMENTS
ENDPOINTS = {
"/interfaces/overview/interfacesInfo": INTERFACES_INFO, "/interfaces/assignment/searchItem": ASSIGNMENTS,
"/interfaces/vlan_settings/searchItem": VLANS, "/interfaces/vip_settings/searchItem": VIPS,
"/routing/settings/searchGateway": GATEWAYS, "/routes/routes/searchroute": ROUTES,
"/unbound/settings/searchHostOverride": DNS_HOSTS, "/dnsmasq/settings/searchHost": DHCP_HOSTS,
"/kea/dhcpv4/searchSubnet": KEA_SUBNETS, "/kea/dhcpv4/searchReservation": KEA_RESERVATIONS,
}
async def _host(client, name, base, cluster="ha"):
r = await client.post("/api/hosts", json={"name": name, "base_url": base, "api_key": "k", "api_secret": "s",
"verify_tls": False, "cluster_name": cluster})
assert r.status_code == 201, r.text
def _serve(respx_mock, base, **replace):
for path, body in ENDPOINTS.items():
respx_mock.get(f"{base}/api{path}").mock(return_value=httpx.Response(200, json=replace.get(path, body)))
def _net(**replace) -> HostNetwork:
data = {
"host_id": "x", "host_name": "x", "interfaces": [], "vlans": [], "vips": [], "gateways": [], "routes": [],
"management_device": None, "can_configure_interfaces": True, "unavailable": [],
}
data.update(replace)
return HostNetwork(**data)
def test_identical_configuration_has_no_differences_even_with_per_node_values():
vip_a = {"uuid": "1", "interface": "lan", "interface_label": "LAN", "mode": "carp", "address": "192.168.1.1/24",
"vhid": "10", "advbase": "1", "advskew": "0", "description": "x"}
vip_b = {**vip_a, "uuid": "2", "advskew": "100"} # advskew legitimately differs between master and backup
a, b = _net(vips=[vip_a]), _net(vips=[vip_b])
assert network_drift_service.compare({"a": a, "b": b}) == []
def test_missing_and_different_objects_are_reported_with_who_has_what():
vlan = {"uuid": "1", "parent": "vtnet1", "tag": "10", "pcp": "0", "description": "LAN", "device": "vtnet1_vlan10"}
gw_a = {"uuid": "g", "name": "WAN_GW", "description": "", "interface": "wan", "interface_label": "WAN", "ip_protocol": "inet",
"address": "10.0.0.1", "is_default": True, "disabled": False, "priority": "255", "status": "Online"}
a = _net(vlans=[vlan, {**vlan, "uuid": "2", "tag": "20"}], gateways=[gw_a])
b = _net(vlans=[vlan], gateways=[{**gw_a, "address": "10.0.0.9"}])
rows = {(r["category"], r["key"]): r for r in network_drift_service.compare({"a": a, "b": b})}
assert rows[("vlans", "20@vtnet1")]["status"] == "missing" and rows[("vlans", "20@vtnet1")]["missing_from"] == ["b"]
assert rows[("gateways", "WAN_GW")]["status"] == "different"
assert rows[("gateways", "WAN_GW")]["signatures"]["a"] != rows[("gateways", "WAN_GW")]["signatures"]["b"]
assert ("vlans", "10@vtnet1") not in rows
def test_a_category_one_node_cannot_deliver_is_not_reported_as_a_difference():
vlan = {"uuid": "1", "parent": "vtnet1", "tag": "10", "pcp": "0", "description": "", "device": "d"}
a, b = _net(vlans=[vlan]), _net(vlans=[], unavailable=["vlans"])
assert network_drift_service.compare({"a": a, "b": b}) == []
@pytest.mark.asyncio
async def test_endpoint_and_alert_report_a_route_missing_on_one_node(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
await _host(client, "fw-da", "https://10.20.30.91")
await _host(client, "fw-db", "https://10.20.30.92")
_serve(respx_mock, "https://10.20.30.91")
_serve(respx_mock, "https://10.20.30.92", **{"/routes/routes/searchroute": {"rows": []}})
body = (await client.get("/api/network/drift")).json()
cluster = next(c for c in body["clusters"] if c["name"] == "ha")
assert cluster["unreachable"] == []
diff = [d for d in cluster["differences"] if d["category"] == "routes"]
assert [(d["key"], d["status"], d["missing_from"]) for d in diff] == [("10.9.0.0/16", "missing", ["fw-db"])]
async with SessionLocal() as db:
problems = await notification_checks.check_network_drift(db)
mine = [v for k, v in problems.items() if k.startswith("ha:routes:")]
assert mine and mine[0]["missing_from"] == ["fw-db"] and "Route" in mine[0]["object"]
@pytest.mark.asyncio
async def test_an_unreachable_node_keeps_open_alerts_instead_of_resolving_them(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
await _host(client, "fw-dc", "https://10.20.30.93", cluster="ha2")
await _host(client, "fw-dd", "https://10.20.30.94", cluster="ha2")
_serve(respx_mock, "https://10.20.30.93")
respx_mock.get(url__regex=r"https://10\.20\.30\.94/.*").mock(side_effect=httpx.ConnectError("down"))
async with SessionLocal() as db:
problems = await notification_checks.check_network_drift(db)
assert "ha2:" in problems.preserve_prefixes
+35
View File
@@ -177,6 +177,41 @@ async def test_check_rule_drift_no_drift_when_identical(client, respx_mock):
assert problems == {}
@pytest.mark.asyncio
async def test_drift_stays_open_while_a_cluster_member_is_unreachable(client, respx_mock):
"""Live 26.7.5: rebooting one firewall made every open drift problem
resolve (only one host reachable => nothing to compare) and re-open
afterwards, sending a resolved+opened pair per problem."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
await _create_host(client, "fw-a", "https://fw-a.test", cluster_name="ha")
await _create_host(client, "fw-b", "https://fw-b.test", cluster_name="ha")
respx_mock.get("https://fw-a.test/api/firewall/filter/searchRule").mock(
return_value=httpx.Response(200, json={"rows": [_live_rule_row("r1", "Allow SSH"), _live_rule_row("r2", "Only A")]})
)
b = respx_mock.get("https://fw-b.test/api/firewall/filter/searchRule").mock(
return_value=httpx.Response(200, json={"rows": [_live_rule_row("r1", "Allow SSH")]})
)
async with SessionLocal() as db:
current = await notification_checks.check_rule_drift(db)
opened, resolved = await notification_dedup.diff_and_update(db, "rule_drift", current)
await db.commit()
assert len(opened) == 1 and not resolved
b.mock(side_effect=httpx.ConnectError("down")) # fw-b reboots
current = await notification_checks.check_rule_drift(db)
assert dict(current) == {}
opened, resolved = await notification_dedup.diff_and_update(db, "rule_drift", current)
await db.commit()
assert not opened and not resolved # still open, nothing sent
b.mock(return_value=httpx.Response(200, json={"rows": [_live_rule_row("r1", "Allow SSH")]}))
current = await notification_checks.check_rule_drift(db)
opened, resolved = await notification_dedup.diff_and_update(db, "rule_drift", current)
await db.commit()
assert not opened and not resolved # same problem, no duplicate "opened"
# ── check_revert_failed / check_maintenance_unknown ─────────────────────
@pytest.mark.asyncio
+21 -2
View File
@@ -17,17 +17,29 @@ from tests.test_openvpn import _create_host, _instance_payload
from tests.helpers import deploy_approved
def _mock_vpnid_search(respx_mock, base_url: str, rows=None):
respx_mock.get(f"{base_url}/api/openvpn/instances/search").mock(
return_value=httpx.Response(200, json={"rows": rows or []})
)
def _mock_full_success(respx_mock, base_url: str, with_statickey: bool = False):
if with_statickey:
respx_mock.post(f"{base_url}/api/openvpn/instances/addStaticKey").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "statickey-1"})
)
respx_mock.post(f"{base_url}/api/openvpn/instances/add").mock(
# The firewall does not assign vpnid itself (live 26.7.5: "A value is
# required."), so the deploy asks for the existing ones first.
respx_mock.get(f"{base_url}/api/openvpn/instances/search").mock(
return_value=httpx.Response(200, json={"rows": [{"uuid": "x", "vpnid": "3"}, {"uuid": "y", "vpnid": "1"}]})
)
add_route = respx_mock.post(f"{base_url}/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
respx_mock.post(f"{base_url}/api/openvpn/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "ok"})
)
return add_route
@pytest.mark.asyncio
@@ -37,13 +49,14 @@ async def test_deploy_success_persists_instance_uuid(client, respx_mock):
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
add_route = _mock_full_success(respx_mock, "https://fw1.test")
resp = await deploy_approved(client, f"/api/openvpn/instances/{instance_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "success"
assert dep["message"] is None
assert json.loads(add_route.calls.last.request.content)["instance"]["vpnid"] == "4"
async with SessionLocal() as db:
row = (await db.execute(select(OpenVpnInstanceDeployment))).scalar_one()
@@ -85,6 +98,7 @@ async def test_deploy_config_write_failure_surfaces_as_error_without_rollback(cl
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
side_effect=httpx.ConnectError("connection refused")
)
@@ -115,6 +129,7 @@ async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, r
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
@@ -150,6 +165,7 @@ async def test_reconfigure_checks_result_key_explicitly(client, respx_mock):
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
@@ -174,6 +190,7 @@ async def test_redeploy_unchanged_skips_opnsense_entirely(client, respx_mock):
_mock_full_success(respx_mock, "https://fw1.test")
await deploy_approved(client, f"/api/openvpn/instances/{instance_id}/deploy")
_mock_vpnid_search(respx_mock, "https://fw1.test")
add_route = respx_mock.post("https://fw1.test/api/openvpn/instances/add")
resp = await deploy_approved(client, f"/api/openvpn/instances/{instance_id}/deploy")
@@ -241,6 +258,7 @@ async def test_multi_host_deploy_sends_each_host_its_own_cert_and_ca(client, res
instance_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
_mock_vpnid_search(respx_mock, "https://fw2.test")
respx_mock.post("https://fw2.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-2"})
)
@@ -271,6 +289,7 @@ async def test_host_missing_cert_selection_fails_closed_without_any_call(client,
)
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
add_route = respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
+120
View File
@@ -0,0 +1,120 @@
"""Taking over rules that were created by hand on a firewall."""
import json
import httpx
import pytest
BASE = "https://fw-adopt.test"
def _row(uuid, **over):
row = {
"uuid": uuid, "description": "Manual SSH", "enabled": "1", "action": "pass", "quick": "1",
"interface": "lan", "%interface": "LAN", "interfacenot": "0", "direction": "in", "ipprotocol": "inet",
"protocol": "TCP", "source_net": "10.0.0.0/8", "source_not": "0", "source_port": "",
"destination_net": "any", "destination_not": "0", "destination_port": "22", "log": "1",
"received-on": "", "gateway": "WAN_GW", "is_automatic": False,
}
row.update(over)
return row
async def _host(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
r = await client.post("/api/hosts", json={"name": "fw-adopt", "base_url": BASE, "api_key": "k", "api_secret": "s"})
return r.json()["id"]
def _live(respx_mock, rows):
return respx_mock.get(f"{BASE}/api/firewall/filter/searchRule").mock(
return_value=httpx.Response(200, json={"rows": rows, "total": len(rows)})
)
@pytest.mark.asyncio
async def test_unmanaged_lists_adoptable_and_refused_with_reasons(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [
_row("u-ok"),
_row("u-neg", description="Not source", source_not="1"),
_row("u-multi", description="Two ifaces", interface="lan,opt1"),
_row("u-float", description="Late", quick="0"),
_row("u-auto", description="anti-lockout rule", is_automatic=True),
])
resp = await client.get(f"/api/rules/unmanaged?host_id={host_id}")
assert resp.status_code == 200, resp.text
by_uuid = {r["uuid"]: r for r in resp.json()}
assert "u-auto" not in by_uuid # system rules are never offered
assert by_uuid["u-ok"]["adoptable"] is True and by_uuid["u-ok"]["log"] is True
assert by_uuid["u-neg"]["reasons"] == ["negated_source"]
assert by_uuid["u-multi"]["reasons"] == ["interface_not_single"]
assert by_uuid["u-float"]["reasons"] == ["not_quick"]
@pytest.mark.asyncio
async def test_adopt_records_rule_without_writing_to_the_firewall(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-ok")])
resp = await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})
assert resp.status_code == 201, resp.text
rule = resp.json()
assert (rule["description"], rule["interface"], rule["source_net"], rule["destination_port"]) == (
"Manual SSH", "lan", "10.0.0.0/8", "22")
assert rule["log"] is True and rule["enabled"] is True
assert rule["target_host_ids"] == [host_id]
assert [d["status"] for d in rule["deployments"]] == ["success"]
assert rule["workflow_state"] == "synced"
assert all(call.request.method == "GET" for call in respx_mock.calls if "/filter/" in str(call.request.url))
# no longer offered afterwards
again = await client.get(f"/api/rules/unmanaged?host_id={host_id}")
assert again.json() == []
@pytest.mark.asyncio
async def test_adopt_refuses_unrepresentable_rule(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-neg", source_not="1")])
resp = await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-neg"})
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "RULE_NOT_ADOPTABLE"
assert "negated_source" in resp.json()["detail"]["params"]["reasons"]
assert (await client.get("/api/rules")).json() == [] or all(r["description"] != "Manual SSH" for r in (await client.get("/api/rules")).json())
@pytest.mark.asyncio
async def test_adopt_unknown_uuid_is_404_and_double_adopt_is_refused(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-ok")])
assert (await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "nope"})).status_code == 404
assert (await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})).status_code == 201
second = await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})
assert second.status_code == 409
assert "already_managed" in second.json()["detail"]["params"]["reasons"]
@pytest.mark.asyncio
async def test_edit_after_adopt_updates_same_remote_rule_and_keeps_log(client, respx_mock):
host_id = await _host(client, respx_mock)
_live(respx_mock, [_row("u-ok")])
rule = (await client.post("/api/rules/adopt", json={"host_id": host_id, "rule_uuid": "u-ok"})).json()
edit = {
"description": "Manual SSH (edited)", "interface": "lan", "direction": "in", "ipprotocol": "inet",
"protocol": "TCP", "action": "pass", "source_net": "10.0.0.0/8", "source_port": None,
"destination_net": "any", "destination_port": "22", "enabled": True, "log": True,
"target_host_ids": [host_id], "target_group_ids": [], "expected_updated_at": rule["updated_at"],
}
assert (await client.put(f"/api/rules/{rule['id']}", json=edit)).status_code == 200
set_route = respx_mock.post(f"{BASE}/api/firewall/filter/setRule/u-ok").mock(
return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(f"{BASE}/api/firewall/filter/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
from tests.helpers import deploy_approved
resp = await deploy_approved(client, f"/api/rules/{rule['id']}/deploy")
assert resp.status_code == 200, resp.text
assert set_route.called
body = set_route.calls.last.request.content.decode()
sent = json.loads(body)["rule"]
assert sent["description"] == "Manual SSH (edited)"
assert sent["log"] == "1" # not silently reset by the push
assert "gateway" not in sent # fields OPNexus does not model are never sent, so they stay as they are
+335
View File
@@ -0,0 +1,335 @@
"""Scheduled maintenance with the opt-in CARP handover: MASTER -> maintenance -> partner MASTER ->
action -> node back -> leave maintenance. A tiny fake cluster stands in for the two firewalls."""
import uuid
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models import HostOperation
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import backup_store, scheduled_maintenance_service as svc
from tests.test_rules import _create_host
A, B = "https://fw-a.test", "https://fw-b.test"
class FakeCluster:
"""fw-a is the node being maintained (MASTER), fw-b the partner."""
def __init__(self, respx_mock, *, toggle_flips=True, partner_follows=True, toggle_error=None, firmware="update"):
self.mock = respx_mock
self.roles = {A: {"role": "MASTER", "maintenance": False}, B: {"role": "BACKUP", "maintenance": False}}
self.job = "ready" # what fw-a's firmware/running reports: ready | running | down
self.toggle_flips, self.partner_follows, self.toggle_error, self.firmware = toggle_flips, partner_follows, toggle_error, firmware
self.toggles = respx_mock.post(f"{A}/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=self._toggle)
self.update = respx_mock.post(f"{A}/api/core/firmware/update").respond(200, json={"status": "ok"})
self.reboot = respx_mock.post(f"{A}/api/core/system/reboot").respond(200, json={"status": "ok"})
self.backup = respx_mock.get(f"{A}/api/core/backup/download/this").respond(200, content=b"<opnsense/>")
for base in (A, B):
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=lambda r, b=base: self._vip(b))
respx_mock.get(f"{base}/api/core/firmware/info").respond(200, json={"product_version": "26.7.5"})
respx_mock.get(f"{base}/api/core/firmware/status").respond(
200, json={"status": firmware, "all_packages": {}, "status_msg": "x", "status_reboot": "0"})
respx_mock.get(f"{A}/api/core/firmware/running").mock(side_effect=self._running_a)
respx_mock.get(f"{B}/api/core/firmware/running").respond(200, json={"status": "ready"})
def _vip(self, base):
if base == A and self.job == "down":
raise httpx.ConnectError("rebooting")
cur = self.roles[base]
return httpx.Response(200, json={"rows": [{"vhid": "1", "status": cur["role"]}], "carp": {
"allow": "1", "maintenancemode": cur["maintenance"], "demotion": "240" if cur["maintenance"] else "0", "status_msg": ""}})
def _running_a(self, request):
if self.job == "down":
raise httpx.ConnectError("rebooting")
return httpx.Response(200, json={"status": self.job})
def _toggle(self, request):
if self.toggle_error:
raise self.toggle_error
cur = self.roles[A]
cur["maintenance"] = not cur["maintenance"]
entering = cur["maintenance"]
if self.toggle_flips:
cur["role"] = "BACKUP" if entering else "MASTER"
if self.partner_follows:
self.roles[B]["role"] = "MASTER" if entering else "BACKUP"
return httpx.Response(200, json={"status": "ok", "action": "enter_maintenance" if entering else "leave_maintenance"})
async def _setup(client, **kwargs):
a = await _create_host(client, "fw-a", A)
b = await _create_host(client, "fw-b", B)
await client.patch(f"/api/hosts/{a['id']}", json={"cluster_name": "ha"})
await client.patch(f"/api/hosts/{b['id']}", json={"cluster_name": "ha"})
return a, b
async def _plan(host, action="update", handover=True):
async with SessionLocal() as db:
s = ScheduledMaintenance(
host_id=uuid.UUID(host["id"]), host_name=host["name"], action=action, carp_handover=handover,
run_at=datetime.now(timezone.utc) - timedelta(minutes=1), actor="admin", created_at=datetime.now(timezone.utc))
db.add(s)
await db.commit()
return s.id
async def _fire():
async with SessionLocal() as db:
return await svc.fire_due(db)
async def _advance():
async with SessionLocal() as db:
return await svc.advance_running(db)
async def _get(sched_id):
async with SessionLocal() as db:
s = await db.get(ScheduledMaintenance, sched_id)
op = await db.get(HostOperation, s.host_operation_id) if s.host_operation_id else None
return s, op
async def _age(sched_id, *, phase=None, job=None):
"""Pretend the phase (or the job) started `phase`/`job` ago."""
async with SessionLocal() as db:
s = await db.get(ScheduledMaintenance, sched_id)
if phase is not None:
s.phase_since = datetime.now(timezone.utc) - phase
if job is not None:
s.progress = {**s.progress, "job_started_at": (datetime.now(timezone.utc) - job).isoformat()}
await db.commit()
# ── happy paths ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_update_with_handover_runs_end_to_end(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
assert (await _fire())["fired"] == 1
s, op = await _get(sid)
assert (s.status, s.phase, op.status) == ("running", "handover_wait", "starting")
assert cluster.backup.call_count == 1 and cluster.toggles.call_count == 1 and cluster.update.call_count == 0
assert cluster.roles[B]["role"] == "MASTER"
assert await _advance() == 1 # partner confirmed -> job submitted
s, op = await _get(sid)
assert (s.phase, op.status) == ("job_wait", "submitted") and cluster.update.call_count == 1
cluster.job = "running"
await _advance()
cluster.job = "ready"
await _advance()
assert (await _get(sid))[0].phase == "leave"
assert cluster.toggles.call_count == 1 # not back yet
await _advance() # leave: toggles once
assert cluster.toggles.call_count == 2 and (await _get(sid))[0].phase == "leave_wait"
assert await _advance() == 0 # roles back to normal -> done
s, op = await _get(sid)
assert s.status == "succeeded" and s.phase is None and s.result["handover"] is True
assert s.result["version_before"] == "26.7.5"
assert op.status == "acknowledged"
assert cluster.roles[A] == {"role": "MASTER", "maintenance": False}
assert cluster.update.call_count == 1 and cluster.toggles.call_count == 2
@pytest.mark.asyncio
async def test_reboot_with_handover_needs_to_see_the_node_go_down(client, respx_mock):
cluster = FakeCluster(respx_mock, firmware="none")
a, _b = await _setup(client)
sid = await _plan(a, "reboot")
await _fire()
assert cluster.backup.call_count == 0 # no backup for a plain reboot
await _advance()
assert cluster.reboot.call_count == 1
await _advance() # still "ready" and never seen down: not a finished reboot
assert (await _get(sid))[0].phase == "job_wait"
cluster.job = "down"
await _advance()
cluster.job = "ready"
await _advance()
assert (await _get(sid))[0].phase == "leave"
await _advance()
await _advance()
assert (await _get(sid))[0].status == "succeeded"
@pytest.mark.asyncio
async def test_handover_flag_on_a_backup_node_just_runs_the_action(client, respx_mock):
cluster = FakeCluster(respx_mock)
cluster.roles[A].update(role="BACKUP")
cluster.roles[B].update(role="MASTER")
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, _op = await _get(sid)
assert s.status == "succeeded" and s.phase is None
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 1
@pytest.mark.asyncio
async def test_without_the_flag_a_master_is_still_refused(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update", handover=False)
await _fire()
s, _ = await _get(sid)
assert s.status == "failed" and "CARP-BACKUP" in s.result["error"]
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 0
# ── failures: what happens to the node ────────────────────────────────────
@pytest.mark.asyncio
async def test_partner_never_takes_over_restores_normal_operation(client, respx_mock):
cluster = FakeCluster(respx_mock, toggle_flips=False)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
cluster.roles[A]["maintenance"] = True # the toggle was accepted, but no role change happened
await _advance() # too early: keep waiting
assert (await _get(sid))[0].phase == "handover_wait" and cluster.toggles.call_count == 1
await _age(sid, phase=timedelta(minutes=4))
await _advance()
s, op = await _get(sid)
assert s.status == "failed" and "wiederhergestellt" in s.result["error"] and op.status == "cancelled"
assert cluster.toggles.call_count == 2 and cluster.roles[A]["maintenance"] is False
assert cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_demoted_node_but_partner_not_master_never_starts_the_job(client, respx_mock):
"""The dangerous case: both nodes BACKUP, nobody carries traffic. The action must not start."""
cluster = FakeCluster(respx_mock, partner_follows=False)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
assert cluster.roles[A]["role"] == "BACKUP" and cluster.roles[B]["role"] == "BACKUP"
await _advance()
assert (await _get(sid))[0].phase == "handover_wait" and cluster.update.call_count == 0
await _age(sid, phase=timedelta(minutes=4))
await _advance()
s, op = await _get(sid)
assert s.status == "failed" and op.status == "cancelled" and cluster.update.call_count == 0
assert cluster.roles[A]["maintenance"] is False # normal operation restored
@pytest.mark.asyncio
async def test_unclear_toggle_answer_is_never_replayed(client, respx_mock):
cluster = FakeCluster(respx_mock, toggle_error=httpx.ReadTimeout("timeout"))
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, op = await _get(sid)
assert s.status == "failed" and op.status == "unknown" and "unklar" in s.result["error"]
assert await _advance() == 0
assert cluster.toggles.call_count == 1 and cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_job_timeout_leaves_the_node_demoted(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
await _advance()
cluster.job = "running"
await _age(sid, job=timedelta(minutes=61))
await _advance()
s, op = await _get(sid)
assert s.status == "failed" and op.status == "unknown" and "Wartungsmodus" in s.result["error"]
assert cluster.roles[A]["maintenance"] is True and cluster.toggles.call_count == 1 # the safe side: partner keeps traffic
@pytest.mark.asyncio
async def test_blocked_preconditions_change_nothing(client, respx_mock):
cluster = FakeCluster(respx_mock)
cluster.roles[B]["role"] = "MASTER" # no BACKUP partner to hand over to (split-brain-like)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, op = await _get(sid)
assert s.status == "failed" and op is None
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_failed_backup_aborts_before_any_switch(client, respx_mock):
cluster = FakeCluster(respx_mock)
respx_mock.get(f"{A}/api/core/backup/download/this").mock(side_effect=httpx.ConnectError("down"))
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, _ = await _get(sid)
assert s.status == "failed" and "nichts wurde verändert" in s.result["error"]
assert cluster.toggles.call_count == 0
@pytest.mark.asyncio
async def test_run_stops_when_a_human_closes_the_operation(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
_s, op = await _get(sid)
async with SessionLocal() as db:
(await db.get(HostOperation, op.id)).status = "cancelled"
await db.commit()
assert await _advance() == 0
s, _ = await _get(sid)
assert s.status == "failed" and "von Hand" in s.result["error"]
assert cluster.toggles.call_count == 1 and cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_step_survives_backend_restart(client, respx_mock):
"""Every step reads its state from the row; nothing lives in memory between ticks."""
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
async with SessionLocal() as db: # a fresh session per step is what a restarted backend does
assert await svc.advance_running(db) == 1
async with SessionLocal() as db:
s = await db.get(ScheduledMaintenance, sid)
assert s.phase == "job_wait" and s.progress["job_started_at"]
# ── API ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_schedule_api_accepts_handover_for_the_master_only_with_the_flag(client, respx_mock):
FakeCluster(respx_mock)
a, _b = await _setup(client)
when = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
url = f"/api/hosts/{a['id']}/maintenance/schedule"
plain = await client.post(url, json={"action": "update", "run_at": when, "acknowledge_unattended": True})
assert plain.status_code == 409
ok = await client.post(url, json={"action": "update", "run_at": when, "acknowledge_unattended": True, "carp_handover": True})
assert ok.status_code == 200 and ok.json()["carp_handover"] is True and ok.json()["phase"] is None
@pytest.mark.asyncio
async def test_schedule_api_handover_needs_a_cluster(client, respx_mock):
respx_mock.get("https://solo.test/api/diagnostics/interface/getVipStatus").respond(200, json={"rows": []})
h = await _create_host(client, "solo", "https://solo.test")
when = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
r = await client.post(f"/api/hosts/{h['id']}/maintenance/schedule",
json={"action": "reboot", "run_at": when, "acknowledge_unattended": True, "carp_handover": True})
assert r.status_code == 409
assert r.json()["detail"]["code"] == "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
+165 -10
View File
@@ -1,6 +1,8 @@
import json
import uuid
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
@@ -76,25 +78,58 @@ async def test_fire_due_reboot_success(client, respx_mock):
assert reboot.call_count == 1
@pytest.mark.asyncio
async def test_fire_due_update_blocked_without_fresh_backup(client, respx_mock):
mock_reads(respx_mock, status="update")
h = await _create_host(client, "fw1", "https://fw1.test")
async def _due_update(h, action="update"):
async with SessionLocal() as db:
sched = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="update",
host_id=uuid.UUID(h["id"]), host_name="fw1", action=action,
run_at=datetime.now(timezone.utc) - timedelta(minutes=1),
actor="admin", created_at=datetime.now(timezone.utc),
)
db.add(sched)
await db.commit()
return sched.id
result = await scheduled_maintenance_service.fire_due(db)
assert result == {"due": 1, "fired": 0, "blocked_or_failed": 1}
refreshed = await db.get(ScheduledMaintenance, sched.id)
@pytest.mark.asyncio
async def test_fire_due_update_takes_its_own_backup_first(client, respx_mock):
mock_reads(respx_mock, status="update")
h = await _create_host(client, "fw1", "https://fw1.test")
download = respx_mock.get("https://fw1.test/api/core/backup/download/this").respond(200, content=b"<opnsense/>")
update = respx_mock.post("https://fw1.test/api/core/firmware/update").respond(200, json={"status": "ok"})
sched_id = await _due_update(h)
async with SessionLocal() as db:
assert await scheduled_maintenance_service.fire_due(db) == {"due": 1, "fired": 1, "blocked_or_failed": 0}
assert (await db.get(ScheduledMaintenance, sched_id)).status == "succeeded"
assert download.call_count == 1 and update.call_count == 1
assert len(backup_store.list_snapshots(uuid.UUID(h["id"]))) == 1
@pytest.mark.asyncio
async def test_fire_due_update_fails_closed_when_backup_fails(client, respx_mock):
mock_reads(respx_mock, status="update")
h = await _create_host(client, "fw1", "https://fw1.test")
respx_mock.get("https://fw1.test/api/core/backup/download/this").mock(side_effect=httpx.ConnectError("down"))
update = respx_mock.post("https://fw1.test/api/core/firmware/update").respond(200, json={"status": "ok"})
sched_id = await _due_update(h)
async with SessionLocal() as db:
assert await scheduled_maintenance_service.fire_due(db) == {"due": 1, "fired": 0, "blocked_or_failed": 1}
refreshed = await db.get(ScheduledMaintenance, sched_id)
assert refreshed.status == "failed"
assert "Konfigurationsbackup" in refreshed.result["error"]
assert "Backup" in refreshed.result["error"]
assert update.call_count == 0
@pytest.mark.asyncio
async def test_fire_due_reboot_takes_no_backup(client, respx_mock):
mock_reads(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
respx_mock.post("https://fw1.test/api/core/system/reboot").respond(200, json={"status": "ok"})
sched_id = await _due_update(h, action="reboot") # no backup route mocked: a download would raise
async with SessionLocal() as db:
await scheduled_maintenance_service.fire_due(db)
assert (await db.get(ScheduledMaintenance, sched_id)).status == "succeeded"
@pytest.mark.asyncio
@@ -126,3 +161,123 @@ async def test_fire_due_blocked_by_active_operation_on_cluster_peer(client, resp
assert result == {"due": 1, "fired": 0, "blocked_or_failed": 1}
refreshed = await db.get(ScheduledMaintenance, sched.id)
assert refreshed.status == "failed"
# ── CARP role is checked when the plan is created, not first at 3 a.m. ────
def _cluster_roles(respx_mock, roles):
for base, role in roles.items():
if role is None:
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=httpx.ConnectError("down"))
else:
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").respond(
200, json={"rows": [{"vhid": "1", "status": role}], "carp": {"allow": "1", "maintenancemode": "0", "demotion": "0"}}
)
respx_mock.get(f"{base}/api/core/firmware/running").respond(200, json={"status": "ready"})
async def _cluster(client, a_url="https://fw-a.test", b_url="https://fw-b.test"):
a = await _create_host(client, "fw-a", a_url)
b = await _create_host(client, "fw-b", b_url)
await client.patch(f"/api/hosts/{a['id']}", json={"cluster_name": "ha"})
await client.patch(f"/api/hosts/{b['id']}", json={"cluster_name": "ha"})
return a, b
@pytest.mark.asyncio
async def test_schedule_on_carp_master_is_rejected_up_front(client, respx_mock):
_cluster_roles(respx_mock, {"https://fw-a.test": "MASTER", "https://fw-b.test": "BACKUP"})
a, b = await _cluster(client)
rejected = await schedule(client, a, action="update")
assert rejected.status_code == 409, rejected.text
assert rejected.json()["detail"]["code"] == "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
assert (await client.get(f"/api/hosts/{a['id']}/maintenance/schedule")).json() == []
# The backup node is where unattended maintenance belongs.
accepted = await schedule(client, b, action="update")
assert accepted.status_code == 200, accepted.text
@pytest.mark.asyncio
async def test_schedule_accepted_when_role_cannot_be_determined(client, respx_mock):
# Unreachable now does not mean wrong at run time — the full check still runs when it fires.
_cluster_roles(respx_mock, {"https://fw-a.test": None, "https://fw-b.test": "MASTER"})
a, _b = await _cluster(client)
assert (await schedule(client, a)).status_code == 200
@pytest.mark.asyncio
async def test_schedule_on_standalone_host_skips_role_check(client, respx_mock):
mock_reads(respx_mock, carp="MASTER")
h = await _create_host(client, "fw1", "https://fw1.test")
assert (await schedule(client, h)).status_code == 200
# ── a plan that did not run is reported ───────────────────────────────────
async def _only_enable(db, flag):
from app.services import notification_settings_service
row = await notification_settings_service.get_settings(db)
for column in row.__table__.columns.keys():
if column.endswith("_enabled"):
setattr(row, column, column == flag)
row.webhook_enabled = True
row.webhook_url = "https://hooks.test/notify"
await db.commit()
@pytest.mark.asyncio
async def test_failed_schedule_is_notified_once_and_closes_silently(client, respx_mock):
from app.models.notification import NotificationProblem
from app.services.notification_service import run_notification_checks
mock_reads(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
hook = respx_mock.post("https://hooks.test/notify").respond(200)
async with SessionLocal() as db:
await _only_enable(db, "scheduled_maintenance_failed_enabled")
now = datetime.now(timezone.utc)
failed = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="update", run_at=now - timedelta(hours=2),
status="failed", actor="admin", created_at=now - timedelta(days=1),
result={"error": "Cluster-Wartung nur auf dem CARP-BACKUP."},
)
ok = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="reboot", run_at=now - timedelta(hours=1),
status="succeeded", actor="admin", created_at=now - timedelta(days=1),
)
old = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="reboot", run_at=now - timedelta(days=10),
status="failed", actor="admin", created_at=now - timedelta(days=11),
)
db.add_all([failed, ok, old])
await db.commit()
assert (await run_notification_checks(db))["opened"] == 1
assert hook.call_count == 1
sent = json.loads(hook.calls.last.request.content)
assert sent["problem_type"] == "scheduled_maintenance_failed"
assert sent["context"]["host_name"] == "fw1" and "CARP-BACKUP" in sent["context"]["message"]
# Still failed on the next poll: no second message.
assert (await run_notification_checks(db))["opened"] == 0
assert hook.call_count == 1
# Once it drops out of the window nothing was "fixed" — no "behoben" message.
failed.run_at = now - timedelta(days=4)
await db.commit()
result = await run_notification_checks(db)
assert result["resolved"] == 1
assert hook.call_count == 1
problem = (await db.execute(select(NotificationProblem).where(NotificationProblem.problem_type == "scheduled_maintenance_failed"))).scalar_one()
assert problem.status == "resolved"
@pytest.mark.asyncio
async def test_settings_roundtrip_scheduled_maintenance_failed_toggle(client):
assert (await client.get("/api/notifications/settings")).json()["scheduled_maintenance_failed_enabled"] is True
resp = await client.put("/api/notifications/settings", json={"scheduled_maintenance_failed_enabled": False})
assert resp.status_code == 200, resp.text
assert resp.json()["scheduled_maintenance_failed_enabled"] is False
+38
View File
@@ -535,6 +535,17 @@ async def test_password_hashing_runs_off_the_event_loop():
# ── SEC-08: upstream errors never carry secrets ───────────────────────
def test_redaction_keeps_validation_messages_readable():
body = (
'{"result": "failed", "validations": {"instance.cert": "Please select a valid certificate from the list.",'
' "instance.auth": "Authentication method required"}, "api_key": "k-MARKER"}'
)
out = redact_text(body)
assert "Please select a valid certificate" in out
assert "Authentication method required" in out
assert "k-MARKER" not in out # secrets outside "validations" are still masked
def test_redaction_scrubs_json_pem_and_known_secrets():
pem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg\n-----END PRIVATE KEY-----"
body = json.dumps({
@@ -859,3 +870,30 @@ async def test_process_death_after_intent_leaves_sweepable_or_flagged_row(client
async with SessionLocal() as db:
row = (await db.scalars(select(RuleDeployment))).one()
assert row.status == "revert_failed" and row.opnsense_rule_uuid is None # visible, blocks retries, notifies
# ── audit events built from unvalidated request paths ────────────────────
@pytest.mark.asyncio
async def test_junk_path_segments_give_422_not_500(client):
"""Fuzzing found 500s/dropped connections: the audit event copied the raw
path segment (300 chars, NUL byte) into VARCHAR(36)/Postgres text."""
for path in (
"/api/hosts/" + "x" * 300,
"/api/hosts/%00/services/ntpd/restart",
"/api/rules/not-a-uuid/deploy",
):
method = "PATCH" if path.count("/") == 3 else "POST"
resp = await client.request(method, path, json={})
assert resp.status_code in (404, 422), (path[:40], resp.status_code, resp.text[:100])
@pytest.mark.asyncio
async def test_nul_byte_in_a_text_field_is_a_422_not_a_500(client):
for path, body in (
("/api/groups", {"name": "a\u0000b", "description": "x", "host_ids": []}),
("/api/rules", {"description": "a\u0000b", "interface": "lan", "target_host_ids": []}),
):
resp = await client.post(path, json=body)
assert resp.status_code == 422, (path, resp.status_code, resp.text[:120])
assert resp.json()["detail"]["code"] == "INVALID_CHARACTERS"
+38
View File
@@ -8,6 +8,17 @@ def _mock_vip(respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
def _mock_service_list(respx_mock):
respx_mock.get("https://fw1.test/api/core/service/search").respond(
200,
json={"rows": [
{"id": "unbound", "name": "unbound", "locked": 0, "running": 1},
{"id": "ntpd", "name": "ntpd", "locked": 0, "running": 1},
{"id": "pf", "name": "pf", "locked": 1, "running": 1},
]},
)
@pytest.mark.asyncio
async def test_list_services(client, respx_mock):
_mock_vip(respx_mock)
@@ -24,6 +35,7 @@ async def test_list_services(client, respx_mock):
async def test_restart_service_success(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
restart = respx_mock.post("https://fw1.test/api/core/service/restart/unbound").respond(200, json={"result": "ok"})
r = await client.post(f"/api/hosts/{h['id']}/services/unbound/restart")
assert r.status_code == 200, r.text
@@ -35,6 +47,7 @@ async def test_restart_service_success(client, respx_mock):
async def test_start_and_stop_service(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
start = respx_mock.post("https://fw1.test/api/core/service/start/ntpd").respond(200, json={"result": "ok"})
stop = respx_mock.post("https://fw1.test/api/core/service/stop/ntpd").respond(200, json={"result": "ok"})
r1 = await client.post(f"/api/hosts/{h['id']}/services/ntpd/start")
@@ -47,6 +60,7 @@ async def test_start_and_stop_service(client, respx_mock):
async def test_restart_service_failure_result_is_rejected(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
respx_mock.post("https://fw1.test/api/core/service/restart/unbound").respond(200, json={"result": "failed"})
r = await client.post(f"/api/hosts/{h['id']}/services/unbound/restart")
assert r.status_code == 502, r.text
@@ -66,3 +80,27 @@ async def test_read_only_user_can_list_but_not_restart(client, respx_mock):
assert blocked.status_code == 403
finally:
await viewer.aclose()
@pytest.mark.asyncio
async def test_unknown_service_is_rejected_without_calling_the_firewall(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
start = respx_mock.post(url__regex=r".*/api/core/service/start/.*").respond(200, json={"result": "ok"})
r = await client.post(f"/api/hosts/{h['id']}/services/doesnotexist/start")
assert r.status_code == 404, r.text
assert r.json()["detail"]["code"] == "SERVICE_NOT_FOUND"
assert start.call_count == 0
@pytest.mark.asyncio
async def test_locked_service_cannot_be_controlled_via_api(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
stop = respx_mock.post(url__regex=r".*/api/core/service/stop/.*").respond(200, json={"result": "ok"})
r = await client.post(f"/api/hosts/{h['id']}/services/pf/stop")
assert r.status_code == 409, r.text
assert r.json()["detail"]["code"] == "SERVICE_LOCKED"
assert stop.call_count == 0
+66
View File
@@ -0,0 +1,66 @@
"""First-run setup assistant: per-user "done" flag and the password reset tool."""
import pytest
from sqlalchemy import select
from app import reset_password
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser, Session
from app.services import auth_service
@pytest.mark.asyncio
async def test_setup_done_defaults_to_false_and_persists(client):
assert (await client.get("/api/settings")).json()["setup_done"] is False
resp = await client.put("/api/settings", json={"setup_done": True})
assert resp.status_code == 200
assert resp.json()["setup_done"] is True
assert (await client.get("/api/settings")).json()["setup_done"] is True
@pytest.mark.asyncio
async def test_other_preference_changes_keep_setup_done(client):
await client.put("/api/settings", json={"setup_done": True})
resp = await client.put("/api/settings", json={"theme": "dark", "language": "en"})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", True)
# ...and the other direction: saving setup_done leaves theme/language alone.
resp = await client.put("/api/settings", json={"setup_done": False})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", False)
@pytest.mark.asyncio
async def test_setup_done_rejects_non_boolean(client):
assert (await client.put("/api/settings", json={"setup_done": "maybe"})).status_code == 422
@pytest.mark.asyncio
async def test_reset_password_prints_new_password_and_ends_sessions(client, capsys):
async with SessionLocal() as db:
before = (await db.execute(select(Session))).scalars().all()
assert before, "the client fixture logged in, so a session must exist"
assert await reset_password.main(None) == 0
out = capsys.readouterr().out
lines = dict(line.split(": ", 1) for line in out.strip().splitlines())
assert lines["Username"] == settings.admin_username
new_password = lines["Password"]
assert len(new_password) >= 20 and new_password != settings.admin_password
async with SessionLocal() as db:
assert (await db.execute(select(Session))).scalars().all() == []
user = (await db.execute(select(AdminUser).where(AdminUser.username == settings.admin_username))).scalar_one()
assert await auth_service.verify_password_async(user.password_hash, new_password)
assert not await auth_service.verify_password_async(user.password_hash, settings.admin_password)
# Put the shared bootstrap admin back for the tests that follow.
user.password_hash = await auth_service.hash_password_async(settings.admin_password)
await db.commit()
@pytest.mark.asyncio
async def test_reset_password_unknown_user_fails(capsys):
assert await reset_password.main("does-not-exist") == 1
assert "No such user" in capsys.readouterr().err
+69
View File
@@ -0,0 +1,69 @@
"""ZFS snapshots: list, create, delete (never the active one)."""
import httpx
import pytest
BASE = "https://10.20.30.80"
async def _host(client, respx_mock, name="fw-snap"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
return (await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})).json()["id"]
def _snaps(respx_mock, rows):
respx_mock.get(f"{BASE}/api/core/snapshots/isSupported").mock(return_value=httpx.Response(200, json={"supported": True}))
return respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": rows}))
ROW = {"uuid": "s1", "name": "default", "active": "NR", "mountpoint": "/", "size": "1.2G", "created_str": "2026-10-06 13:59"}
@pytest.mark.asyncio
async def test_list_snapshots(client, respx_mock):
host_id = await _host(client, respx_mock)
_snaps(respx_mock, [ROW])
body = (await client.get(f"/api/hosts/{host_id}/snapshots")).json()
assert body["supported"] is True and body["rows"][0]["name"] == "default" and body["rows"][0]["active"] == "NR"
@pytest.mark.asyncio
async def test_unsupported_firewall_is_reported(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-nozfs")
respx_mock.get(f"{BASE}/api/core/snapshots/isSupported").mock(return_value=httpx.Response(200, json={"supported": False}))
assert (await client.get(f"/api/hosts/{host_id}/snapshots")).json() == {"supported": False, "rows": []}
@pytest.mark.asyncio
async def test_create_is_confirmed_by_listing_again(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-snap2")
new = {"uuid": "s2", "name": "before_change", "active": "-", "mountpoint": "-", "size": "0", "created_str": "now"}
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": [ROW, new]}))
post = respx_mock.post(f"{BASE}/api/core/snapshots/add").mock(return_value=httpx.Response(200, json="OK"))
resp = await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "before_change", "note": "x"})
assert resp.status_code == 201, resp.text
assert resp.json()["uuid"] == "s2" and post.called
@pytest.mark.asyncio
async def test_create_rejects_bad_names_and_reports_a_snapshot_that_never_appeared(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-snap3")
assert (await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "bad name!"})).status_code == 422
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": [ROW]}))
respx_mock.post(f"{BASE}/api/core/snapshots/add").mock(return_value=httpx.Response(200, json="OK"))
assert (await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "ghost"})).status_code == 502
@pytest.mark.asyncio
async def test_active_snapshot_cannot_be_deleted_others_can(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-snap4")
old = {"uuid": "s3", "name": "old", "active": "-", "mountpoint": "-", "size": "1M", "created_str": "x"}
state = {"rows": [ROW, old]}
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(side_effect=lambda r: httpx.Response(200, json=state))
def drop(request):
state["rows"] = [ROW]
return httpx.Response(200, json={"status": "ok"})
delete = respx_mock.post(f"{BASE}/api/core/snapshots/del/s3").mock(side_effect=drop)
blocked = await client.delete(f"/api/hosts/{host_id}/snapshots/s1")
assert blocked.status_code == 409 and blocked.json()["detail"]["params"]["reasons"] == "snapshot_active"
assert (await client.delete(f"/api/hosts/{host_id}/snapshots/s3")).status_code == 204 and delete.called
assert (await client.delete(f"/api/hosts/{host_id}/snapshots/nope")).status_code == 404
+176
View File
@@ -0,0 +1,176 @@
import httpx
import pytest
from tests.test_users import _login_as
from app.config import settings
from app.services import update_check_service
from app.version import APP_VERSION
FEED = "https://feed.test/api/v1/repos/o/r/releases/latest"
UPDATER = "http://updater.test:8080"
def _bump(minor=0, patch=0, major=0):
a, b, c = (int(x) for x in APP_VERSION.split("."))
return f"{a + major}.{b + minor}.{c + patch}"
@pytest.fixture(autouse=True)
def _setup(monkeypatch, tmp_path):
token = tmp_path / "token"
token.write_text("t" * 40)
monkeypatch.setattr(settings, "update_check_url", FEED)
monkeypatch.setattr(settings, "updater_url", UPDATER)
monkeypatch.setattr(settings, "updater_token_file", str(token))
update_check_service.reset_cache()
yield
update_check_service.reset_cache()
def _feed(respx_mock, version):
respx_mock.get(FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{version}"}))
def _updater_status(respx_mock, **over):
body = {"mode": "release", "running_version": APP_VERSION, "job": None, "phases": [], "log": [],
"services": {"backend": "healthy"}, "rollback_available": False, "rollback_loses_data": False,
"last_update": None}
body.update(over)
return respx_mock.get(f"{UPDATER}/status").mock(return_value=httpx.Response(200, json=body))
@pytest.mark.asyncio
async def test_status_reports_update_and_can_update(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
_updater_status(respx_mock)
body = (await client.get("/api/system/updater")).json()
assert body["available"] is True and body["can_update"] is True
assert body["latest"] == _bump(minor=1) and body["major_change"] is False
@pytest.mark.asyncio
async def test_status_without_token_is_unavailable_not_an_error(client, respx_mock, monkeypatch, tmp_path):
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
_feed(respx_mock, _bump(minor=1))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "not_enabled" and body["can_update"] is False
assert body["update_available"] is True # der Hinweis bleibt trotzdem
@pytest.mark.asyncio
async def test_status_unreachable_updater(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
respx_mock.get(f"{UPDATER}/status").mock(side_effect=httpx.ConnectError("down"))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "unreachable"
@pytest.mark.asyncio
async def test_major_change_is_not_offered_for_one_click(client, respx_mock):
_feed(respx_mock, _bump(major=1))
_updater_status(respx_mock)
body = (await client.get("/api/system/updater")).json()
assert body["major_change"] is True and body["can_update"] is False
resp = await client.post("/api/system/updater/update", json={"version": _bump(major=1)})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_MAJOR_CHANGE"
@pytest.mark.asyncio
async def test_local_build_install_cannot_update(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
_updater_status(respx_mock, mode="local")
body = (await client.get("/api/system/updater")).json()
assert body["can_update"] is False and body["mode"] == "local"
@pytest.mark.asyncio
async def test_update_forwards_only_the_feed_version(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.status_code == 202
sent = route.calls.last.request
assert sent.headers["authorization"] == "Bearer " + "t" * 40
assert b'"version": "%s"' % newer.encode() in sent.content or newer.encode() in sent.content
@pytest.mark.asyncio
@pytest.mark.parametrize("version", ["0.0.1", "99.99.99", "1.0.0-rc1", "latest", "../../x"])
async def test_update_rejects_any_other_version(client, respx_mock, version):
_feed(respx_mock, _bump(patch=1))
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
resp = await client.post("/api/system/updater/update", json={"version": version})
assert resp.status_code in (409, 422)
assert route.call_count == 0
@pytest.mark.asyncio
async def test_update_when_no_update_available(client, respx_mock):
_feed(respx_mock, APP_VERSION)
resp = await client.post("/api/system/updater/update", json={"version": APP_VERSION})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_NOT_OFFERED"
@pytest.mark.asyncio
async def test_busy_and_rejected_map_to_error_codes(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
route = respx_mock.post(f"{UPDATER}/update")
route.mock(return_value=httpx.Response(409, json={"error": "busy"}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.json()["detail"]["code"] == "UPDATER_BUSY"
route.mock(return_value=httpx.Response(409, json={"error": "not_release_mode"}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.json()["detail"] == {"code": "UPDATER_REJECTED", "params": {"reason": "not_release_mode"}}
@pytest.mark.asyncio
async def test_update_without_updater_is_503(client, respx_mock, monkeypatch, tmp_path):
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
newer = _bump(patch=1)
_feed(respx_mock, newer)
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.status_code == 503 and resp.json()["detail"]["code"] == "UPDATER_UNAVAILABLE"
@pytest.mark.asyncio
async def test_rollback_is_forwarded(client, respx_mock):
route = respx_mock.post(f"{UPDATER}/rollback").mock(return_value=httpx.Response(202, json={"job": {"id": "r"}}))
resp = await client.post("/api/system/updater/rollback")
assert resp.status_code == 202 and route.call_count == 1
@pytest.mark.asyncio
async def test_read_only_user_cannot_read_or_start_updates(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
status = _updater_status(respx_mock)
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
await client.post("/api/users", json={"username": "viewer-upd", "password": "password-123456", "role": "read_only"})
viewer = await _login_as("viewer-upd", "password-123456")
try:
assert (await viewer.get("/api/system/updater")).status_code == 403
assert (await viewer.post("/api/system/updater/update", json={"version": newer})).status_code == 403
assert (await viewer.post("/api/system/updater/rollback")).status_code == 403
finally:
await viewer.aclose()
assert status.call_count == 0 and route.call_count == 0
@pytest.mark.asyncio
async def test_anonymous_is_rejected(anon_client):
assert (await anon_client.get("/api/system/updater")).status_code == 401
assert (await anon_client.post("/api/system/updater/rollback")).status_code == 401
@pytest.mark.asyncio
async def test_update_request_is_audited(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
assert (await client.post("/api/system/updater/update", json={"version": newer})).status_code == 202
events = (await client.get("/api/audit?kind=system")).json()["items"]
assert {(e["action"], e["status"]) for e in events} >= {("update", "requested"), ("update", "accepted")}
+33 -1
View File
@@ -6,6 +6,8 @@ denormalized `servers` field on the Peer payload) and to WireGuard's OWN
reconfigure response shape ({"result": "ok"}, matching OpenVPN's shape,
NOT IPsec's {"status": "ok"}), which this suite checks explicitly rather
than assuming symmetry with either sibling feature."""
import json
import httpx
import pytest
from sqlalchemy import select
@@ -16,7 +18,15 @@ from tests.test_wireguard import _create_host, _tunnel_payload
from tests.helpers import deploy_approved
def _mock_full_success(respx_mock, base_url: str):
def _mock_general(respx_mock, base_url: str, enabled: str = "1"):
# The service-wide switch (General.enabled) is checked before every deploy.
respx_mock.get(f"{base_url}/api/wireguard/general/get").mock(
return_value=httpx.Response(200, json={"general": {"enabled": enabled}})
)
def _mock_full_success(respx_mock, base_url: str, general_enabled: str = "1"):
_mock_general(respx_mock, base_url, general_enabled)
respx_mock.post(f"{base_url}/api/wireguard/server/addServer").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "server-1"})
)
@@ -68,6 +78,7 @@ async def test_peer_payload_carries_injected_server_uuid(client, respx_mock):
add_peer = respx_mock.post("https://fw1.test/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
_mock_general(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/wireguard/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "ok"})
)
@@ -134,6 +145,7 @@ async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, r
respx_mock.post("https://fw1.test/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
_mock_general(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/wireguard/service/reconfigure").mock(
side_effect=httpx.ConnectError("connection refused")
)
@@ -173,6 +185,7 @@ async def test_reconfigure_checks_result_key_explicitly(client, respx_mock):
respx_mock.post("https://fw1.test/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
_mock_general(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/wireguard/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "failed"})
)
@@ -282,3 +295,22 @@ async def test_secrets_never_persisted_and_rotation_is_not_a_no_op(client, respx
assert add_server_route.call_count == 1, "unchanged from the first deploy — the rotation must go through setServer, not addServer"
assert set_server_route.call_count == 1, "the rotated key must actually be re-pushed, not silently no-op'd"
assert rotated_key.encode() in set_server_route.calls.last.request.content
@pytest.mark.asyncio
async def test_deploy_switches_on_the_global_wireguard_service_when_disabled(client, respx_mock):
"""Live 26.7.5: with General.enabled=0 the config is written but no wg
interface exists, so the deploy must enable the switch first."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/wireguard/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test", general_enabled="0")
set_route = respx_mock.post("https://fw1.test/api/wireguard/general/set").mock(
return_value=httpx.Response(200, json={"result": "saved"})
)
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
assert resp.json()["deployments"][0]["status"] == "success"
assert json.loads(set_route.calls.last.request.content) == {"general": {"enabled": "1"}}
+3
View File
@@ -16,3 +16,6 @@ services:
proxy:
build: ./proxy
updater:
build: ./updater
+38 -6
View File
@@ -28,9 +28,8 @@ services:
logging: *default-logging
# Docker's default 64MB /dev/shm is too small for Postgres's parallel
# vacuum/query workers on anything but a toy dataset — found the hard
# way running VACUUM on a multi-GB table (see docs/HANDBUCH.md's "Backup &
# Restore" section for how that table got that large in the first
# place). Official Postgres image docs recommend raising this.
# way running VACUUM on a multi-GB table (see docs/HANDBUCH.md's "Logs" section for how
# large that table gets). Official Postgres image docs recommend raising this.
shm_size: "256mb"
environment:
# Datenbank- und Benutzername. Wirken nur beim allerersten Start eines
@@ -62,8 +61,8 @@ services:
# path at all. Reuses the postgres image directly (pg_dump is already
# in it) rather than adding a new image to build/maintain.
# Excludes firewall_log_entries/host_resource_samples: high-volume,
# disposable operational history (see docs/HANDBUCH.md's "Backup & Restore"
# section for exactly how large this got in practice) rather than
# disposable operational history (see docs/HANDBUCH.md's "Logs" section for
# how large these get) rather than
# config a disaster recovery actually needs — including them would
# make the backup itself multi-GB and a disk-usage risk in its own
# right, working against the point of this service.
@@ -120,6 +119,9 @@ services:
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
UPDATE_CHECK_INTERVAL_HOURS: ${UPDATE_CHECK_INTERVAL_HOURS:-12}
# Optionaler Updater-Dienst (Profil "updater", siehe unten). Ohne ihn
# zeigt das Webinterface nur an, wie man ihn aktiviert.
UPDATER_URL: ${UPDATER_URL:-http://updater:8080}
volumes:
- secrets:/data/opnsense-secrets
- backups:/data/opnsense-backups
@@ -133,10 +135,13 @@ services:
# Gleiches Volume wie der postgres-backup-Service: hier landet das
# automatische Sicherheits-Backup vor einer Schema-Migration.
- postgres_backups:/backups
# Zugriffstoken des Updaters (nur lesen). Leer, solange der Updater
# nicht aktiviert ist.
- updater_state:/updater:ro
# Bound to loopback only by default — reachable from this host, not
# the network. Set BIND_ADDRESS in .env (e.g. to 0.0.0.0, or a
# specific interface IP) for network-wide access instead of editing
# this file — see docs/HANDBUCH.md's "Produktivbetrieb" for the TLS-proxy
# this file — see docs/HANDBUCH.md's "Zugriff aus dem Netz und TLS" for the TLS-proxy
# alternative, which is safer than widening this directly.
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${BACKEND_PORT:-8100}:8000"
@@ -195,7 +200,34 @@ services:
- caddy_data:/data
- caddy_config:/config
# ── Optional: Updates aus dem Webinterface ──────────────────────────
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=updater in
# .env und `docker compose up -d`. Dies ist der einzige Container mit
# Zugriff auf den Docker-Socket (= root-gleiche Rechte auf diesem Host): er
# hat keine veröffentlichten Ports, ist nur mit seinem Token ansprechbar
# und kann nur "aktualisieren"/"zurückrollen" (updater/server.py). Das
# Backend selbst bekommt den Socket nie. Nur für Release-Images
# (OPNEXUS_IMAGE_REPO gesetzt).
updater:
profiles: ["updater"]
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-updater:${OPNEXUS_VERSION:-latest}
restart: unless-stopped
logging: *default-logging
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Das Verzeichnis mit docker-compose.yml und .env (der Updater schreibt
# nur OPNEXUS_VERSION und .opnexus-update-state hinein).
- .:/project
- updater_state:/state
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
volumes:
updater_state:
postgres_data:
postgres_backups:
secrets:
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more