Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf326d2d17 | ||
|
|
8fccb1a547 | ||
|
|
cf59bdb38e | ||
|
|
c662a58426 | ||
|
|
3b76f9f5fa | ||
|
|
b341ccd49f | ||
|
|
20fd575420 | ||
|
|
1fc2d2c87c | ||
|
|
9889be64ef | ||
|
|
5c092fabac |
No files matched your search
+9
-1
@@ -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.
|
||||
# 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
|
||||
|
||||
@@ -59,3 +59,5 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 scripts/check_i18n.py
|
||||
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
|
||||
# Updater-Container (Zustandsübergänge, Ablehnungen, Auth) ohne Docker-Daemon.
|
||||
- run: python3 -m unittest discover -s updater -p 'test_*.py'
|
||||
@@ -12,7 +12,7 @@
|
||||
# Berechtigung "write:package" eintragen)
|
||||
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
|
||||
# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration.
|
||||
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy}:<version>
|
||||
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy,updater}:<version>
|
||||
# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen).
|
||||
#
|
||||
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
service: [backend, frontend, proxy]
|
||||
service: [backend, frontend, proxy, updater]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
+199
@@ -31,6 +31,201 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [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 +255,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
|
||||
|
||||
+17
-5
@@ -5,9 +5,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> **Stand:** `1.11.0-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.16.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
|
||||
```
|
||||
|
||||
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-von-außerhalb-dieser-vm--produktivbetrieb-mit-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.16.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> **Status:** `1.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.16.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.
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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-von-außerhalb-dieser-vm--produktivbetrieb-mit-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.16.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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -99,6 +105,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 +147,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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -289,6 +296,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 +394,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")
|
||||
@@ -48,6 +48,7 @@ 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")
|
||||
|
||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy import Boolean, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
@@ -9,3 +9,6 @@ class UserPreferences(Base):
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
|
||||
theme: Mapped[str] = mapped_column(String(10), default='system')
|
||||
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
|
||||
# Set once the user finished or dismissed the first-run setup assistant
|
||||
# (frontend /setup); it is then no longer offered automatically.
|
||||
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy import Boolean, DateTime, String, false
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -29,6 +29,7 @@ class FirewallRule(Base):
|
||||
destination_net: Mapped[str] = mapped_column(String(128), default="any")
|
||||
destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
log: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,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)))
|
||||
@@ -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()
|
||||
|
||||
@@ -112,7 +112,7 @@ async def delete_managed_certificate(cert_id: uuid.UUID, db: AsyncSession = Depe
|
||||
try:
|
||||
await managed_certificate_service.delete_certificate(db, cert, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
|
||||
|
||||
# ── ACME accounts ──────────────────────────────────────────────────────
|
||||
@@ -197,7 +197,7 @@ async def delete_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(
|
||||
try:
|
||||
await acme_service.delete_account(db, account, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
|
||||
|
||||
# ── ACME certificate requests ─────────────────────────────────────────
|
||||
@@ -298,4 +298,4 @@ async def delete_acme_certificate_request(request_id: uuid.UUID, db: AsyncSessio
|
||||
try:
|
||||
await acme_service.delete_certificate_request(db, req, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
@@ -0,0 +1,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)
|
||||
@@ -155,6 +155,8 @@ async def update_host(host_id: uuid.UUID, payload: HostUpdate, db: AsyncSession
|
||||
if existing:
|
||||
raise api_error(409, ErrorCode.HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS)
|
||||
host.name = payload.name
|
||||
if payload.base_url is not None:
|
||||
host.base_url = payload.base_url
|
||||
if payload.verify_tls is not None:
|
||||
host.verify_tls = payload.verify_tls
|
||||
if payload.clear_tls_pin:
|
||||
|
||||
@@ -174,7 +174,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
try:
|
||||
await undeploy_tunnel(db, tunnel)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(tunnel, attribute_names=["deployments"])
|
||||
ipsec_psk_store.delete_secret(tunnel.id)
|
||||
await db.delete(tunnel)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.routers.hosts import _upstream_error
|
||||
from app.schemas.network import (
|
||||
HostNetwork, NetworkChangeExecute, NetworkChangePreview, NetworkChangeRequest, NetworkChangeResult,
|
||||
)
|
||||
from app.services import network_change_service, network_service
|
||||
from app.services.policy_service import policy_lock
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/network", tags=["network"])
|
||||
|
||||
|
||||
@router.get("", response_model=HostNetwork)
|
||||
async def host_network(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
return await network_service.get_host_network(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
async def _host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
@router.post("/changes/preview", response_model=NetworkChangePreview)
|
||||
async def preview_change(host_id: uuid.UUID, payload: NetworkChangeRequest, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
plan = await network_change_service.preview(db, host, payload.kind, payload.action, payload.uuid, payload.fields)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
return {k: v for k, v in plan.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
@router.post("/changes", response_model=NetworkChangeResult)
|
||||
async def execute_change(host_id: uuid.UUID, payload: NetworkChangeExecute, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host_or_404(db, host_id)
|
||||
return await network_change_service.execute(
|
||||
db, host, payload.kind, payload.action, payload.uuid, payload.fields, payload.preview_token, payload.acknowledge)
|
||||
|
||||
|
||||
@router.get("/template/{kind}")
|
||||
async def change_template(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Blank form for one kind, with the choices this firewall accepts for its select fields."""
|
||||
if kind not in network_change_service.FIELDS:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
return await network_change_service.template(host, kind)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
@router.get("/item/{kind}/{item_uuid}")
|
||||
async def current_item(host_id: uuid.UUID, kind: str, item_uuid: str, db: AsyncSession = Depends(get_db)):
|
||||
"""The object's current values on the firewall, for pre-filling an edit form."""
|
||||
if kind not in network_change_service.FIELDS or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", item_uuid):
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
return await network_change_service.item(host, kind, item_uuid)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
@router.get("/objects/{kind}")
|
||||
async def list_objects(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Rows of a generic object kind (tunables, cron jobs, LAGG, ...), reduced to their display columns."""
|
||||
if kind not in network_change_service.OBJECT_COLUMNS:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
host = await _host_or_404(db, host_id)
|
||||
try:
|
||||
return await network_change_service.list_objects(host, kind)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
@@ -0,0 +1,29 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import network_drift_service
|
||||
|
||||
router = APIRouter(prefix="/network", tags=["network"])
|
||||
|
||||
|
||||
@router.get("/drift")
|
||||
async def network_drift(db: AsyncSession = Depends(get_db)):
|
||||
"""Per cluster: differences in the network configuration between its nodes (live read of every node)."""
|
||||
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
|
||||
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
|
||||
for host in hosts:
|
||||
if host.cluster_name:
|
||||
by_cluster[host.cluster_name].append(host)
|
||||
clusters = []
|
||||
for name, members in sorted(by_cluster.items()):
|
||||
if len(members) < 2:
|
||||
continue
|
||||
differences, compared, unreachable = await network_drift_service.compute_cluster(members)
|
||||
clusters.append({"name": name, "hosts": [h.name for h in members], "compared": compared,
|
||||
"unreachable": unreachable, "differences": differences})
|
||||
return {"clusters": clusters}
|
||||
@@ -168,7 +168,7 @@ async def delete_one_to_one_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(
|
||||
try:
|
||||
await undeploy_one_to_one(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
@@ -268,4 +268,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
return split_rows(await opnsense_client.search_one_to_one_rules(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
@@ -176,7 +176,7 @@ async def delete_instance(instance_id: uuid.UUID, db: AsyncSession = Depends(get
|
||||
try:
|
||||
await undeploy_instance(db, instance)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(instance, attribute_names=["deployments"])
|
||||
openvpn_key_store.delete_secret(instance.id)
|
||||
await db.delete(instance)
|
||||
|
||||
@@ -175,7 +175,7 @@ async def delete_outbound_nat_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
|
||||
try:
|
||||
await undeploy_outbound_nat(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
@@ -275,7 +275,7 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
return split_rows(await opnsense_client.search_outbound_nat_rules(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/mode")
|
||||
|
||||
@@ -294,7 +294,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
await undeploy_hierarchy_rule(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
|
||||
@@ -170,7 +170,7 @@ async def delete_port_forward_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
|
||||
try:
|
||||
await undeploy_port_forward(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
@@ -270,4 +270,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
return split_rows(await opnsense_client.search_port_forward_rules(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
@@ -17,6 +17,8 @@ from app.services.alias_service import check_alias_consistency, describe_alias_c
|
||||
from app.services.interface_service import check_interface_consistency
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment
|
||||
from app.services import rule_adopt_service
|
||||
from app.schemas.rule import AdoptRequest, UnmanagedRule
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||
|
||||
@@ -59,6 +61,7 @@ def _to_read(
|
||||
destination_net=rule.destination_net,
|
||||
destination_port=rule.destination_port,
|
||||
enabled=rule.enabled,
|
||||
log=rule.log,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
target_host_ids=[host.id for host in rule.targets],
|
||||
@@ -84,6 +87,42 @@ async def list_rules(db: AsyncSession = Depends(get_db)):
|
||||
return [_to_read(rule, rule.targets, rule.deployments) for rule in rules]
|
||||
|
||||
|
||||
@router.get("/unmanaged", response_model=list[UnmanagedRule])
|
||||
async def list_unmanaged_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""Rules found on the host that OPNexus does not manage yet (live read)."""
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
return await rule_adopt_service.list_unmanaged(db, host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
|
||||
|
||||
@router.post("/adopt", response_model=RuleRead, status_code=201)
|
||||
async def adopt_rule(payload: AdoptRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Takes over a hand-made rule. Writes nothing to the firewall."""
|
||||
await policy_lock(db)
|
||||
host = await db.get(OpnsenseHost, payload.host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
try:
|
||||
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid)
|
||||
except rule_adopt_service.RuleNotFoundOnHost as exc:
|
||||
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) from exc
|
||||
except rule_adopt_service.NotAdoptable as exc:
|
||||
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
await db.commit()
|
||||
# The new object sits in the session without its relationships loaded; db.get() would return it as-is.
|
||||
rule = (await db.execute(
|
||||
select(FirewallRule).where(FirewallRule.id == rule.id).options(*_load_options())
|
||||
.execution_options(populate_existing=True)
|
||||
)).scalar_one()
|
||||
return _to_read(rule, rule.targets, rule.deployments)
|
||||
|
||||
|
||||
@router.post("", response_model=RuleRead, status_code=201)
|
||||
async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
@@ -101,6 +140,7 @@ async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
|
||||
destination_net=payload.destination_net,
|
||||
destination_port=payload.destination_port,
|
||||
enabled=payload.enabled,
|
||||
log=payload.log,
|
||||
targets=hosts,
|
||||
groups=groups,
|
||||
)
|
||||
@@ -172,7 +212,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
await undeploy_rule(db, rule)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
|
||||
await db.refresh(rule, attribute_names=["deployments"])
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
|
||||
@@ -31,6 +31,19 @@ async def _control(host_id: uuid.UUID, name: str, action: str, fn, db: AsyncSess
|
||||
await policy_lock(db)
|
||||
host = await host_or_404(db, host_id)
|
||||
await ensure_no_maintenance(db, [host])
|
||||
# The UI greys out locked services, but the API must not rely on that:
|
||||
# unknown names are accepted by the firewall with "ok", and locked ones
|
||||
# (pf, webgui, routing, ...) can be stopped through it.
|
||||
try:
|
||||
listing = await client.search_services(host)
|
||||
except client.OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
rows = listing.get("rows", []) if isinstance(listing, dict) else []
|
||||
row = next((r for r in rows if name in (r.get("id"), r.get("name"))), None)
|
||||
if row is None:
|
||||
raise api_error(404, ErrorCode.SERVICE_NOT_FOUND, service=name)
|
||||
if row.get("locked") in (1, "1", True):
|
||||
raise api_error(409, ErrorCode.SERVICE_LOCKED, service=name)
|
||||
await audit_service.persist(
|
||||
kind="host", action=action, status="requested",
|
||||
host_id=str(host.id), host_name=host.name,
|
||||
|
||||
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
|
||||
class Preferences(BaseModel):
|
||||
theme: Literal['light','dark','system'] | None = None
|
||||
language: Literal['de','en'] | None = None
|
||||
setup_done: bool | None = None
|
||||
|
||||
@router.get('')
|
||||
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
|
||||
preferences=await db.get(UserPreferences,user.id)
|
||||
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
|
||||
'language':preferences.language if preferences else 'de',
|
||||
'setup_done':preferences.setup_done if preferences else False,
|
||||
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||
'session_lifetime_seconds':settings.session_lifetime}
|
||||
|
||||
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
|
||||
current = await db.get(UserPreferences, user.id)
|
||||
theme = payload.theme or (current.theme if current else 'system')
|
||||
language = payload.language or (current.language if current else 'de')
|
||||
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
|
||||
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
|
||||
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
|
||||
await db.commit()
|
||||
return {'username':username,'theme':theme,'language':language,
|
||||
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
|
||||
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||
'session_lifetime_seconds':settings.session_lifetime}
|
||||
@@ -0,0 +1,55 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.routers.hosts import _upstream_error
|
||||
from app.services import snapshot_service
|
||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.policy_service import policy_lock
|
||||
|
||||
router = APIRouter(prefix="/hosts/{host_id}/snapshots", tags=["snapshots"])
|
||||
|
||||
|
||||
class SnapshotCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
note: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if not host:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_snapshots(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host(db, host_id)
|
||||
try:
|
||||
return await snapshot_service.list_snapshots(host)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise _upstream_error(host, exc) from exc
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_snapshot(host_id: uuid.UUID, payload: SnapshotCreate, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host(db, host_id)
|
||||
await ensure_no_maintenance(db, [host])
|
||||
return await snapshot_service.create(host, payload.name, payload.note)
|
||||
|
||||
|
||||
@router.delete("/{snapshot_uuid}", status_code=204)
|
||||
async def delete_snapshot(host_id: uuid.UUID, snapshot_uuid: str, db: AsyncSession = Depends(get_db)):
|
||||
await policy_lock(db)
|
||||
host = await _host(db, host_id)
|
||||
await ensure_no_maintenance(db, [host])
|
||||
if not snapshot_uuid.replace("-", "").isalnum() or len(snapshot_uuid) > 64:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
await snapshot_service.delete(host, snapshot_uuid)
|
||||
@@ -1,6 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services import update_check_service
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.services import update_check_service, updater_client
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
@@ -10,3 +14,93 @@ async def version_check() -> dict:
|
||||
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
||||
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
||||
return await update_check_service.check()
|
||||
|
||||
|
||||
def _admin(user: AdminUser) -> AdminUser:
|
||||
# Der Updater ist Administration der Installation selbst — auch das reine
|
||||
# Lesen (Logs, Rollback-Optionen) bleibt Admins vorbehalten.
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
return user
|
||||
|
||||
|
||||
def _major(version: str | None) -> int | None:
|
||||
try:
|
||||
return int((version or "").split(".")[0])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class UpdateRequest(BaseModel):
|
||||
version: str = Field(min_length=5, max_length=20)
|
||||
|
||||
|
||||
@router.get("/updater")
|
||||
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
|
||||
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check()
|
||||
result: dict = {
|
||||
"available": False, "reason": None, "mode": None,
|
||||
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
|
||||
"release_url": check["release_url"], "feed_enabled": check["enabled"],
|
||||
"feed_error": check["error"], "checked_at": check["checked_at"],
|
||||
"can_update": False, "major_change": False,
|
||||
"job": None, "phases": [], "log": [], "services": {},
|
||||
"rollback_available": False, "rollback_loses_data": False, "rollback_version": None,
|
||||
}
|
||||
try:
|
||||
updater = await updater_client.status()
|
||||
except updater_client.UpdaterUnavailable as exc:
|
||||
result["reason"] = str(exc)
|
||||
return result
|
||||
except updater_client.UpdaterRejected:
|
||||
result["reason"] = "unreachable"
|
||||
return result
|
||||
|
||||
mode = updater.get("mode")
|
||||
running = (updater.get("job") or {}).get("state") == "running"
|
||||
result.update(
|
||||
available=True, mode=mode, job=updater.get("job"), phases=updater.get("phases") or [],
|
||||
log=updater.get("log") or [], services=updater.get("services") or {},
|
||||
rollback_available=bool(updater.get("rollback_available")),
|
||||
rollback_loses_data=bool(updater.get("rollback_loses_data")),
|
||||
rollback_version=((updater.get("last_update") or {}).get("prev_version")),
|
||||
)
|
||||
# Hauptversionssprünge nur bewusst über die Kommandozeile (Release-Notes lesen).
|
||||
result["major_change"] = bool(check["latest"]) and _major(check["latest"]) != _major(check["current"])
|
||||
result["can_update"] = (
|
||||
mode == "release" and not running and bool(check["update_available"]) and not result["major_change"]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/updater/update", status_code=202)
|
||||
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
|
||||
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
|
||||
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
||||
_admin(user)
|
||||
check = await update_check_service.check()
|
||||
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
|
||||
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
||||
if _major(check["latest"]) != _major(check["current"]):
|
||||
raise api_error(409, ErrorCode.UPDATE_MAJOR_CHANGE)
|
||||
return await _forward(updater_client.start_update(check["latest"]))
|
||||
|
||||
|
||||
@router.post("/updater/rollback", status_code=202)
|
||||
async def updater_rollback(user: AdminUser = Depends(current_user)) -> dict:
|
||||
_admin(user)
|
||||
return await _forward(updater_client.start_rollback())
|
||||
|
||||
|
||||
async def _forward(call) -> dict:
|
||||
try:
|
||||
return await call
|
||||
except updater_client.UpdaterUnavailable:
|
||||
raise api_error(503, ErrorCode.UPDATER_UNAVAILABLE)
|
||||
except updater_client.UpdaterRejected as rejected:
|
||||
if rejected.code == "busy":
|
||||
raise api_error(409, ErrorCode.UPDATER_BUSY)
|
||||
raise api_error(409, ErrorCode.UPDATER_REJECTED, reason=rejected.code)
|
||||
@@ -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)
|
||||
|
||||
@@ -47,6 +47,8 @@ class HostUpdate(BaseModel):
|
||||
recreate is still the way to rotate a host's API key for now."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
# New address of the firewall, e.g. after its management IP was changed.
|
||||
base_url: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
verify_tls: bool | None = None
|
||||
tls_ca_pem: str | None = Field(default=None, max_length=32768)
|
||||
clear_tls_pin: bool = False
|
||||
@@ -56,6 +58,11 @@ class HostUpdate(BaseModel):
|
||||
policy_hierarchy_group_id: uuid.UUID | None = None
|
||||
clear_policy_hierarchy_group_id: bool = False # same ambiguity as clear_cluster_name above
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def _base_url_is_safe(cls, value: str | None) -> str | None:
|
||||
return validate_base_url(value) if value is not None else None
|
||||
|
||||
|
||||
class HostRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -123,6 +130,9 @@ CertStatus = Literal["valid", "expiring_soon", "expired", "unknown"]
|
||||
|
||||
class HostCertificate(BaseModel):
|
||||
uuid: str
|
||||
# OPNsense's own cross-references (OpenVPN cert/ca, Trust-Store caref) use
|
||||
# this id, not the row UUID.
|
||||
refid: str = ""
|
||||
descr: str
|
||||
common_name: str | None
|
||||
issuer: str
|
||||
@@ -135,6 +145,7 @@ class HostCertificate(BaseModel):
|
||||
|
||||
class HostCa(BaseModel):
|
||||
uuid: str
|
||||
refid: str = ""
|
||||
descr: str
|
||||
common_name: str | None
|
||||
valid_from: datetime | None
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NetAddress(BaseModel):
|
||||
address: str # CIDR, e.g. 192.168.1.2/24
|
||||
# Set for CARP addresses only
|
||||
vhid: str | None = None
|
||||
carp_status: str | None = None
|
||||
|
||||
|
||||
class NetInterface(BaseModel):
|
||||
device: str
|
||||
identifier: str | None # OPNsense's own name (wan, lan, opt3) — None when unassigned
|
||||
description: str
|
||||
status: str
|
||||
enabled: bool
|
||||
is_physical: bool
|
||||
mtu: str | None
|
||||
mac: str | None
|
||||
media: str | None
|
||||
vlan_tag: str | None
|
||||
vlan_parent: str | None
|
||||
ipv4_type: str | None # "Static IPv4" / "DHCP" ... (assigned interfaces only)
|
||||
ipv6_type: str | None
|
||||
addresses: list[NetAddress]
|
||||
pending_action: str | None = None
|
||||
# Listed in the firewall's interface assignment (wan, lan, optN) — only those can be edited or removed.
|
||||
assigned: bool = False
|
||||
# This is the interface the firewall answers OPNexus on. Changing it can cut the connection.
|
||||
is_management: bool = False
|
||||
|
||||
|
||||
class NetVlan(BaseModel):
|
||||
uuid: str
|
||||
parent: str
|
||||
tag: str
|
||||
pcp: str | None
|
||||
description: str
|
||||
device: str | None
|
||||
|
||||
|
||||
class NetVip(BaseModel):
|
||||
"""No CARP password here, by design: OPNsense returns it in plain text."""
|
||||
uuid: str
|
||||
interface: str
|
||||
interface_label: str
|
||||
mode: str
|
||||
address: str
|
||||
vhid: str | None
|
||||
advbase: str | None
|
||||
advskew: str | None
|
||||
description: str
|
||||
|
||||
|
||||
class NetGateway(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
description: str
|
||||
interface: str
|
||||
interface_label: str
|
||||
ip_protocol: str
|
||||
address: str
|
||||
is_default: bool
|
||||
disabled: bool
|
||||
priority: str | None
|
||||
status: str
|
||||
|
||||
|
||||
class NetRoute(BaseModel):
|
||||
uuid: str
|
||||
network: str
|
||||
gateway: str
|
||||
description: str
|
||||
disabled: bool
|
||||
|
||||
|
||||
class NetDnsHost(BaseModel):
|
||||
uuid: str
|
||||
hostname: str
|
||||
domain: str
|
||||
record_type: str
|
||||
server: str
|
||||
enabled: bool
|
||||
description: str
|
||||
|
||||
|
||||
class NetDhcpHost(BaseModel):
|
||||
uuid: str
|
||||
host: str
|
||||
domain: str
|
||||
ip: str
|
||||
hwaddr: str
|
||||
lease_time: str
|
||||
description: str
|
||||
|
||||
|
||||
class NetKeaSubnet(BaseModel):
|
||||
uuid: str
|
||||
subnet: str
|
||||
pools: str
|
||||
routers: str
|
||||
dns_servers: str
|
||||
description: str
|
||||
|
||||
|
||||
class NetKeaReservation(BaseModel):
|
||||
uuid: str
|
||||
subnet: str
|
||||
ip_address: str
|
||||
hw_address: str
|
||||
hostname: str
|
||||
description: str
|
||||
|
||||
|
||||
class HostNetwork(BaseModel):
|
||||
host_id: str
|
||||
host_name: str
|
||||
interfaces: list[NetInterface]
|
||||
vlans: list[NetVlan]
|
||||
vips: list[NetVip]
|
||||
gateways: list[NetGateway]
|
||||
routes: list[NetRoute]
|
||||
dns_hosts: list[NetDnsHost] = []
|
||||
dhcp_hosts: list[NetDhcpHost] = []
|
||||
kea_subnets: list[NetKeaSubnet] = []
|
||||
kea_reservations: list[NetKeaReservation] = []
|
||||
management_device: str | None
|
||||
# The firewall's own API can set interface IPs/types (OPNsense >= ~26.7.6). Older 26.7.x
|
||||
# only lists assignments. Detected from the data, not from a version number.
|
||||
can_configure_interfaces: bool
|
||||
# Sections the firewall could not deliver (e.g. older OPNsense without that API) — never silently empty.
|
||||
unavailable: list[str]
|
||||
|
||||
|
||||
class NetworkChangeRequest(BaseModel):
|
||||
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation",
|
||||
"tunable", "cron_job", "syslog_destination", "gateway_group", "lagg", "bridge", "loopback", "gre", "gif", "vxlan", "dns_forward", "dhcp_range"]
|
||||
action: Literal["add", "set", "del"]
|
||||
# Goes into a URL path, so only plain identifiers.
|
||||
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
|
||||
fields: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NetworkChangeExecute(NetworkChangeRequest):
|
||||
preview_token: str = Field(min_length=64, max_length=64)
|
||||
acknowledge: bool = False
|
||||
|
||||
|
||||
class NetworkChangeRisk(BaseModel):
|
||||
code: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NetworkFieldChange(BaseModel):
|
||||
field: str
|
||||
before: str | None
|
||||
after: str | None
|
||||
|
||||
|
||||
class NetworkChangePreview(BaseModel):
|
||||
token: str
|
||||
kind: str
|
||||
action: str
|
||||
uuid: str | None
|
||||
host_id: str
|
||||
host_name: str
|
||||
changes: list[NetworkFieldChange]
|
||||
risks: list[NetworkChangeRisk]
|
||||
warnings: list[NetworkChangeRisk]
|
||||
blocks: list[NetworkChangeRisk]
|
||||
requires_acknowledgement: bool
|
||||
management_device: str | None
|
||||
can_execute: bool
|
||||
|
||||
|
||||
class NetworkChangeResult(BaseModel):
|
||||
status: str
|
||||
uuid: str | None
|
||||
verified: bool
|
||||
mismatches: list[str]
|
||||
backup_id: str
|
||||
@@ -9,7 +9,7 @@ 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",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ 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
|
||||
|
||||
poll_interval_seconds: int
|
||||
@@ -71,6 +72,7 @@ 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
|
||||
|
||||
poll_interval_seconds: int | None = Field(
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -62,6 +62,10 @@ async def _live_rows_or_none(host: OpnsenseHost, nat_type: str) -> list[dict] |
|
||||
|
||||
|
||||
async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> list[dict]:
|
||||
return (await compute_cluster_drift_with_reachable(hosts, nat_type))[0]
|
||||
|
||||
|
||||
async def compute_cluster_drift_with_reachable(hosts: list[OpnsenseHost], nat_type: str) -> tuple[list[dict], set]:
|
||||
"""Same shape/contract as rule_drift_service.compute_cluster_drift:
|
||||
one row per fingerprint missing from at least one reachable host."""
|
||||
_, fingerprint_fn = _SPECS[nat_type]
|
||||
@@ -78,4 +82,4 @@ async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> lis
|
||||
for entry in by_fingerprint.values():
|
||||
entry["reachable_host_ids"] = reachable_ids
|
||||
|
||||
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)]
|
||||
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)], reachable_ids
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Changes to VLANs, virtual IPs, gateways and static routes of ONE firewall.
|
||||
|
||||
Unlike rules, these objects are specific to a firewall (own IPs, own VLAN
|
||||
layout), so there is no central copy and no multi-host push: the firewall is the
|
||||
source of truth and every change goes straight to it, but only through a
|
||||
preview/execute pair:
|
||||
|
||||
preview reads the live state, computes the diff and the risks, and returns a
|
||||
token that covers exactly that state.
|
||||
execute re-reads the live state, refuses if it no longer matches the token,
|
||||
refuses risky changes without an explicit acknowledgement, saves a
|
||||
fresh config backup, writes the object, applies it, and reads it back
|
||||
to check that the firewall really holds what was asked for.
|
||||
|
||||
The main risk is cutting the connection OPNexus itself uses (the interface or
|
||||
address the firewall is reached on, the default route, a route that swallows the
|
||||
management address). Those cases are detected from the firewall's own
|
||||
interface list and need the acknowledgement. OPNexus cannot undo a change that
|
||||
cuts it off, so there is deliberately NO automatic rollback here — the backup
|
||||
taken before the change is the way back.
|
||||
|
||||
Secrets (the CARP password) are never returned, hashed into the token only, and
|
||||
never written to the audit log.
|
||||
"""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import audit_service, backup_store, network_service, opnsense_client
|
||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
# Only these fields can be sent; anything else is rejected, never forwarded.
|
||||
FIELDS: dict[str, set[str]] = {
|
||||
"vlan": {"if", "tag", "pcp", "proto", "descr"},
|
||||
"vip": {"interface", "mode", "network", "vhid", "advbase", "advskew", "password", "descr", "nosync", "peer", "peer6"},
|
||||
"gateway": {"disabled", "name", "descr", "interface", "ipprotocol", "gateway", "defaultgw", "fargw",
|
||||
"monitor_disable", "monitor", "priority", "weight"},
|
||||
"route": {"network", "gateway", "descr", "enabled"},
|
||||
"dns_host": {"enabled", "hostname", "domain", "rr", "server", "mx", "mxprio", "addptr", "description"},
|
||||
"dhcp_host": {"host", "domain", "local", "ip", "hwaddr", "client_id", "lease_time", "ignore", "descr"},
|
||||
"kea_subnet": {"subnet", "pools", "valid_lifetime", "next_server", "description", "option_data.routers",
|
||||
"option_data.domain_name_servers", "option_data.domain_name", "option_data.ntp_servers"},
|
||||
"kea_reservation": {"subnet", "ip_address", "hw_address", "client_id", "hostname", "description", "next_server"},
|
||||
"tunable": {"tunable", "value", "descr"},
|
||||
"cron_job": {"enabled", "minutes", "hours", "days", "months", "weekdays", "who", "command", "parameters", "description"},
|
||||
"syslog_destination": {"enabled", "transport", "program", "level", "facility", "hostname", "port", "rfc5424", "description"},
|
||||
"gateway_group": {"name", "item", "item2", "item3", "item4", "item5", "trigger", "poolopts", "descr"},
|
||||
"lagg": {"members", "primary_member", "proto", "lacp_fast_timeout", "use_flowid", "lagghash", "lacp_strict", "mtu", "descr"},
|
||||
"bridge": {"members", "linklocal", "enablestp", "proto", "descr"},
|
||||
"loopback": {"deviceId", "description"},
|
||||
"gre": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
|
||||
"gif": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
|
||||
"vxlan": {"deviceId", "vxlanid", "vxlanlocal", "vxlanlocalport", "vxlanremote", "vxlanremoteport", "vxlangroup", "vxlandev"},
|
||||
"dns_forward": {"enabled", "type", "domain", "server", "port", "verify", "forward_tcp_upstream", "forward_first", "description"},
|
||||
"dhcp_range": {"interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "domain_type", "domain", "description"},
|
||||
"interface": {"if", "descr", "enable", "type4", "ipaddr", "gateway", "type6", "ipaddrv6", "gatewayv6", "mtu",
|
||||
"blockpriv", "blockbogons", "lock"},
|
||||
}
|
||||
SECRET_FIELDS: dict[str, set[str]] = {"vip": {"password"}}
|
||||
# Section of HostNetwork that must be readable for the kind to be managed on this firewall.
|
||||
SECTION = {"vlan": "vlans", "vip": "vips", "gateway": "gateways", "route": "routes", "interface": "assignments",
|
||||
"dns_host": "dns_hosts", "dhcp_host": "dhcp_hosts", "kea_subnet": "kea_subnets",
|
||||
"kea_reservation": "kea_reservations"}
|
||||
# Columns the generic object lists show (the firewall's own field names). Lists never carry fields outside FIELDS.
|
||||
OBJECT_COLUMNS: dict[str, list[str]] = {
|
||||
"tunable": ["tunable", "value", "default_value", "descr"],
|
||||
"cron_job": ["enabled", "minutes", "hours", "days", "months", "weekdays", "command", "description"],
|
||||
"syslog_destination": ["enabled", "transport", "hostname", "port", "program", "level", "description"],
|
||||
"gateway_group": ["name", "item", "item2", "item3", "trigger", "descr"],
|
||||
"lagg": ["laggif", "members", "proto", "descr"],
|
||||
"bridge": ["bridgeif", "members", "proto", "descr"],
|
||||
"loopback": ["deviceId", "description"],
|
||||
"gre": ["greif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
|
||||
"gif": ["gifif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
|
||||
"vxlan": ["deviceId", "vxlanid", "vxlanlocal", "vxlanremote", "vxlangroup", "vxlandev"],
|
||||
"dns_forward": ["enabled", "type", "domain", "server", "port", "description"],
|
||||
"dhcp_range": ["interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "description"],
|
||||
}
|
||||
# These change how the network is wired; the others (DNS/DHCP data) cannot cut the connection to the firewall.
|
||||
WIRING_KINDS = {"vlan", "vip", "gateway", "route", "interface", "lagg", "bridge", "gre", "gif", "vxlan"}
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def validate_fields(kind: str, fields: dict) -> dict[str, str]:
|
||||
allowed = FIELDS[kind]
|
||||
clean: dict[str, str] = {}
|
||||
for key, value in fields.items():
|
||||
if key not in allowed:
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=str(key)[:40])
|
||||
if isinstance(value, bool):
|
||||
value = "1" if value else "0"
|
||||
if value is None:
|
||||
value = ""
|
||||
if not isinstance(value, (str, int)) or isinstance(value, bool):
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
|
||||
text = str(value).strip()
|
||||
if len(text) > 255 or _CONTROL.search(text):
|
||||
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=key)
|
||||
clean[key] = text
|
||||
return clean
|
||||
|
||||
|
||||
def _explode(node: dict) -> dict:
|
||||
"""Kea nests its DHCP options one level deep; treat them as "option_data.<name>" like the whitelist does."""
|
||||
out: dict = {}
|
||||
for key, value in node.items():
|
||||
if key == "option_data" and isinstance(value, dict):
|
||||
for child, inner in value.items():
|
||||
out[f"option_data.{child}"] = inner
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def flatten(kind: str, node: dict) -> dict[str, str]:
|
||||
"""OPNsense answers select fields as {option: {selected: 0|1}}; reduce to the selected value(s)."""
|
||||
out: dict[str, str] = {}
|
||||
for key, value in _explode(node).items():
|
||||
if key in SECRET_FIELDS.get(kind, set()):
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
selected = [k for k, o in value.items() if isinstance(o, dict) and o.get("selected") and k != ""]
|
||||
if value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
|
||||
out[key] = ",".join(selected)
|
||||
continue
|
||||
out[key] = "" if value is None else str(value)
|
||||
return out
|
||||
|
||||
|
||||
def _ip(text: str):
|
||||
try:
|
||||
return ipaddress.ip_interface(text.strip()).ip
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _network(text: str):
|
||||
try:
|
||||
return ipaddress.ip_network(text.strip(), strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _risk(code: str, **params) -> dict:
|
||||
return {"code": code, "params": params}
|
||||
|
||||
|
||||
def assess(kind: str, action: str, uuid: str | None, before: dict, after: dict,
|
||||
net, mgmt_addresses: set, peers: list[str], context: dict | None = None) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
"""Returns (risks needing acknowledgement, plain warnings, hard blocks)."""
|
||||
risks: list[dict] = []
|
||||
warnings: list[dict] = []
|
||||
blocks: list[dict] = []
|
||||
mgmt_device = net.management_device
|
||||
mgmt_iface = next((i for i in net.interfaces if i.device == mgmt_device), None)
|
||||
mgmt_ident = mgmt_iface.identifier if mgmt_iface else None
|
||||
|
||||
if SECTION.get(kind) in net.unavailable:
|
||||
blocks.append(_risk("api_unavailable", section=SECTION[kind]))
|
||||
if mgmt_device is None and kind in WIRING_KINDS:
|
||||
# We cannot tell what this change could cut, so treat every change to existing objects as risky.
|
||||
if action in ("set", "del"):
|
||||
risks.append(_risk("management_unknown"))
|
||||
|
||||
context = context or {}
|
||||
if kind == "interface":
|
||||
if not net.can_configure_interfaces:
|
||||
blocks.append(_risk("interface_api_unavailable"))
|
||||
if context.get("pending"):
|
||||
blocks.append(_risk("interface_changes_pending"))
|
||||
if action in ("set", "del") and mgmt_ident and uuid == mgmt_ident:
|
||||
risks.append(_risk("management_interface", device=mgmt_device))
|
||||
if action == "del" and before.get("lock") == "1":
|
||||
# OPNsense refuses to remove a locked interface; say so now instead of failing at apply time.
|
||||
blocks.append(_risk("interface_locked"))
|
||||
if action == "del":
|
||||
rules = context.get("rules_using") or []
|
||||
if rules:
|
||||
risks.append(_risk("interface_rules_removed", count=len(rules), names=", ".join(rules[:5])))
|
||||
if action in ("set", "del") and uuid in ("wan", "lan") and uuid != mgmt_ident:
|
||||
warnings.append(_risk("core_interface", name=uuid))
|
||||
|
||||
if kind in ("lagg", "bridge"):
|
||||
members = {m for side in (before, after) for m in side.get("members", "").split(",") if m}
|
||||
if mgmt_device and mgmt_device in members:
|
||||
risks.append(_risk("management_member", device=mgmt_device))
|
||||
elif mgmt_device is None and action in ("set", "del"):
|
||||
pass # already covered by management_unknown above
|
||||
if kind == "tunable":
|
||||
# sysctl values can change how the whole system behaves (and some only apply after a reboot).
|
||||
risks.append(_risk("tunable_change"))
|
||||
if action in ("set", "del") and uuid and "-" not in uuid:
|
||||
# A built-in default (its id is not a UUID): the firewall would create a new entry instead of
|
||||
# changing it, so overriding one is an "add", not an edit.
|
||||
blocks.append(_risk("tunable_default_row"))
|
||||
if kind == "gateway_group" and action == "del":
|
||||
warnings.append(_risk("gateway_group_in_use"))
|
||||
|
||||
if kind == "vlan" and action in ("set", "del"):
|
||||
device = next((v.device for v in net.vlans if v.uuid == uuid), None)
|
||||
if device and device == mgmt_device:
|
||||
risks.append(_risk("management_interface", device=device))
|
||||
|
||||
if kind == "vip":
|
||||
for side in (before, after) if action != "add" else (after,):
|
||||
ip = _ip(side.get("network", "")) if side.get("network") else None
|
||||
if ip is not None and ip in mgmt_addresses:
|
||||
risks.append(_risk("management_address", address=str(ip)))
|
||||
break
|
||||
if "carp" in (before.get("mode"), after.get("mode")):
|
||||
warnings.append(_risk("carp_change"))
|
||||
|
||||
if kind == "gateway":
|
||||
current = next((g for g in net.gateways if g.uuid == uuid), None)
|
||||
if current is not None and action in ("set", "del"):
|
||||
if current.is_default:
|
||||
risks.append(_risk("default_gateway", name=current.name))
|
||||
if mgmt_ident and current.interface == mgmt_ident:
|
||||
risks.append(_risk("management_gateway", name=current.name))
|
||||
if action in ("add", "set") and after.get("defaultgw") == "1" and (current is None or not current.is_default):
|
||||
risks.append(_risk("default_gateway_change"))
|
||||
|
||||
if kind == "route":
|
||||
for side in (before, after) if action != "add" else (after,):
|
||||
network = _network(side.get("network", "")) if side.get("network") else None
|
||||
if network is None:
|
||||
continue
|
||||
if network.prefixlen == 0:
|
||||
risks.append(_risk("default_route", network=str(network)))
|
||||
break
|
||||
if any(a.version == network.version and a in network for a in mgmt_addresses):
|
||||
risks.append(_risk("route_covers_management", network=str(network)))
|
||||
break
|
||||
|
||||
if peers:
|
||||
warnings.append(_risk("cluster_peers", hosts=", ".join(peers)))
|
||||
# De-duplicate while keeping order.
|
||||
seen, unique = set(), []
|
||||
for r in risks:
|
||||
key = json.dumps(r, sort_keys=True)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique, warnings, blocks
|
||||
|
||||
|
||||
def _token(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict, before: dict, mgmt_device) -> str:
|
||||
state = {
|
||||
"host": str(host.id), "url": host.base_url, "kind": kind, "action": action, "uuid": uuid,
|
||||
# hash, so a secret never sits in the token's input in the clear
|
||||
"fields": hashlib.sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest(),
|
||||
"before": before, "mgmt": mgmt_device,
|
||||
}
|
||||
return hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def _public(kind: str, fields: dict) -> dict:
|
||||
return {k: ("(gesetzt)" if k in SECRET_FIELDS.get(kind, set()) and v else v) for k, v in fields.items()
|
||||
if not (k in SECRET_FIELDS.get(kind, set()) and not v)}
|
||||
|
||||
|
||||
async def _peers(db: AsyncSession, host: OpnsenseHost) -> list[str]:
|
||||
if not host.cluster_name:
|
||||
return []
|
||||
rows = await db.scalars(select(OpnsenseHost.name).where(
|
||||
OpnsenseHost.cluster_name == host.cluster_name, OpnsenseHost.id != host.id).order_by(OpnsenseHost.name))
|
||||
return list(rows.all())
|
||||
|
||||
|
||||
async def list_objects(host: OpnsenseHost, kind: str) -> dict:
|
||||
"""Rows of one generic object kind, reduced to the columns above (plus uuid) — never whole rows,
|
||||
so nothing the list does not need (or must not show) leaves the server."""
|
||||
columns = OBJECT_COLUMNS[kind]
|
||||
try:
|
||||
rows = await opnsense_client.search_network_objects(host, kind)
|
||||
except OpnsenseAPIError as exc:
|
||||
if "404" in str(exc):
|
||||
return {"available": False, "columns": columns, "rows": []} # this OPNsense version lacks the API
|
||||
raise
|
||||
out = []
|
||||
for row in rows:
|
||||
item = {"uuid": str(row.get("uuid", ""))}
|
||||
for col in columns:
|
||||
value = row.get(col)
|
||||
item[col] = "" if value is None or isinstance(value, (dict, list)) else str(value)
|
||||
out.append(item)
|
||||
return {"available": True, "columns": columns, "rows": out}
|
||||
|
||||
|
||||
async def _rules_using(host: OpnsenseHost, identifier: str) -> list[str]:
|
||||
"""Descriptions of the user-made rules that name this interface (OPNsense deletes them with it)."""
|
||||
data = await opnsense_client.search_rules(host, all_rows=True)
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [str(r.get("description") or r.get("uuid")) for r in rows
|
||||
if isinstance(r, dict) and not r.get("is_automatic")
|
||||
and identifier in str(r.get("interface") or "").split(",")]
|
||||
|
||||
|
||||
async def preview(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> dict:
|
||||
fields = validate_fields(kind, fields)
|
||||
if action in ("set", "del") and not uuid:
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
net = await network_service.get_host_network(host)
|
||||
before: dict[str, str] = {}
|
||||
if action in ("set", "del"):
|
||||
node = await opnsense_client.get_network_item(host, kind, uuid)
|
||||
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
# Only the fields that may be sent: the rest (e.g. Kea's DDNS key secret) never reaches the token or the audit log.
|
||||
before = {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
|
||||
after = {} if action == "del" else ({**before, **fields} if action == "set" else dict(fields))
|
||||
mgmt_addresses = await network_service.management_addresses(host)
|
||||
context: dict = {}
|
||||
if kind == "interface":
|
||||
if net.can_configure_interfaces:
|
||||
context["pending"] = await opnsense_client.interface_changes_pending(host)
|
||||
if action == "del":
|
||||
context["rules_using"] = await _rules_using(host, uuid)
|
||||
risks, warnings, blocks = assess(kind, action, uuid, before, after, net, mgmt_addresses, await _peers(db, host), context)
|
||||
secret = SECRET_FIELDS.get(kind, set())
|
||||
changes = []
|
||||
for key in sorted(set(before) | set(fields)):
|
||||
old, new = before.get(key), fields.get(key) if action != "del" else None
|
||||
if action == "del":
|
||||
if old not in (None, "") and key in FIELDS[kind]:
|
||||
changes.append({"field": key, "before": old, "after": None})
|
||||
elif key in fields and (action == "add" or old != new):
|
||||
if key in secret:
|
||||
changes.append({"field": key, "before": None, "after": "(gesetzt)" if new else None})
|
||||
else:
|
||||
changes.append({"field": key, "before": old, "after": new})
|
||||
token = _token(host, kind, action, uuid, fields, before, net.management_device)
|
||||
return {
|
||||
"token": token, "kind": kind, "action": action, "uuid": uuid, "host_id": str(host.id), "host_name": host.name,
|
||||
"changes": changes, "risks": risks, "warnings": warnings, "blocks": blocks,
|
||||
"requires_acknowledgement": bool(risks), "management_device": net.management_device,
|
||||
"can_execute": not blocks and (action == "del" or bool(changes)),
|
||||
"_fields": fields, "_before": before,
|
||||
}
|
||||
|
||||
|
||||
def _matches(expected: dict[str, str], actual: dict[str, str], secret: set[str]) -> list[str]:
|
||||
return [k for k, v in expected.items() if k not in secret and actual.get(k, "") != v]
|
||||
|
||||
|
||||
async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict) -> tuple[bool, list[str]]:
|
||||
try:
|
||||
if action == "del":
|
||||
present = await opnsense_client.list_network_item_uuids(host, kind)
|
||||
return (uuid not in present), ([] if uuid not in present else ["still_present"])
|
||||
actual = flatten(kind, await opnsense_client.get_network_item(host, kind, uuid))
|
||||
mismatches = _matches(fields, actual, SECRET_FIELDS.get(kind, set()))
|
||||
return (not mismatches), mismatches
|
||||
except OpnsenseAPIError:
|
||||
return False, ["unreadable"]
|
||||
|
||||
|
||||
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
|
||||
token: str, acknowledged: bool) -> dict:
|
||||
await ensure_no_maintenance(db, [host])
|
||||
try:
|
||||
plan = await preview(db, host, kind, action, uuid, fields)
|
||||
except OpnsenseAPIError as exc:
|
||||
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
|
||||
if plan["token"] != token:
|
||||
raise api_error(409, ErrorCode.ENTWURF_ODER_ZIELE_WURDEN_GEAENDERT_VORSCHAU_ERNEUT_OEFFNEN)
|
||||
if plan["blocks"]:
|
||||
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons=", ".join(b["code"] for b in plan["blocks"]))
|
||||
if not plan["can_execute"]:
|
||||
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="nothing_to_change")
|
||||
if plan["requires_acknowledgement"] and not acknowledged:
|
||||
raise api_error(409, ErrorCode.NETWORK_ACK_REQUIRED, reasons=", ".join(r["code"] for r in plan["risks"]))
|
||||
|
||||
fields = plan["_fields"]
|
||||
send = dict(fields)
|
||||
if kind == "vip" and action == "set" and plan["_before"].get("network"):
|
||||
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
|
||||
# the request, so every VIP update must repeat it (found live: a partial set was rejected / could wipe it).
|
||||
send.setdefault("network", plan["_before"]["network"])
|
||||
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
|
||||
expected = {**plan["_before"], **send} if action == "set" else dict(send)
|
||||
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
|
||||
audit = {"kind": "network", "action": f"{kind}.{action}"[:40], "entity_id": uuid, "entity_name": entity,
|
||||
"host_id": str(host.id), "host_name": host.name}
|
||||
details = {"before": plan["_before"], "after": _public(kind, fields) if action != "del" else None,
|
||||
"risks": [r["code"] for r in plan["risks"]], "acknowledged": acknowledged}
|
||||
|
||||
# Way back: a config backup taken right before the change. No backup, no change.
|
||||
try:
|
||||
backup_id = backup_store.save_snapshot(host.id, await opnsense_client.download_backup(host))
|
||||
except OpnsenseAPIError as exc:
|
||||
await audit_service.persist(**audit, status="aborted", message="Backup vor der Änderung fehlgeschlagen", details=details)
|
||||
raise api_error(502, ErrorCode.NETWORK_BACKUP_FAILED, host=host.name) from exc
|
||||
details["backup_id"] = backup_id
|
||||
await audit_service.persist(**audit, status="started", message=None, details=details)
|
||||
|
||||
try:
|
||||
result = await opnsense_client.write_network_item(host, kind, action, uuid, send)
|
||||
except OpnsenseAPIError as exc:
|
||||
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
|
||||
try:
|
||||
await opnsense_client.reconfigure_network(host, kind)
|
||||
except OpnsenseAPIError as exc:
|
||||
# Written but not (provably) applied: say so instead of reporting success.
|
||||
await audit_service.persist(**audit, status="apply_uncertain", message=str(exc)[:500], details=details)
|
||||
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id=backup_id) from exc
|
||||
|
||||
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
|
||||
await audit_service.persist(**audit, status="applied" if verified else "unverified",
|
||||
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
||||
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
|
||||
|
||||
|
||||
def _options(value: dict) -> list[dict]:
|
||||
return [{"value": k, "label": str(o.get("value", k)) if isinstance(o, dict) else str(k), "selected": bool(isinstance(o, dict) and o.get("selected"))}
|
||||
for k, o in value.items()]
|
||||
|
||||
|
||||
async def template(host: OpnsenseHost, kind: str) -> dict:
|
||||
"""The firewall's own blank form for this kind: field defaults and, for select fields, the choices it
|
||||
accepts right now (e.g. only the gateways it considers valid for a route)."""
|
||||
node = _explode(await opnsense_client.get_network_item(host, kind, None))
|
||||
out: dict[str, dict] = {}
|
||||
for key in sorted(FIELDS[kind]):
|
||||
if key not in node:
|
||||
continue
|
||||
value = node[key]
|
||||
if isinstance(value, dict) and value and all(isinstance(o, dict) and "selected" in o for o in value.values()):
|
||||
options = _options(value)
|
||||
# Free-text list fields (IPs, MACs, DNS servers) arrive in this shape too; the form decides how to show them.
|
||||
out[key] = {"value": ",".join(o["value"] for o in options if o["selected"] and o["value"] != ""), "options": options}
|
||||
elif key in SECRET_FIELDS.get(kind, set()):
|
||||
out[key] = {"value": "", "options": None}
|
||||
else:
|
||||
out[key] = {"value": "" if value is None or isinstance(value, dict) else str(value), "options": None}
|
||||
return out
|
||||
|
||||
|
||||
async def item(host: OpnsenseHost, kind: str, uuid: str) -> dict[str, str]:
|
||||
"""One object as the firewall holds it right now, reduced to the fields that may be sent (no secrets)."""
|
||||
node = await opnsense_client.get_network_item(host, kind, uuid)
|
||||
if not node or not any(v not in ("", None) for v in node.values() if not isinstance(v, dict)):
|
||||
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||
return {k: v for k, v in flatten(kind, node).items() if k in FIELDS[kind]}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Compares the network configuration of the nodes of an HA cluster.
|
||||
|
||||
Interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP objects should
|
||||
be the same on both nodes of a pair; a VLAN or gateway that exists on one node
|
||||
only is exactly the kind of difference that breaks a failover months later.
|
||||
|
||||
Per-node values are deliberately NOT compared: interface addresses, CARP
|
||||
advskew/password and the active default gateway legitimately differ between
|
||||
the nodes. What is compared is the identity of each object (its key) and the
|
||||
settings that must agree (its signature). A category is skipped when any
|
||||
reachable node cannot deliver it (older OPNsense), instead of reporting that
|
||||
as a difference.
|
||||
"""
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.schemas.network import HostNetwork
|
||||
from app.services import network_service
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
CATEGORIES = ["interfaces", "vlans", "vips", "gateways", "routes", "dns_hosts", "dhcp_hosts", "kea_subnets", "kea_reservations"]
|
||||
|
||||
|
||||
def signatures(net: HostNetwork) -> dict[str, dict[str, str]]:
|
||||
out: dict[str, dict[str, str]] = {c: {} for c in CATEGORIES}
|
||||
for i in net.interfaces:
|
||||
if i.assigned and i.identifier:
|
||||
out["interfaces"][i.identifier] = f"{i.description}|{i.vlan_tag or ''}|{i.ipv4_type or ''}"
|
||||
for v in net.vlans:
|
||||
out["vlans"][f"{v.tag}@{v.parent}"] = v.description
|
||||
for v in net.vips:
|
||||
if v.mode == "carp":
|
||||
out["vips"][f"carp:{v.vhid}"] = f"{v.address}|{v.advbase or ''}|{v.description}"
|
||||
else:
|
||||
out["vips"][f"{v.mode}:{v.address}"] = v.description
|
||||
for g in net.gateways:
|
||||
out["gateways"][g.name] = f"{g.interface}|{g.ip_protocol}|{g.address}|{g.disabled}"
|
||||
for r in net.routes:
|
||||
out["routes"][r.network] = f"{r.gateway}|{r.disabled}"
|
||||
for d in net.dns_hosts:
|
||||
out["dns_hosts"][f"{d.hostname}.{d.domain}|{d.record_type}"] = f"{d.server}|{d.enabled}"
|
||||
for h in net.dhcp_hosts:
|
||||
out["dhcp_hosts"][f"{h.host}.{h.domain}"] = f"{h.ip}|{h.hwaddr}"
|
||||
for s in net.kea_subnets:
|
||||
out["kea_subnets"][s.subnet] = f"{s.pools}|{s.routers}|{s.dns_servers}"
|
||||
for k in net.kea_reservations:
|
||||
out["kea_reservations"][f"{k.ip_address}|{k.hw_address}"] = k.hostname
|
||||
return out
|
||||
|
||||
|
||||
# Which HostNetwork.unavailable entry makes a category incomparable.
|
||||
_UNAVAILABLE_KEY = {"interfaces": "assignments"}
|
||||
|
||||
|
||||
def compare(per_host: dict[str, HostNetwork]) -> list[dict]:
|
||||
"""per_host: host name -> its network. Returns one row per object that is missing on a node or differs."""
|
||||
names = sorted(per_host)
|
||||
sigs = {n: signatures(per_host[n]) for n in names}
|
||||
rows: list[dict] = []
|
||||
for category in CATEGORIES:
|
||||
skip_key = _UNAVAILABLE_KEY.get(category, category)
|
||||
if any(skip_key in per_host[n].unavailable for n in names):
|
||||
continue
|
||||
keys = sorted({k for n in names for k in sigs[n][category]})
|
||||
for key in keys:
|
||||
present = {n: sigs[n][category][key] for n in names if key in sigs[n][category]}
|
||||
if len(present) < len(names):
|
||||
rows.append({"category": category, "key": key, "status": "missing",
|
||||
"present_on": sorted(present), "missing_from": sorted(set(names) - set(present)),
|
||||
"signatures": present})
|
||||
elif len(set(present.values())) > 1:
|
||||
rows.append({"category": category, "key": key, "status": "different",
|
||||
"present_on": names, "missing_from": [], "signatures": present})
|
||||
return rows
|
||||
|
||||
|
||||
async def compute_cluster(hosts: list[OpnsenseHost]) -> tuple[list[dict], list[str], list[str]]:
|
||||
"""Returns (differences, compared host names, unreachable host names)."""
|
||||
per_host: dict[str, HostNetwork] = {}
|
||||
unreachable: list[str] = []
|
||||
for host in hosts:
|
||||
try:
|
||||
per_host[host.name] = await network_service.get_host_network(host)
|
||||
except OpnsenseAPIError:
|
||||
unreachable.append(host.name)
|
||||
if len(per_host) < 2:
|
||||
return [], sorted(per_host), sorted(unreachable)
|
||||
return compare(per_host), sorted(per_host), sorted(unreachable)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Read-only network inventory of one firewall: interfaces, VLANs, virtual
|
||||
IPs (CARP), gateways and static routes, straight from the firewall.
|
||||
|
||||
Also works out which interface the firewall answers OPNexus on. Every later
|
||||
write feature in this area (VLAN/IP/gateway changes) must refuse or escalate
|
||||
changes to that interface — an apply there can cut the very connection OPNexus
|
||||
would need to undo it.
|
||||
|
||||
Shapes verified live on OPNsense 26.7.1 and 26.7.6 (see opnsense_client).
|
||||
"""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.schemas.network import (
|
||||
HostNetwork,
|
||||
NetAddress,
|
||||
NetGateway,
|
||||
NetDhcpHost,
|
||||
NetDnsHost,
|
||||
NetInterface,
|
||||
NetKeaReservation,
|
||||
NetKeaSubnet,
|
||||
NetRoute,
|
||||
NetVip,
|
||||
NetVlan,
|
||||
)
|
||||
from app.services import opnsense_client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
|
||||
def _rows(data) -> list[dict]:
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
def _flag(value) -> bool:
|
||||
return value in ("1", 1, True)
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _opt(value) -> str | None:
|
||||
"""OPNsense is inconsistent about number vs. string (a gateway priority came back as 254, not "254")."""
|
||||
return None if value in (None, "") else str(value)
|
||||
|
||||
|
||||
async def management_addresses(host: OpnsenseHost) -> set:
|
||||
"""IP addresses OPNexus uses to reach this firewall (literal or resolved)."""
|
||||
hostname = urlparse(host.base_url).hostname
|
||||
if not hostname:
|
||||
return set()
|
||||
try:
|
||||
return {ipaddress.ip_address(hostname)}
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
infos = await asyncio.get_running_loop().getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return set()
|
||||
found = set()
|
||||
for info in infos:
|
||||
try:
|
||||
found.add(ipaddress.ip_address(info[4][0].split("%")[0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return found
|
||||
|
||||
|
||||
def _addresses(row: dict) -> list[NetAddress]:
|
||||
result = []
|
||||
for key in ("ipv4", "ipv6"):
|
||||
for entry in row.get(key) or []:
|
||||
if isinstance(entry, dict) and entry.get("ipaddr"):
|
||||
result.append(NetAddress(
|
||||
address=str(entry["ipaddr"]),
|
||||
vhid=_opt(entry.get("vhid")),
|
||||
carp_status=_opt(entry.get("status")),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _address_ip(address: str):
|
||||
try:
|
||||
return ipaddress.ip_interface(address).ip
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _interface(row: dict, assignments: dict[str, dict], management: set) -> NetInterface:
|
||||
identifier = row.get("identifier") or None
|
||||
assigned = assignments.get(identifier or "") or {}
|
||||
addresses = _addresses(row)
|
||||
vlan = row.get("vlan") or {}
|
||||
return NetInterface(
|
||||
device=_text(row.get("device")),
|
||||
identifier=identifier,
|
||||
description=_text(row.get("description")),
|
||||
status=_text(row.get("status")),
|
||||
enabled=_flag(row.get("enabled", True)) if "enabled" in row else True,
|
||||
is_physical=bool(row.get("is_physical")),
|
||||
mtu=_opt(row.get("mtu")),
|
||||
mac=_opt(row.get("macaddr")),
|
||||
media=_opt(row.get("media")),
|
||||
vlan_tag=_opt((vlan.get("tag") if isinstance(vlan, dict) else None) or row.get("vlan_tag")),
|
||||
vlan_parent=_opt(vlan.get("parent")) if isinstance(vlan, dict) else None,
|
||||
ipv4_type=assigned.get("%type4") or None,
|
||||
ipv6_type=assigned.get("%type6") or None,
|
||||
addresses=addresses,
|
||||
pending_action=assigned.get("pending_action") or None,
|
||||
assigned=bool(identifier and identifier in assignments),
|
||||
is_management=any(_address_ip(a.address) in management for a in addresses if not a.vhid),
|
||||
)
|
||||
|
||||
|
||||
def _vlan(row: dict) -> NetVlan:
|
||||
return NetVlan(
|
||||
uuid=_text(row.get("uuid")),
|
||||
parent=_text(row.get("if")),
|
||||
tag=_text(row.get("tag")),
|
||||
pcp=_opt(row.get("pcp")),
|
||||
description=_text(row.get("descr")),
|
||||
device=(row.get("vlanif") or "").split(" ")[0] or None,
|
||||
)
|
||||
|
||||
|
||||
def _vip(row: dict) -> NetVip:
|
||||
# Deliberately picks fields by name: the row also carries the CARP password.
|
||||
return NetVip(
|
||||
uuid=_text(row.get("uuid")),
|
||||
interface=_text(row.get("interface")),
|
||||
interface_label=_text(row.get("%interface") or row.get("interface")),
|
||||
mode=_text(row.get("mode")),
|
||||
address=_text(row.get("address") or row.get("subnet")),
|
||||
vhid=_opt(row.get("vhid")),
|
||||
advbase=_opt(row.get("advbase")),
|
||||
advskew=_opt(row.get("advskew")),
|
||||
description=_text(row.get("descr")),
|
||||
)
|
||||
|
||||
|
||||
def _gateway(row: dict) -> NetGateway:
|
||||
return NetGateway(
|
||||
uuid=_text(row.get("uuid")),
|
||||
name=_text(row.get("name")),
|
||||
description=_text(row.get("descr")),
|
||||
interface=_text(row.get("interface")),
|
||||
interface_label=_text(row.get("interface_descr") or row.get("interface")),
|
||||
ip_protocol=_text(row.get("ipprotocol")),
|
||||
address=_text(row.get("gateway")),
|
||||
is_default=bool(row.get("defaultgw")),
|
||||
disabled=bool(row.get("disabled")),
|
||||
priority=_opt(row.get("priority")),
|
||||
status=_text(row.get("status")),
|
||||
)
|
||||
|
||||
|
||||
def _route(row: dict) -> NetRoute:
|
||||
return NetRoute(
|
||||
uuid=_text(row.get("uuid")),
|
||||
network=_text(row.get("network")),
|
||||
gateway=_text(row.get("gateway")),
|
||||
description=_text(row.get("descr")),
|
||||
# list rows carry "enabled" ("1"/"0"); older shapes a "disabled" flag
|
||||
disabled=_flag(row.get("disabled")) or row.get("enabled") in ("0", 0, False),
|
||||
)
|
||||
|
||||
|
||||
def _dns_host(row: dict) -> NetDnsHost:
|
||||
return NetDnsHost(
|
||||
uuid=_text(row.get("uuid")), hostname=_text(row.get("hostname")), domain=_text(row.get("domain")),
|
||||
record_type=_text(row.get("rr")), server=_text(row.get("server")), enabled=_flag(row.get("enabled")),
|
||||
description=_text(row.get("description")),
|
||||
)
|
||||
|
||||
|
||||
def _dhcp_host(row: dict) -> NetDhcpHost:
|
||||
return NetDhcpHost(
|
||||
uuid=_text(row.get("uuid")), host=_text(row.get("host")), domain=_text(row.get("domain")),
|
||||
ip=_text(row.get("ip")), hwaddr=_text(row.get("hwaddr")), lease_time=_text(row.get("lease_time")),
|
||||
description=_text(row.get("descr")),
|
||||
)
|
||||
|
||||
|
||||
def _kea_subnet(row: dict) -> NetKeaSubnet:
|
||||
# Picked by name on purpose: the row also carries the DDNS key secret.
|
||||
return NetKeaSubnet(
|
||||
uuid=_text(row.get("uuid")), subnet=_text(row.get("subnet")), pools=_text(row.get("pools")),
|
||||
routers=_text(row.get("option_data.routers")), dns_servers=_text(row.get("option_data.domain_name_servers")),
|
||||
description=_text(row.get("description")),
|
||||
)
|
||||
|
||||
|
||||
def _kea_reservation(row: dict) -> NetKeaReservation:
|
||||
return NetKeaReservation(
|
||||
uuid=_text(row.get("uuid")), subnet=_text(row.get("%subnet") or row.get("subnet")),
|
||||
ip_address=_text(row.get("ip_address")), hw_address=_text(row.get("hw_address")),
|
||||
hostname=_text(row.get("hostname")), description=_text(row.get("description")),
|
||||
)
|
||||
|
||||
|
||||
async def _optional(name: str, call, unavailable: list[str]) -> list[dict]:
|
||||
try:
|
||||
return _rows(await call)
|
||||
except OpnsenseAPIError:
|
||||
unavailable.append(name)
|
||||
return []
|
||||
|
||||
|
||||
async def get_host_network(host: OpnsenseHost) -> HostNetwork:
|
||||
"""Raises OpnsenseAPIError when the interface list itself is unavailable
|
||||
(host down); every other section degrades to `unavailable` instead."""
|
||||
info_rows = _rows(await opnsense_client.get_interfaces_info(host))
|
||||
unavailable: list[str] = []
|
||||
(assignment_rows, vlan_rows, vip_rows, gateway_rows, route_rows,
|
||||
dns_rows, dhcp_rows, kea_subnet_rows, kea_reservation_rows) = await asyncio.gather(
|
||||
_optional("assignments", opnsense_client.search_interface_assignments(host), unavailable),
|
||||
_optional("vlans", opnsense_client.search_vlans(host), unavailable),
|
||||
_optional("vips", opnsense_client.search_vips(host), unavailable),
|
||||
_optional("gateways", opnsense_client.search_gateways(host), unavailable),
|
||||
_optional("routes", opnsense_client.search_static_routes(host), unavailable),
|
||||
_optional("dns_hosts", opnsense_client.search_dns_hosts(host), unavailable),
|
||||
_optional("dhcp_hosts", opnsense_client.search_dhcp_hosts(host), unavailable),
|
||||
_optional("kea_subnets", opnsense_client.search_kea_subnets(host), unavailable),
|
||||
_optional("kea_reservations", opnsense_client.search_kea_reservations(host), unavailable),
|
||||
)
|
||||
management = await management_addresses(host)
|
||||
assignments = {_text(r.get("identifier")): r for r in assignment_rows if r.get("identifier")}
|
||||
interfaces = [_interface(r, assignments, management) for r in info_rows]
|
||||
management_device = next((i.device for i in interfaces if i.is_management), None)
|
||||
return HostNetwork(
|
||||
host_id=str(host.id),
|
||||
host_name=host.name,
|
||||
interfaces=interfaces,
|
||||
vlans=[_vlan(r) for r in vlan_rows],
|
||||
vips=[_vip(r) for r in vip_rows],
|
||||
gateways=[_gateway(r) for r in gateway_rows],
|
||||
routes=[_route(r) for r in route_rows],
|
||||
dns_hosts=[_dns_host(r) for r in dns_rows if not r.get("isAlias")],
|
||||
dhcp_hosts=[_dhcp_host(r) for r in dhcp_rows],
|
||||
kea_subnets=[_kea_subnet(r) for r in kea_subnet_rows],
|
||||
kea_reservations=[_kea_reservation(r) for r in kea_reservation_rows],
|
||||
management_device=management_device,
|
||||
can_configure_interfaces=any("type4" in r for r in assignment_rows),
|
||||
unavailable=sorted(unavailable),
|
||||
)
|
||||
@@ -14,7 +14,8 @@ 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.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 +54,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 +86,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 +122,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 +148,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:
|
||||
|
||||
@@ -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,6 +31,7 @@ _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",
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ _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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
"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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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="-")
|
||||
@@ -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()
|
||||
|
||||
@@ -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.16.0"
|
||||
APP_CODENAME = "Hobby"
|
||||
@@ -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")
|
||||
+14
-14
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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']}")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -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"]}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")}
|
||||
@@ -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"}}
|
||||
@@ -16,3 +16,6 @@ services:
|
||||
|
||||
proxy:
|
||||
build: ./proxy
|
||||
|
||||
updater:
|
||||
build: ./updater
|
||||
@@ -120,6 +120,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,6 +136,9 @@ 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
|
||||
@@ -195,7 +201,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:
|
||||
|
||||
+317
-51
@@ -157,6 +157,56 @@ Verkehrsunterbrechung auf dem falschen Knoten.
|
||||
|
||||
---
|
||||
|
||||
## Einrichtungsassistent und Passwort-Reset
|
||||
|
||||
**Einrichtungsassistent** (`/setup`, System › Einrichtung). Vier Schritte mit
|
||||
kurzen Erklärungen, damit eine frische Installation ohne Handbuch funktioniert:
|
||||
|
||||
1. **Willkommen:** was der Assistent tut, Sprache (Deutsch/Englisch). Er liest
|
||||
nur von der Firewall; geändert wird dort erst, wenn du selbst etwas ausrollst.
|
||||
2. **Konto:** eigenes Passwort setzen (optional, empfohlen, wenn OPNexus beim
|
||||
Start eines erzeugt hat) und Zwei-Faktor-Anmeldung einrichten (derselbe
|
||||
Baustein wie in den Einstellungen).
|
||||
3. **Firewall:** Anleitung für den API-Key in OPNsense (System › Access › Users,
|
||||
Benutzer, „API keys“, `apikey.txt`), dann Name, Adresse, Key und Secret
|
||||
eintragen, Zertifikat abrufen und den Fingerabdruck bestätigen (dasselbe
|
||||
Festsetzen wie beim Hinzufügen eines Hosts). Nach dem Anlegen zeigt der
|
||||
Schritt sofort, ob die Firewall antwortet, und bei „offline“ was man prüft.
|
||||
Weitere Firewalls, auch die Knoten eines HA-Paars, lassen sich im selben
|
||||
Schritt nacheinander eintragen.
|
||||
4. **Fertig:** Zusammenfassung (Firewalls erreichbar, Zwei-Faktor an/aus) und
|
||||
Verweise auf das, was danach sinnvoll ist: Regeln, Benachrichtigungen,
|
||||
Updates, Datenbank-Sicherung.
|
||||
|
||||
*Wann er sich öffnet:* nach der Anmeldung auf dem Dashboard, solange es **keinen
|
||||
Host** gibt und dieser Benutzer den Assistenten weder abgeschlossen noch
|
||||
übersprungen hat. Der Merker liegt pro Benutzer in `user_preferences.setup_done`
|
||||
(Migration `0005`, `PUT /api/settings {"setup_done": true}`). `read_only`-Benutzer
|
||||
bekommen ihn nicht angeboten. Wieder öffnen geht über System › Einrichtung, die
|
||||
Einstellungen und die Schnellsuche (Strg/⌘+K).
|
||||
|
||||
**Passwort-Reset** (`opnexus password [BENUTZER] [--yes]`). Setzt für das
|
||||
Admin-Konto (Standard: `ADMIN_USERNAME`; gibt es das nicht und genau einen Admin,
|
||||
dieser) ein neues Zufallspasswort, gibt es einmal aus und beendet alle Sitzungen
|
||||
des Kontos. Eine Rückfrage kommt nur, wenn sich schon jemand angemeldet hat;
|
||||
ohne Terminal ist `--yes` nötig. Dahinter steht
|
||||
`docker compose exec backend python -m app.reset_password [BENUTZER]`, wie
|
||||
`app.reset_2fa` ein Werkzeug mit Shell-Zugriff auf den Server als
|
||||
Vertrauensgrenze. Eine eingeschaltete Zwei-Faktor-Anmeldung bleibt bestehen
|
||||
(verloren: `app.reset_2fa`). Es gibt bewusst keine Anzeige des alten Passworts:
|
||||
Gespeichert wird nur der Argon2id-Hash.
|
||||
|
||||
*Verifikation 2026-10-10:* Backend-Tests (`test_setup_assistant.py`: Merker
|
||||
persistiert und bleibt bei anderen Einstellungen erhalten, Reset gibt Passwort
|
||||
aus, beendet Sitzungen und tauscht den Hash, unbekannter Benutzer scheitert;
|
||||
vollständige Suite 583 bestanden). Kommandozeile gegen einen Wegwerf-Stack:
|
||||
frische Installation ohne Rückfrage, nach einer Anmeldung Rückfrage bzw.
|
||||
`--yes`, ungültige Option/Benutzer mit Fehlercode. Browser (Chromium, 1440 und
|
||||
375 px, Wegwerf-Stack mit Mock-Firewall): Anmeldung landet auf `/setup`,
|
||||
Sprachwechsel, Passwortwechsel inklusive Fehlerfall, Host online und offline,
|
||||
Zusammenfassung, Abschließen merkt sich den Zustand, keine Konsolenfehler, kein
|
||||
horizontaler Überlauf.
|
||||
|
||||
## Barrierefreiheit (2026-10-04)
|
||||
|
||||
Version 1.8.0 „Harris“. Ziel: OPNexus per Tastatur und mit Screenreader
|
||||
@@ -183,10 +233,6 @@ fand.
|
||||
`aria-hidden`, Unternavigation als eigene Landmarke. Leere Tabellenköpfe
|
||||
tragen einen nur für Screenreader sichtbaren Text („Aktionen“). Der
|
||||
Host-Status (`StatusDot`) war fest deutsch und folgt jetzt der Sprache.
|
||||
- **Prüfen.** `scripts/verify_a11y.mjs` wiederholt den axe-Lauf (nur lesend:
|
||||
schreibende Anfragen werden im Browser lokal beantwortet, das Theme wird nur
|
||||
in der Antwort überschrieben). Aufruf siehe Skriptkopf; Exit-Code ≠ 0 bei
|
||||
Verstößen.
|
||||
|
||||
**Verifikation:** axe-core (WCAG 2.0/2.1/2.2 A + AA, Best Practices) auf allen
|
||||
18 Seiten, Hell/Dunkel bei 1440 px und Hell bei 375 px: 0 Verstöße. Zusätzlich
|
||||
@@ -379,14 +425,7 @@ unter `frontend/lib/i18n/` (`de.json`/`en.json`, ca. 1.400 Schlüssel).
|
||||
Firewall-Daten (Aliase, Regelbeschreibungen) und die deutschen Suchbegriffe der
|
||||
Befehlspalette (sie bleiben zusätzlich zu den englischen Seitentiteln nutzbar).
|
||||
- **Prüfwerkzeuge:** `python3 scripts/check_i18n.py` (Katalog-Parität,
|
||||
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel) und
|
||||
`scripts/verify_i18n.mjs` (Browser: Umschalten über die Einstellungen, alle
|
||||
lesenden Seiten samt „Neu“-Dialogen auf deutsche Restwörter prüfen, 375 px auf
|
||||
horizontalen Überlauf; schreibende Requests werden abgefangen; `/updates` wird
|
||||
ausgelassen, weil das Öffnen dort Update-Suchen auf den Firewalls startet).
|
||||
Das Skript braucht `OPNEXUS_TEST_PASSWORD` und als `OPNEXUS_BASE_URL` den in
|
||||
`FRONTEND_ORIGIN` konfigurierten Origin (sonst blockiert die CORS-Freigabe die
|
||||
Anmeldung).
|
||||
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel).
|
||||
|
||||
**Verifiziert 2026-09-19:** DE→EN→DE mit DB-Persistenz und Reload; 17 Seiten und
|
||||
alle „Neu“-Dialoge im Englischen ohne deutsche Restwörter (Treffer nur aus
|
||||
@@ -909,7 +948,7 @@ bestätigt, bevor mit dem Picker begonnen wurde).
|
||||
|
||||
## Restore, Stufe 1: Vorschau + manuelle Übergabe (2026-09-09)
|
||||
|
||||
Setzt `docs/restore-plan.md` (8. September) in seiner ersten Ausbaustufe um:
|
||||
Erste Ausbaustufe des Restore-Assistenten (geplant am 8. September):
|
||||
eine lokal gespeicherte OPNsense-Konfigurationssicherung (`backup_store.py`,
|
||||
bereits vorhandenes "Backups"-Panel) prüfen, sicher als Vorschau anzeigen und
|
||||
den Wechsel auf das OPNsense-Webinterface dokumentiert übergeben — **OPNexus
|
||||
@@ -2096,7 +2135,7 @@ Details: `docs/verification/log-search-*`.
|
||||
Tabelle werden durch einen Datenbank-Trigger blockiert; Datenbankadministratoren
|
||||
können technisch weiterhin das Schema ändern. Keine rückwirkende Rekonstruktion
|
||||
früherer Aktionen. XML-Backups, Passwörter und API-Secrets werden nicht protokolliert.
|
||||
- **Restore**: separat ausgearbeiteter [Restore-Plan](restore-plan.md).
|
||||
- **Restore**: separat ausgearbeitet, siehe Abschnitt „Restore, Stufe 1“.
|
||||
Es gibt keine implementierte Restore-Ausführung. Der Plan unterscheidet lokale
|
||||
OPNexus-XML-Dateien von bereits auf der Firewall vorhandenen Revisionen.
|
||||
|
||||
@@ -2415,9 +2454,12 @@ in die Backend-Logs geschrieben:
|
||||
docker compose logs backend | grep -A5 "generated password"
|
||||
```
|
||||
|
||||
Danach in `.env` ein eigenes `ADMIN_PASSWORD` setzen (empfohlen) oder das
|
||||
Passwort über die künftige "Passwort ändern"-Funktion (`POST
|
||||
/api/auth/change-password`, aktuell nur per API, kein UI-Formular) ändern.
|
||||
Ist das Passwort verloren oder nie gesehen worden, setzt `opnexus password`
|
||||
(im Repo-Checkout `scripts/opnexus password`) ein **neues** Zufallspasswort und
|
||||
zeigt es an; siehe "Einrichtungsassistent und Passwort-Reset" unten. Danach
|
||||
das Passwort im Einrichtungsassistenten oder unter Einstellungen › Konto
|
||||
ändern, oder in `.env` ein eigenes `ADMIN_PASSWORD` setzen (gilt nur beim
|
||||
Anlegen des ersten Kontos).
|
||||
Login-Versuche sind pro (IP, Benutzername) und pro IP rate-limitiert
|
||||
(Standard: 5 Fehlversuche / 5 Minuten Sperre).
|
||||
|
||||
@@ -2520,37 +2562,6 @@ Release-Seite angehängt (GitHub und Forgejo haben dafür verschiedene APIs, die
|
||||
CI lädt es deshalb nicht hoch). Anwender brauchen damit nur Docker und diese
|
||||
vier Dateien.
|
||||
|
||||
**Ein Release veröffentlichen:** Versionsdateien und `CHANGELOG.md` anpassen,
|
||||
committen, dann `git tag v1.9.1 && git push origin v1.9.1`. Der Release-
|
||||
Workflow prüft per `scripts/check_version.sh`, dass Tag, `version.py`,
|
||||
`version.ts` und CHANGELOG zusammenpassen, lässt die Tests laufen und pusht
|
||||
erst dann die Tags `1.9.1`, `1.9` und `1` (Vorabversionen wie `v1.10.0-rc1`
|
||||
nur mit vollem Tag).
|
||||
|
||||
**Entwicklungs-Repo und öffentliches Repo (seit 2026-10-05):** Entwickelt wird
|
||||
im privaten Repo (volle Historie). Öffentlich ist `git.opnexus.dev/opnexus/opnexus`,
|
||||
und dort landet pro Release nur ein sauberer Snapshot-Commit — nie die
|
||||
Entwicklungs-Historie (frühere Lizenzstände, interne Übergaben,
|
||||
Live-Verifikation mit echten Adressen). Ablauf eines Releases:
|
||||
|
||||
```bash
|
||||
git tag v1.11.0 # im Entwicklungs-Repo, nach check_version.sh
|
||||
scripts/publish_public.sh v1.11.0 # Trockenlauf: Snapshot nach .public-repo/ prüfen
|
||||
scripts/publish_public.sh v1.11.0 --push # Snapshot + Tag ins öffentliche Repo
|
||||
scripts/build_release_images.sh v1.11.0 --push # Images bauen + in die Registry (ohne CI-Runner)
|
||||
scripts/make_update_feed.sh v1.11.0 # erst jetzt: Feed für updates.opnexus.dev
|
||||
```
|
||||
|
||||
`publish_public.sh` exportiert den Tag (nicht den Working Tree), lässt
|
||||
`.claude/`, `docs/HANDOFF-*`, `docs/verification*` und `.env` weg und bricht
|
||||
ab, wenn im Export interne Adressen/Hostnamen (Muster stehen im Skript) oder
|
||||
Schlüsselblöcke stehen — ein Sicherheitsnetz, kein Ersatz fürs Durchsehen.
|
||||
Folge: Verweise auf `docs/verification*` laufen im öffentlichen Repo ins Leere
|
||||
(siehe Hinweis oben im Handbuch). Hinter dem Cloudflare-Proxy (Free) sind
|
||||
Uploads auf 100 MB pro Request begrenzt; der Push des 1.10.0-rc1 ging
|
||||
problemlos durch, bei größeren Layern die Registry-Domain auf „DNS only“
|
||||
stellen oder aus dem LAN pushen.
|
||||
|
||||
**Registry ist reine Konfiguration**, nicht Teil der Workflow-Datei —
|
||||
Repository-Variablen `REGISTRY` (Default `ghcr.io`) und `IMAGE_NAMESPACE`
|
||||
(Default: Repo-Besitzer), Secrets `REGISTRY_USERNAME`/`REGISTRY_TOKEN`. Für
|
||||
@@ -2566,8 +2577,8 @@ GitHub/Forgejo `releases/latest`, den man in `.env` einträgt:
|
||||
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||
```
|
||||
|
||||
Das ist der offizielle Feed des Projekts (statische JSON-Datei aus
|
||||
`website/latest.json`, siehe `website/README.md`). Jeder andere Feed im
|
||||
Das ist der offizielle Feed des Projekts (eine statische JSON-Datei, ausgeliefert als
|
||||
`updates.opnexus.dev/latest.json`). Jeder andere Feed im
|
||||
selben Format funktioniert ebenso, etwa `…/api/v1/repos/<owner>/opnexus/releases/latest`
|
||||
auf einer eigenen Forgejo/GitHub-Instanz.
|
||||
|
||||
@@ -2578,7 +2589,78 @@ einzelnes GET mit dem User-Agent `OPNexus/<Version>` hinaus, ohne Instanz- oder
|
||||
Nutzerdaten. Ein unerreichbarer Feed bleibt unauffällig (der letzte bekannte
|
||||
Stand bleibt, Neuversuch frühestens nach 15 Minuten); Vorabversionen
|
||||
(`-rc1`) werden nie vorgeschlagen. Der Hinweis lässt sich pro Version
|
||||
wegklicken und installiert nichts — dafür gibt es `opnexus update`.
|
||||
wegklicken und installiert nichts — dafür gibt es den optionalen Updater
|
||||
(nächster Abschnitt) oder `opnexus update`.
|
||||
|
||||
#### Update aus dem Webinterface (optional)
|
||||
|
||||
Statt `opnexus update` auf dem Server kann ein Administrator OPNexus auch in
|
||||
den **Einstellungen › „OPNexus aktualisieren“** aktualisieren: Version und
|
||||
Release-Notes ansehen, „Auf X.Y.Z aktualisieren“ klicken, bestätigen,
|
||||
Fortschritt verfolgen. Die Seite überbrückt den kurzen Neustart der Dienste
|
||||
selbst und lädt sich danach neu. Ein fehlgeschlagenes Update rollt wie auf der
|
||||
Kommandozeile automatisch zurück und zeigt das Ergebnis samt Protokoll an;
|
||||
„Auf Vorversion zurücksetzen“ macht das letzte Update rückgängig (mit Hinweis,
|
||||
ob dabei Daten verloren gehen — nur wenn das Update die Datenbank migriert hat).
|
||||
|
||||
**Das ist bewusst abgeschaltet und muss aktiviert werden**, weil es Docker-Zugriff
|
||||
braucht. Dafür gibt es einen eigenen kleinen Dienst, den **Updater**
|
||||
(`updater/`, Compose-Profil `updater`):
|
||||
|
||||
```bash
|
||||
# .env auf dem Server
|
||||
COMPOSE_PROFILES=updater
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --no-build
|
||||
```
|
||||
|
||||
Sicherheitsmodell:
|
||||
|
||||
- Der Updater ist der **einzige** Container mit dem Docker-Socket. Das
|
||||
entspricht root-gleichen Rechten auf dem Host — deshalb ist er optional und
|
||||
winzig gehalten (`updater/server.py`, nur Standardbibliothek). Das Backend
|
||||
bekommt den Socket nie.
|
||||
- Er hat **keinen veröffentlichten Port** und ist nur aus dem Compose-Netz
|
||||
erreichbar, nur mit einem Token, den er beim ersten Start selbst erzeugt
|
||||
und in sein Volume `updater_state` schreibt (das Backend hängt es
|
||||
schreibgeschützt ein). Von außen kommt man nicht heran.
|
||||
- Er kann **nur** „aktualisieren auf X.Y.Z“, „zurückrollen“ und „Status“ — es
|
||||
gibt keinen Weg, einen eigenen Befehl auszuführen. Eine Zielversion muss
|
||||
eine stabile `X.Y.Z` sein, neuer als die laufende, mit derselben
|
||||
Hauptversion.
|
||||
- Das Backend leitet nur Admin-Anfragen weiter und nimmt als Ziel
|
||||
ausschließlich die Version an, die der Release-Feed (`UPDATE_CHECK_URL`)
|
||||
als neueste stabile meldet. Jede Anfrage steht im Audit-Log (Typ
|
||||
„OPNexus-System“). Nur-Lese-Konten sehen den Abschnitt nicht.
|
||||
- **Hauptversions-Sprünge** (z. B. 1.x → 2.0) lassen sich bewusst nicht per
|
||||
Klick auslösen: Release-Notes lesen, dann auf dem Server `opnexus update 2.0.0`.
|
||||
|
||||
Voraussetzungen und Grenzen:
|
||||
|
||||
- Nur für **Release-Images** (`OPNEXUS_IMAGE_REPO` gesetzt) und mit aktivem
|
||||
Release-Feed (`UPDATE_CHECK_URL`). Installationen, die lokal aus dem
|
||||
Quellcode bauen, aktualisieren weiter mit `git pull && docker compose up -d --build`;
|
||||
die Einstellungen sagen das an.
|
||||
- Der Updater arbeitet im Verzeichnis mit `docker-compose.yml` und `.env`
|
||||
(als `/project` eingehängt, schreibend — er setzt `OPNEXUS_VERSION` und
|
||||
legt `.opnexus-update-state` ab) und führt dieselbe Logik aus wie
|
||||
`opnexus update`: Images zuerst ziehen, wechseln, auf „gesund“ warten,
|
||||
sonst Rollback. Ein Update wechselt die Images, aber **nicht** die
|
||||
Compose-Datei; braucht eine Version neue Compose-Einträge, steht das in
|
||||
den Release-Notes und ist dann einmal von Hand zu tun.
|
||||
- Der Updater erneuert sich nach einem erfolgreichen Update selbst (aus einem
|
||||
kurzlebigen Hilfs-Container heraus); schlägt das fehl, läuft er weiter und
|
||||
wird beim nächsten `docker compose up -d` erneuert.
|
||||
- Läuft kein Updater, ist die Weboberfläche trotzdem voll nutzbar — der
|
||||
Abschnitt erklärt dann nur, wie man ihn aktiviert.
|
||||
|
||||
**Verifikation 1.15.0:** Update, Rollback mit Datenbank-Wiederherstellung und ein kaputtes
|
||||
Update mit automatischem Rollback liefen über die Oberfläche gegen einen isolierten Stack mit
|
||||
echten Release-Images und lokaler Registry (inklusive Selbsterneuerung des Updaters und
|
||||
Neuladen der Seite); Backend-API-Tests, Updater-Tests ohne Docker und die Browser-Prüfung
|
||||
(1440 px hell/dunkel, 375 px, alle Zustände, axe ohne Verstöße) sind grün.
|
||||
|
||||
#### Update und Rollback mit `opnexus`
|
||||
|
||||
@@ -3023,6 +3105,190 @@ headless), inklusive Auth-Flow und Deploy-Sperrschutz:
|
||||
- Keine Konsolen-Fehler außer erwarteten 401/502 nach Logout bzw. gegen
|
||||
nicht erreichbare Test-Hosts.
|
||||
|
||||
## Netzwerk, DNS und DHCP (2026-10-09)
|
||||
|
||||
Neuer Bereich **Netzwerk** (`/network`). Je Firewall: Inventar und Änderungen für
|
||||
Schnittstellen, VLANs, virtuelle IPs, Gateways, statische Routen, DNS-Einträge
|
||||
(Unbound) und DHCP (dnsmasq-Hosts, Kea-Subnetze und -Reservierungen). Diese Objekte
|
||||
sind firewall-spezifisch (eigene IPs, eigenes VLAN-Layout), deshalb gibt es hier
|
||||
keine zentrale Kopie und keinen Multi-Host-Push: Die Firewall ist die Quelle der
|
||||
Wahrheit, jede Änderung geht direkt dorthin, aber nur über Vorschau und Ausführung.
|
||||
|
||||
**Ablauf einer Änderung** (`services/network_change_service.py`):
|
||||
|
||||
1. *Vorschau* (`POST /api/hosts/{id}/network/changes/preview`): liest den Live-Stand,
|
||||
berechnet Diff, Risiken, Warnungen und Sperren, liefert einen Token über genau
|
||||
diesen Stand.
|
||||
2. *Ausführen* (`POST /api/hosts/{id}/network/changes`): liest den Stand erneut und
|
||||
verweigert bei abweichendem Token. Risikoreiche Änderungen brauchen
|
||||
`acknowledge` (die UI verlangt zusätzlich die Eingabe des Firewall-Namens). Dann:
|
||||
Konfig-Backup speichern (schlägt es fehl, wird nichts geändert), schreiben,
|
||||
anwenden (`reconfigure`), zurücklesen und mit dem Gewünschten vergleichen. Jeder
|
||||
Schritt landet im Audit-Log (`kind=network`: `started`, `applied`, `unverified`,
|
||||
`apply_uncertain`, `rejected`, `aborted`).
|
||||
3. Nur Felder aus einer festen Liste je Objektart werden akzeptiert (`FIELDS`),
|
||||
alles andere ergibt 422. Auswahllisten im Formular kommen von der Firewall
|
||||
selbst (`GET …/network/template/{kind}`): OPNsense bietet z. B. für eine Route
|
||||
nur die Gateways an, die es für gültig hält.
|
||||
|
||||
**Schutz vor dem Kappen der eigenen Verbindung:** OPNexus ermittelt aus der
|
||||
Schnittstellenliste der Firewall, über welche Schnittstelle sie unter der
|
||||
hinterlegten Adresse erreichbar ist (`management_device`). Bestätigungspflichtig
|
||||
sind: Änderung oder Löschen dieser Schnittstelle, ihres VLANs oder ihres Gateways,
|
||||
Standard-Gateway ändern/löschen, eine Standardroute, eine Route, die die
|
||||
Firewall-Adresse abdeckt, und jede Änderung bestehender Objekte, wenn sich der
|
||||
Verbindungsweg nicht bestimmen lässt (NAT, CARP-Adresse). **Es gibt keinen
|
||||
automatischen Rollback** (anders als bei Regeln): OPNexus kann eine Änderung nicht
|
||||
zurücknehmen, die seine eigene Verbindung trennt. Der Rückweg ist das Backup vor
|
||||
der Änderung. DNS- und DHCP-Daten können die Verbindung nicht kappen und lösen
|
||||
diesen Schutz nicht aus.
|
||||
|
||||
**Schnittstellen-Zuweisung** (`/interfaces/assignment/*`): zweistufig. `set`/`add`/`del`
|
||||
merken die Änderung nur vor (Datei auf der Firewall), erst `reconfigure` wendet sie
|
||||
an. Deshalb blockiert OPNexus eine neue Änderung, solange dort schon etwas
|
||||
vorgemerkt ist (`interface_changes_pending`), sonst würde das Anwenden fremde
|
||||
Änderungen mitnehmen. Beim Löschen entfernt OPNsense selbst alle Regeln und NAT-
|
||||
Einträge, die die Schnittstelle nutzen; die Vorschau listet sie. Gesperrte
|
||||
Schnittstellen (`lock`) lassen sich nicht löschen, das sagt die Vorschau vorab.
|
||||
Beschreibungen dürfen nur Buchstaben, Ziffern und Unterstriche enthalten.
|
||||
**Die API kann die IP-Konfiguration erst ab OPNsense 26.7.6**; auf 26.7.1 liefert
|
||||
die Zuweisungsliste nur Name und Gerät. OPNexus erkennt das an den Daten
|
||||
(`can_configure_interfaces`) und sperrt die Änderung mit Begründung.
|
||||
|
||||
**Geheimnisse:** `vip_settings` liefert das CARP-Passwort im Klartext, Kea-Subnetze
|
||||
den DDNS-Schlüssel. Beides wird nie zurückgegeben, gespeichert oder auditiert: Das
|
||||
Inventar wählt Felder namentlich aus, der „Vorher“-Zustand wird auf die erlaubten
|
||||
Felder begrenzt, ein neues CARP-Passwort geht nur beim Ausführen an die Firewall.
|
||||
|
||||
**Von der Firewall übernehmen** (`/rules` → „Von Firewall übernehmen“,
|
||||
`services/rule_adopt_service.py`): Regeln, die von Hand in OPNsense angelegt wurden,
|
||||
werden als verwaltete Regel mit der vorhandenen UUID erfasst. Die Übernahme liest
|
||||
die Regel frisch von der Firewall und schreibt nichts dorthin; erst die erste
|
||||
Änderung läuft durch den normalen Ablauf (Vorschau, Push, Bestätigung, Auto-Revert).
|
||||
Nicht übernommen werden Regeln, die das Modell verfälschen würde: invertierte
|
||||
Schnittstelle/Quelle/Ziel, mehrere oder keine Schnittstelle, „Empfangen auf“, keine
|
||||
Quick-Regel, Richtung weder ein noch aus, IPv4+IPv6 in einer Regel, fehlende
|
||||
Beschreibung, Systemregeln. Die Gründe zeigt die Oberfläche. Regeln haben jetzt ein
|
||||
Protokollieren-Flag (`log`, Migration 0003), weil jeder Push bisher fest `log=0`
|
||||
schrieb. Felder, die OPNexus nicht kennt (Gateway, Zustandsoptionen, Zeitpläne, …),
|
||||
bleiben unverändert, denn `setRule` überschreibt nur mitgesendete Felder (live
|
||||
bestätigt).
|
||||
|
||||
**Verifikation 2026-10-09:** Endpunkte gegen die Core-Quellen geprüft und live auf
|
||||
drei Test-Firewalls (test-fw-1/-2 mit 26.7.6 als HA-Paar, test-fw-3 mit 26.7.1_1):
|
||||
je anlegen, ändern, löschen mit Backup, Apply und Rücklesen für VLAN, virtuelle IP,
|
||||
Gateway, Schnittstellen-Zuweisung inkl. IP, Unbound-Eintrag, dnsmasq-Host,
|
||||
Kea-Subnetz mit verschachtelten Optionen und Kea-Reservierung; Übernahme einer
|
||||
Handregel mit Gateway und `log=1`, danach Bearbeiten und Ausrollen ohne Verlust.
|
||||
Der Guard verweigerte das Löschen des Standard-Gateways und die Änderung der
|
||||
WAN-Schnittstelle ohne Bestätigung. Browser (1440 px und 375 px, keine
|
||||
Konsolenfehler, kein Overflow): Anlegen und Löschen von VLAN, Schnittstelle und
|
||||
DNS-Eintrag über die Oberfläche. Der Live-Test fand drei Fehler, die Mocks nicht
|
||||
zeigten (Gateway-`priority` als Zahl, Routen-Feld `enabled` statt `disabled`,
|
||||
Beschreibungsmuster der Schnittstelle), der Browser-Test einen weiteren (Dialog
|
||||
verschwand nach dem Neuladen). 534 Backend-Tests grün.
|
||||
|
||||
**Weitere Verifikation (zweiter Durchlauf):** Der Bestätigungs-Pfad wurde live
|
||||
ausgeführt: Beschreibung der WAN-Schnittstelle mit Bestätigung geändert, und die
|
||||
WAN-IP von test-fw-2 auf eine freie Adresse im selben Netz gesetzt, womit OPNexus
|
||||
den Weg zur Firewall verliert (Ergebnis „angewendet, nicht prüfbar“, Audit
|
||||
`unverified`, Backup vorhanden). Wiederherstellung: neue Adresse unter Hosts ›
|
||||
Bearbeiten eintragen, WAN-IP über die Engine zurücksetzen, Adresse zurücksetzen.
|
||||
Außerdem: Routen-Lebenszyklus (aktivieren/deaktivieren), CARP-VIP auf dem HA-Paar
|
||||
(Backup zuerst, dann Master; Advskew ändern), Übernahme von Regeln mit Alias-Quelle,
|
||||
IPv6 und Block/Log, Ablehnung einer Regel mit invertierter Quelle, Auto-Revert einer
|
||||
unbestätigten Änderung an einer übernommenen Regel (Status `reverted`, ursprüngliche
|
||||
Beschreibung und `log=1` zurück), Übernahme auf beiden Knoten des HA-Paars,
|
||||
Kea-Reservierung samt Subnetz, Unbound-Eintrag mit echter DNS-Abfrage (NXDOMAIN →
|
||||
Antwort → NXDOMAIN). Browser: Anlegen, Bearbeiten und Löschen für VIP, Gateway, Route,
|
||||
dnsmasq-Host, Kea-Subnetz und -Reservierung, Übernahme per Dialog, Nur-Lesen-Rolle
|
||||
(alle Aktions-Buttons gesperrt), Englisch und Dark Mode ohne deutsche Reste,
|
||||
axe-core-Prüfung der sieben Netzwerkseiten. Die Migration 0003 lief auf einer Kopie
|
||||
der Prod-Datenbank (ohne Log-Tabelle): 0002 → 0003, bestehende Regeln `log=false`.
|
||||
Lesend gegen die Prod-Firewalls (26.7.5): Inventar und Erkennung der
|
||||
Verwaltungs-Schnittstelle (`vlan0110`) stimmen. **Nicht getestet:** die tatsächliche
|
||||
Vergabe von DHCP-Leases (Kea ist auf den Test-Firewalls deaktiviert).
|
||||
|
||||
**Bekannte Grenzen:** Übernommene Regeln werden je Firewall einzeln übernommen. Wer
|
||||
eine übernommene Regel zusätzlich auf einen weiteren Host als Ziel setzt, legt dort
|
||||
eine zweite, doppelte Regel an (die Vorschau zeigt „create“). Keine Multi-Host-Vorlagen
|
||||
für Netzwerkobjekte, kein automatisches Rolling-Apply auf CARP-Paaren (nur das
|
||||
Übertragen auf den Partner per Klick), keine Kea-DDNS-Felder.
|
||||
|
||||
### Erweiterung 1.13.0: Geräte, Host-System, Abgleich im Cluster
|
||||
|
||||
Alles über dieselbe Engine (Vorschau, Backup, Anwenden, Zurücklesen, Audit, Guard):
|
||||
|
||||
- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Ein LAGG oder eine Bridge, die
|
||||
die Verwaltungs-Schnittstelle als Mitglied enthält, braucht die Bestätigung
|
||||
(`management_member`). Beim Anlegen einer Bridge sind die Mitglieder Schnittstellen-
|
||||
Kennungen (opt1, opt2), bei einem LAGG Gerätenamen.
|
||||
- **Gateways › Gateway-Gruppen**, **DNS › Weiterleitungen** (Unbound-Forwards) und
|
||||
**DHCP › Bereiche** (dnsmasq).
|
||||
- **Host-System** (neue Gruppe): **Tunables** (jede Änderung braucht die Bestätigung; die
|
||||
eingebauten Vorgaben lassen sich nicht bearbeiten, nur per „Überschreiben“ mit einem
|
||||
eigenen Eintrag übersteuern), **Cron**, **Syslog** (entfernte Log-Ziele) und
|
||||
**Snapshots** (ZFS: anlegen und löschen; das aktive Boot-Environment lässt sich nicht
|
||||
löschen, das Aktivieren mit Neustart gibt es bewusst nicht). Snapshots haben eine
|
||||
eigene, flache API und laufen deshalb über `services/snapshot_service.py`, nicht über
|
||||
die Engine.
|
||||
- **Netzwerk › Abgleich:** vergleicht die Knoten jedes Clusters (Schnittstellen,
|
||||
VLANs, VIPs, Gateways, Routen, DNS, DHCP). Adressen, CARP-Advskew/-Passwort und das
|
||||
aktive Standard-Gateway sind pro Knoten verschieden und werden nicht verglichen; eine
|
||||
Kategorie, die ein Knoten nicht liefern kann, wird übersprungen statt als Abweichung
|
||||
gemeldet. Dazu ein neuer Alarm „Netzwerk-Abweichung im Cluster“
|
||||
(`network_drift`, Einstellungen › Benachrichtigungen, Migration 0004), der sich nach
|
||||
der Korrektur selbst auflöst und bei einem nicht erreichbaren Knoten offene Meldungen
|
||||
stehen lässt.
|
||||
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts auf einem Cluster-Knoten
|
||||
bietet der Ergebnis-Schritt „Auf <Partner> übertragen“ an (Dialog mit denselben
|
||||
Werten, mit eigener Vorschau, eigenem Backup und eigenem Guard). Empfohlene
|
||||
Reihenfolge: zuerst der Backup-Knoten, dann der Master.
|
||||
- **Kopfzeile:** Bei acht Gruppen zeigt die Hauptnavigation die Namen ab 1150 px Breite,
|
||||
darunter nur Icons mit Tooltip, damit Suche und Abmelden in einer Zeile bleiben.
|
||||
|
||||
**Verifikation 1.13.0 (live auf test-fw-1/-2/-3):** je anlegen, ändern, in der Liste
|
||||
sehen und löschen mit Backup, Apply und Rücklesen für Tunable, Cron-Job, Syslog-Ziel,
|
||||
Gateway-Gruppe, Loopback, GRE, DNS-Forward und dnsmasq-Bereich auf test-fw-3, Bridge
|
||||
auf test-fw-1 (mit zwei dafür zugewiesenen, unbenutzten VLANs) und LAGG auf test-fw-3;
|
||||
Snapshots anlegen/löschen auf test-fw-1 und -3 (der aktive wird abgelehnt). Der Abgleich
|
||||
und sein Alarm wurden mit einem nur auf einem Knoten vorhandenen VLAN geprüft
|
||||
(Meldung geöffnet, nach dem Löschen aufgelöst). Browser: Übertragen auf den Partner,
|
||||
Abgleich vor und nach dem Angleichen, Überschreiben und Löschen eines Tunables,
|
||||
Snapshot anlegen/löschen, alle neuen Seiten in Deutsch und Englisch (Dark Mode),
|
||||
axe-core über die ganze App ohne Verstöße, Kopfzeile in einer Zeile ab 768 px.
|
||||
|
||||
### Erweiterung 1.14.0: Diagnose, VXLAN, GIF
|
||||
|
||||
- **Netzwerk › Geräte** kennt jetzt auch **VXLAN** und **GIF** (gleiche Engine wie LAGG, Bridge
|
||||
und GRE: Vorschau, Backup, Anwenden, Zurücklesen, Audit).
|
||||
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung (TCP) und DNS-Abfrage, die die
|
||||
ausgewählte Firewall selbst ausführt (`/api/hosts/{id}/diagnostics/{ping|traceroute|portprobe|dns}`,
|
||||
`services/diagnostics_service.py`). Sicherheitsmerkmale:
|
||||
- nur für Admins (alles sind POSTs, die Nur-Lesen-Rolle bekommt 403),
|
||||
- das Ziel wird vor dem Senden streng geprüft (nur Hostname-/IP-Zeichen, Quelladresse nur als IP,
|
||||
Anzahl 1–10, Port 1–65535); die Firewall prüft zusätzlich selbst und lehnt Ungültiges mit 422 ab,
|
||||
- jeder Lauf steht mit Tool und Ziel im Audit-Log (`kind=diagnostic`), Ergebnisse werden nicht gespeichert,
|
||||
- höchstens vier Abfragen gleichzeitig pro OPNexus-Prozess,
|
||||
- die Port-Prüfung gibt nie den Banner/Payload der Gegenstelle weiter, nur „offen/nicht erreichbar“,
|
||||
- ein Ping läuft als kurzer Job und wird **immer** wieder gestoppt und entfernt, auch wenn das Abfragen
|
||||
der Zwischenstände scheitert (live geprüft: danach keine Jobs auf den Firewalls).
|
||||
Ein Port-Test auf einen geschlossenen oder blockierten Port dauert bei OPNsense 10 Sekunden; die
|
||||
Diagnose-Aufrufe haben deshalb ein Zeitlimit von 40 s.
|
||||
- **Bewusst nicht gebaut: Packet-Capture.** Mitschnitte enthalten Nutzdaten fremder Verbindungen und
|
||||
Zugangsdaten im Klartext; das gehört nicht in eine Verwaltungsoberfläche ohne eigenes Aufbewahrungs-
|
||||
und Berechtigungskonzept.
|
||||
- **Fehlerkorrektur:** Die neue Route `/hostsystem` beginnt mit `/hosts`; die Navigation markierte
|
||||
dadurch „Übersicht › Hosts“ und der Seitentitel zeigte „Hosts“. Der Pfadvergleich prüft jetzt auf
|
||||
die ganze Seite oder Unterseiten.
|
||||
|
||||
**Verifikation 1.14.0 (live):** VXLAN und GIF auf test-fw-3 (anlegen, ändern, auflisten, löschen, Geräteliste
|
||||
danach unverändert). Alle vier Diagnose-Werkzeuge auf test-fw-1 und -3 gegen das Gateway im Management-Netz
|
||||
(Ping 4/4 und 3/3 verloren zu einer nicht erreichbaren Adresse, Traceroute, Port 443 offen und Port 1 nach
|
||||
10 s „nicht erreichbar“, DNS mit mehreren Record-Typen, ungültige Eingabe → 422). Browser (1440 und 375 px):
|
||||
alle vier Werkzeuge, VXLAN und GIF über die Geräteseite, Englisch/Dark Mode ohne deutsche Reste,
|
||||
axe-core über die ganze App ohne Verstöße. 560 Backend-Tests grün.
|
||||
|
||||
## Definition of Done — Stand
|
||||
|
||||
- [x] `docker compose up -d --build` startet DB, Backend, Frontend
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -339,13 +339,7 @@ function AliasesContent() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasPending && (
|
||||
<Button variant="secondary" onClick={() => handleConfirm(alias.id)} disabled={confirmingId === alias.id}>
|
||||
<ShieldCheck size={14} />
|
||||
{confirmingId === alias.id ? t("rules.confirming") : t("rules.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEditor(alias)}
|
||||
@@ -375,6 +369,12 @@ function AliasesContent() {
|
||||
<DeploymentRow key={d.host_id} deployment={d} secondsLeft={secondsLeft} />
|
||||
))}
|
||||
</ul>
|
||||
{hasPending && (
|
||||
<Button className="ml-auto" variant="secondary" onClick={() => handleConfirm(alias.id)} disabled={confirmingId === alias.id}>
|
||||
<ShieldCheck size={14} />
|
||||
{confirmingId === alias.id ? t("rules.confirming") : t("rules.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api, AuditEvent, AuditPage } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Select } from "@/components/ui/Field";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification"];
|
||||
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification","system"];
|
||||
const actionKeys = ["deploy","confirm","rollback","remove","retry-rollback","delete","create","edit","reboot","update"];
|
||||
const statusKeys = ["requested","accepted","rejected","interrupted","success","pending","error","reverted","revert_failed","removed","submitted","acknowledged","starting","unknown","denied"];
|
||||
export default function AuditHistory() {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { labelFor, type Language } from "@/lib/i18n";
|
||||
@@ -110,6 +111,7 @@ function certProblemLabel(context: Record<string, unknown>, t: (key: string, par
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { language, t, errorMessage } = useLanguage();
|
||||
const router = useRouter();
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
|
||||
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
|
||||
@@ -133,6 +135,15 @@ export default function DashboardPage() {
|
||||
.then(async (data) => {
|
||||
if (!active) return;
|
||||
setSummary(data);
|
||||
// Fresh install: nothing to manage yet and the setup assistant was
|
||||
// never finished or skipped, so open it instead of an empty dashboard.
|
||||
if (data.hosts.length === 0) {
|
||||
Promise.all([api.me(), api.settings()])
|
||||
.then(([me, prefs]) => {
|
||||
if (active && me.role !== "read_only" && !prefs.setup_done) router.replace("/setup");
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
const reachableHosts = data.hosts.filter((h) => h.status !== "offline" && h.status !== "disabled");
|
||||
const results = await Promise.all(
|
||||
reachableHosts.map(async (host) => {
|
||||
@@ -164,7 +175,7 @@ export default function DashboardPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [errorMessage]);
|
||||
}, [errorMessage, router]);
|
||||
|
||||
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
|
||||
const securityWarningsLoading = problems === null || firmwareWarnings === null;
|
||||
@@ -212,7 +223,7 @@ export default function DashboardPage() {
|
||||
{summary === null ? (
|
||||
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
|
||||
) : summary.hosts.length === 0 ? (
|
||||
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link>} />
|
||||
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<span className="flex flex-wrap justify-center gap-x-4 gap-y-1"><Link href="/setup" className="text-sm font-medium text-sky-700 hover:underline">{t("dashboard.startSetup")}</Link><Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link></span>} />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{summary.hosts.map((host) => (
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
/* Visible keyboard focus everywhere. Mouse clicks do not trigger :focus-visible, so this
|
||||
only shows for keyboard users. Inputs keep their own ring via the focus: utilities. */
|
||||
@layer base {
|
||||
/* A scroll container must also be the containing block of absolutely
|
||||
positioned content inside it. Otherwise the table header's `sr-only` span
|
||||
(position:absolute, static position in the right-most column) escapes the
|
||||
clip, widens the whole document and makes phones zoom the page out
|
||||
(found live on /hosts and /users at 375px). */
|
||||
.overflow-x-auto { position: relative; }
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(var(--focus-ring));
|
||||
outline-offset: 2px;
|
||||
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user