Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba6357193 | ||
|
|
23d3a00ae1 | ||
|
|
4379a17b15 | ||
|
|
983e05a816 | ||
|
|
dd6a64d953 | ||
|
|
6964ec6537 | ||
|
|
5e5d51b57f | ||
|
|
1aba618ad5 | ||
|
|
ba954c05bf | ||
|
|
63d014b14c | ||
|
|
4838dad588 | ||
|
|
c4c8c4ff3a | ||
|
|
295555cfd6 | ||
|
|
f5dbe8a41a | ||
|
|
552c88e425 | ||
|
|
b2c69f51a0 |
No files matched your search
+8
-1
@@ -73,6 +73,8 @@ TRUST_PROXY_HEADERS=false
|
|||||||
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
||||||
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
|
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
|
||||||
# (optional, siehe unten) per Klick im Webinterface.
|
# (optional, siehe unten) per Klick im Webinterface.
|
||||||
|
# Einfacher: im Webinterface unter Einstellungen › OPNexus aktualisieren einschalten.
|
||||||
|
# Ein Wert hier hat Vorrang und sperrt den Schalter dort.
|
||||||
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||||
# UPDATE_CHECK_INTERVAL_HOURS=12
|
# UPDATE_CHECK_INTERVAL_HOURS=12
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ TRUST_PROXY_HEADERS=false
|
|||||||
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
||||||
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
|
# 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
|
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
|
||||||
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch, "Updates per Klick".
|
# AUS. Braucht außerdem die Versionsprüfung (Webinterface oder UPDATE_CHECK_URL, oben). Siehe Handbuch, "Updates per Klick".
|
||||||
# COMPOSE_PROFILES=updater
|
# COMPOSE_PROFILES=updater
|
||||||
|
|
||||||
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
||||||
@@ -93,3 +95,8 @@ TRUST_PROXY_HEADERS=false
|
|||||||
# SMTP_CA_FILE=/data/smtp-ca.pem
|
# SMTP_CA_FILE=/data/smtp-ca.pem
|
||||||
# SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt.
|
# SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt.
|
||||||
# ALLOW_INSECURE_SMTP_AUTH=false
|
# ALLOW_INSECURE_SMTP_AUTH=false
|
||||||
|
|
||||||
|
# Management-Tunnel (WireGuard-Hub für Firewalls hinter NAT), optional.
|
||||||
|
# Zum Aktivieren (Handbuch „Firewalls hinter NAT“): COMPOSE_PROFILES=overlay
|
||||||
|
# und den UDP-Port aus dem Internet erreichbar machen.
|
||||||
|
#OVERLAY_PORT=51820
|
||||||
+131
-1
@@ -29,7 +29,137 @@ in der App auf der Login-Seite, im Footer jeder Seite, auf `/about` und in
|
|||||||
den Einstellungen.
|
den Einstellungen.
|
||||||
|
|
||||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||||
ansteht): Lanner, Merlin …
|
ansteht): Merlin …
|
||||||
|
|
||||||
|
## [1.23.11] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Updates per Klick prüfen jetzt ebenfalls die Signatur:** Der Updater-Container enthält `cosign` (feste Version, Prüfsumme beim Bau
|
||||||
|
geprüft) und verifiziert die neuen Images vor dem Wechsel, wenn `cosign.pub` im Projektverzeichnis liegt. Das Image wird dadurch größer
|
||||||
|
(rund 266 statt 123 MB).
|
||||||
|
|
||||||
|
## [1.23.10] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
|
||||||
|
Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
|
||||||
|
installiert ist. Anleitung: Handbuch, „Images prüfen“.
|
||||||
|
|
||||||
|
## [1.23.9] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
|
||||||
|
statt „baut aus dem Quellcode“.
|
||||||
|
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
|
||||||
|
|
||||||
|
## [1.23.8] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
|
||||||
|
die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
|
||||||
|
`docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
|
||||||
|
|
||||||
|
## [1.23.7] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
|
||||||
|
|
||||||
|
## [1.23.6] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
|
||||||
|
und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
|
||||||
|
`UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
|
||||||
|
|
||||||
|
## [1.23.5] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
|
||||||
|
und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
|
||||||
|
bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
|
||||||
|
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
|
||||||
|
darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
|
||||||
|
|
||||||
|
## [1.23.4] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
|
||||||
|
im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
|
||||||
|
anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
|
||||||
|
Prüfung weicht ab“.
|
||||||
|
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
|
||||||
|
einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
|
||||||
|
|
||||||
|
## [1.23.3] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
|
||||||
|
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
|
||||||
|
Garage, MinIO, RustFS und Versity S3 Gateway.
|
||||||
|
|
||||||
|
## [1.23.2] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
|
||||||
|
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
|
||||||
|
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
|
||||||
|
|
||||||
|
## [1.23.1] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
|
||||||
|
- Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
|
||||||
|
- Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
|
||||||
|
- SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
|
||||||
|
- Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
|
||||||
|
- Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
|
||||||
|
- Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
|
||||||
|
- Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
|
||||||
|
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
|
||||||
|
|
||||||
|
## [1.23.0] "Harrier" — 2026-10-20
|
||||||
|
|
||||||
|
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
|
||||||
|
Befunden (je Firewall und Prüfung, mit „behoben“-Meldung; standardmäßig aus; Migration `0017`); druckbarer **Sicherheitsbericht**
|
||||||
|
(Knopf „Bericht“, Drucken/PDF über den Browser).
|
||||||
|
|
||||||
|
## [1.22.0] "Peregrine" — 2026-10-20
|
||||||
|
|
||||||
|
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
|
||||||
|
(Host-Seite › Standort-Variablen, listet die verwendeten Platzhalter automatisch). Je Host aufgelöst in Vorschau, Ausrollen, Zählern und
|
||||||
|
Audit; fehlt ein Wert, wird nichts gesendet (Vorschau „Variable fehlt“, Ausrollen wird vor jedem Schreiben abgelehnt, zusätzlich eine
|
||||||
|
Sperre im API-Client). Neue Tabelle `host_variables` (Migration `0016`).
|
||||||
|
|
||||||
|
## [1.21.0] "Osprey" — 2026-10-20
|
||||||
|
|
||||||
|
- **Management-Tunnel** (System › Management-Tunnel): Firewalls hinter NAT/CGNAT verwalten. Ein optionaler WireGuard-Hub auf dem
|
||||||
|
OPNexus-Server (Compose-Profil `overlay`, Container `wg-hub` aus dem Backend-Image mit `NET_ADMIN`) nimmt die Firewalls entgegen, die sich
|
||||||
|
nach außen einwählen; OPNexus erreicht deren API über einen kleinen CONNECT-Proxy des Hubs, der nur ins Tunnel-Netz verbindet. Je Firewall
|
||||||
|
eine Tunnel-Adresse und ein eigener Pre-Shared Key, Anleitung mit kopierbaren Werten, Live-Status (Handshake, Datenmenge). Der Hub nimmt
|
||||||
|
keine neuen Verbindungen aus dem Tunnel an. Ende-zu-Ende gegen eine echte OPNsense 26.7 geprüft (Handshake, API, Zertifikat festsetzen).
|
||||||
|
Neue Tabellen `overlay_settings`, `overlay_peers` (Migration `0015`); das Backend-Image enthält jetzt `wireguard-tools`, `iproute2`, `iptables`.
|
||||||
|
|
||||||
|
## [1.20.0] "Lilli" — 2026-10-20
|
||||||
|
|
||||||
|
- **Single Sign-on (OIDC)**: Anmeldung über Authentik, Keycloak, Entra ID, Google u. a. (Authorization Code mit PKCE). Die Rolle
|
||||||
|
kommt bei jeder Anmeldung aus den Gruppen des Anbieters, ohne zugeordnete Gruppe kommt niemand hinein; SSO-Konten werden nie mit
|
||||||
|
lokalen Konten verknüpft; lokale Anmeldung bleibt als Notzugang. ID-Token werden streng geprüft (Signatur nur asymmetrisch, Aussteller,
|
||||||
|
Zielgruppe, Ablauf, Nonce, einmaliger State), gegen einen nachgebauten Anbieter getestet (inkl. Fälschungsversuche). Neue Tabellen
|
||||||
|
`oidc_settings`, `oidc_login_states`, Spalten `admin_users.auth_source/oidc_subject` (Migration `0013`). Neue Abhängigkeit: PyJWT.
|
||||||
|
- **Behoben:** `opnexus backup --full` und `opnexus restore` (Vollsicherung, in 1.19.0 beschrieben) fehlten im 1.19.0-Bundle und -Updater-Image,
|
||||||
|
weil die Änderung am Skript nicht mit eingecheckt war; sie sind erst ab dieser Version enthalten.
|
||||||
|
- **Aufgabentrennung**: Schalter, mit dem nur Admins ausrollen dürfen, Operatoren aber vorbereiten (Migration `0014`).
|
||||||
|
|
||||||
|
## [1.19.0] "Lanner" — 2026-10-20
|
||||||
|
|
||||||
|
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende
|
||||||
|
(sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“.
|
||||||
|
|
||||||
|
- **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine
|
||||||
|
Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit.
|
||||||
|
Tokens eines Operators gehen bis zum Bereich „Ausrollen“.
|
||||||
|
- **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens
|
||||||
|
einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`).
|
||||||
|
- **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM,
|
||||||
|
Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen
|
||||||
|
zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte
|
||||||
|
`notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen
|
||||||
|
AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus.
|
||||||
|
- **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer
|
||||||
|
verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen).
|
||||||
|
- **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und
|
||||||
|
Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle
|
||||||
|
`rule_hits` (Migration `0012`).
|
||||||
|
- Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
|
||||||
|
|
||||||
## [1.18.0] "Kestrel" — 2026-10-19
|
## [1.18.0] "Kestrel" — 2026-10-19
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **Stand:** `1.18.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
> **Stand:** `1.23.11` 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
|
## Funktionen
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
|||||||
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
|
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
|
||||||
- **Automatisierung** — persönliche API-Tokens (Lesen / Ausrollen / Admin), Prometheus-Metriken, ein nur lesender MCP-Endpunkt für KI-Assistenten und ein OpenAPI-Schema.
|
- **Automatisierung** — persönliche API-Tokens (Lesen / Ausrollen / Admin), Prometheus-Metriken, ein nur lesender MCP-Endpunkt für KI-Assistenten und ein OpenAPI-Schema.
|
||||||
- **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
|
- **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
|
||||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
- **Backups & Wiederherstellung** — geplante Konfigurationssicherungen mit Aufbewahrung, verschlüsselte Offsite-Kopie auf S3-kompatiblen Speicher, Änderungsansicht zwischen Sicherungen und eine vollständige verschlüsselte Sicherung von OPNexus selbst (`opnexus backup --full`).
|
||||||
|
- **Single Sign-on** — Anmeldung über deinen Identitätsanbieter (OIDC); die Rolle kommt bei jeder Anmeldung aus dessen Gruppen.
|
||||||
|
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Operator / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||||
|
|
||||||
Alle Screenshots zeigen fiktive Beispieldaten.
|
Alle Screenshots zeigen fiktive Beispieldaten.
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.18.0
|
OPNEXUS_VERSION=1.23.11
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -71,7 +73,7 @@ Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen,
|
|||||||
|
|
||||||
## Datenschutz
|
## Datenschutz
|
||||||
|
|
||||||
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) ist **aus**, solange du ihn nicht setzt; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (Schalter unter Einstellungen › OPNexus aktualisieren, oder `UPDATE_CHECK_URL` in der `.env`) ist **aus**, solange du ihn nicht einschaltest; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **Status:** `1.18.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
> **Status:** `1.23.11` 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
|
## What it does
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
|||||||
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
|
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
|
||||||
- **Automation** — personal API tokens (read / deploy / admin), Prometheus metrics, a read-only MCP endpoint for AI assistants and an OpenAPI schema.
|
- **Automation** — personal API tokens (read / deploy / admin), Prometheus metrics, a read-only MCP endpoint for AI assistants and an OpenAPI schema.
|
||||||
- **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
|
- **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
|
||||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
|
- **Backups & recovery** — scheduled config backups with retention, an encrypted offsite copy to S3-compatible storage, a redacted change view between backups, and a full encrypted backup/restore of OPNexus itself (`opnexus backup --full`).
|
||||||
|
- **Single sign-on** — sign in with your identity provider (OIDC); roles come from its groups at every login.
|
||||||
|
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / operator / read-only), keyboard operation, dark mode, German and English UI.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.18.0
|
OPNEXUS_VERSION=1.23.11
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -75,7 +77,7 @@ docker compose pull && docker compose up -d --no-build
|
|||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (switch under Settings › Update OPNexus, or `UPDATE_CHECK_URL` in the `.env`) is **off** unless you turn it on; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ WORKDIR /app
|
|||||||
COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh
|
COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh
|
||||||
RUN /tmp/install_pg_client.sh 16 && rm /tmp/install_pg_client.sh
|
RUN /tmp/install_pg_client.sh 16 && rm /tmp/install_pg_client.sh
|
||||||
|
|
||||||
|
# Management-Overlay (nur der wg-hub-Container nutzt das, siehe app/overlay_hub.py).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends wireguard-tools iproute2 iptables \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from starlette.responses import JSONResponse
|
|||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.errors import ErrorCode
|
from app.errors import ErrorCode
|
||||||
from app.routers.auth import COOKIE_NAME
|
from app.routers.auth import COOKIE_NAME
|
||||||
|
from app.routers import governance
|
||||||
from app.routers.auth import client_ip
|
from app.routers.auth import client_ip
|
||||||
from app.services import api_token_service, auth_service
|
from app.services import api_token_service, auth_service
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ _PUBLIC_PATHS = {
|
|||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/api/auth/me",
|
"/api/auth/me",
|
||||||
"/api/health",
|
"/api/health",
|
||||||
|
# SSO: the browser arrives here without a session; every step is checked inside.
|
||||||
|
"/api/auth/oidc/config",
|
||||||
|
"/api/auth/oidc/start",
|
||||||
|
"/api/auth/oidc/callback",
|
||||||
}
|
}
|
||||||
|
|
||||||
# /docs, /redoc and /openapi.json used to be public — fully documenting a
|
# /docs, /redoc and /openapi.json used to be public — fully documenting a
|
||||||
@@ -112,13 +117,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
parts = request.url.path.strip("/").split("/")
|
parts = request.url.path.strip("/").split("/")
|
||||||
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system"}
|
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system", "backups": "backup"}
|
||||||
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
||||||
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
|
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
|
||||||
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
|
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
|
||||||
kind = event["kind"]
|
kind = event["kind"]
|
||||||
else:
|
else:
|
||||||
kind = kinds.get(parts[1]) if len(parts) > 1 else None
|
kind = kinds.get(parts[1]) if len(parts) > 1 else None
|
||||||
|
if len(parts) > 2 and parts[1] == "settings":
|
||||||
|
kind = "settings" # where OPNexus stores/sends things (own prefs at /api/settings stay unaudited)
|
||||||
action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create")
|
action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create")
|
||||||
event = {"kind": kind, "action": action, "entity_id": parts[2] if len(parts) > 2 else None,
|
event = {"kind": kind, "action": action, "entity_id": parts[2] if len(parts) > 2 else None,
|
||||||
"host_id": parts[2] if kind == "host" and len(parts) > 2 else None,
|
"host_id": parts[2] if kind == "host" and len(parts) > 2 else None,
|
||||||
@@ -143,6 +150,24 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
status_code=403,
|
status_code=403,
|
||||||
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
|
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
|
||||||
)
|
)
|
||||||
|
if user.role == "operator" and api_token_service.operator_denies(request.method, request.url.path):
|
||||||
|
if tracked:
|
||||||
|
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
|
||||||
|
try:
|
||||||
|
await audit_service.persist(**event, status="denied", message="Nur für Administratoren")
|
||||||
|
finally:
|
||||||
|
audit_service.context.reset(ctx)
|
||||||
|
return JSONResponse(status_code=403, content={"detail": {"code": ErrorCode.ADMIN_REQUIRED.value, "params": {}}})
|
||||||
|
if user.role == "operator" and api_token_service.is_rollout(request.method, request.url.path):
|
||||||
|
async with SessionLocal() as gdb:
|
||||||
|
allowed = (await governance.get_settings(gdb)).operators_may_deploy
|
||||||
|
if not allowed:
|
||||||
|
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
|
||||||
|
try:
|
||||||
|
await audit_service.persist(**event, status="denied", message="Ausrollen nur durch Administratoren (Aufgabentrennung)")
|
||||||
|
finally:
|
||||||
|
audit_service.context.reset(ctx)
|
||||||
|
return JSONResponse(status_code=403, content={"detail": {"code": ErrorCode.DEPLOY_NEEDS_ADMIN.value, "params": {}}})
|
||||||
request.state.user = user
|
request.state.user = user
|
||||||
request.state.api_token = api_token
|
request.state.api_token = api_token
|
||||||
actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150]
|
actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150]
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Decrypt an offsite backup (``.opxbak``) back into a config.xml.
|
||||||
|
|
||||||
|
docker compose exec -T backend python -m app.backup_decrypt < file.opxbak > config.xml
|
||||||
|
|
||||||
|
The passphrase comes from the BACKUP_PASSPHRASE environment variable or, if that is
|
||||||
|
not set, from the passphrase stored in OPNexus. For a disaster-recovery machine
|
||||||
|
without OPNexus run it with the passphrase you kept elsewhere:
|
||||||
|
|
||||||
|
docker run --rm -i -e BACKUP_PASSPHRASE=... <backend image> python -m app.backup_decrypt < file.opxbak > config.xml
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.services import backup_crypto, notification_secret_store
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
passphrase = os.environ.get("BACKUP_PASSPHRASE") or notification_secret_store.read_secret("backup_passphrase")
|
||||||
|
if not passphrase:
|
||||||
|
print("Keine Passphrase: BACKUP_PASSPHRASE setzen.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
sys.stdout.buffer.write(backup_crypto.decrypt(sys.stdin.buffer.read(), passphrase))
|
||||||
|
except backup_crypto.DecryptError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
|
|||||||
# keine Instanz- oder Nutzerdaten.
|
# keine Instanz- oder Nutzerdaten.
|
||||||
update_check_url: str = ""
|
update_check_url: str = ""
|
||||||
update_check_interval_hours: int = 12
|
update_check_interval_hours: int = 12
|
||||||
|
# Registry der Release-Images (OPNEXUS_IMAGE_REPO). Leer = OPNexus wird lokal aus dem
|
||||||
|
# Quellcode gebaut; dann gibt es keine Updates per Klick (nur der Hinweis in den Einstellungen).
|
||||||
|
opnexus_image_repo: str = ""
|
||||||
|
|
||||||
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
|
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
|
||||||
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
|
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
|
||||||
@@ -53,6 +56,11 @@ class Settings(BaseSettings):
|
|||||||
# reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan".
|
# reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan".
|
||||||
frontend_origin: str = ""
|
frontend_origin: str = ""
|
||||||
opnsense_secret_dir: str = "/data/opnsense-secrets"
|
opnsense_secret_dir: str = "/data/opnsense-secrets"
|
||||||
|
# Management overlay (WireGuard hub in the optional `wg-hub` container, see services/overlay_service.py).
|
||||||
|
overlay_dir: str = "/data/overlay"
|
||||||
|
overlay_proxy_url: str = "http://wg-hub:8888"
|
||||||
|
# Lab only: allow an http:// endpoint for offsite backups (credentials in the clear).
|
||||||
|
allow_insecure_offsite: bool = False
|
||||||
opnsense_backup_dir: str = "/data/opnsense-backups"
|
opnsense_backup_dir: str = "/data/opnsense-backups"
|
||||||
opnsense_timeout: float = 10.0
|
opnsense_timeout: float = 10.0
|
||||||
# Firewall API credentials are sent as HTTP Basic auth. Plain http://
|
# Firewall API credentials are sent as HTTP Basic auth. Plain http://
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class ErrorCode(str, Enum):
|
|||||||
UPDATER_REJECTED = "UPDATER_REJECTED"
|
UPDATER_REJECTED = "UPDATER_REJECTED"
|
||||||
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
|
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
|
||||||
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
|
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
|
||||||
|
UPDATE_CHECK_MANAGED_BY_SERVER = "UPDATE_CHECK_MANAGED_BY_SERVER"
|
||||||
|
UPDATE_CHECK_URL_INVALID = "UPDATE_CHECK_URL_INVALID"
|
||||||
ADMIN_REQUIRED = "ADMIN_REQUIRED"
|
ADMIN_REQUIRED = "ADMIN_REQUIRED"
|
||||||
BACKUP_EMPTY = "BACKUP_EMPTY"
|
BACKUP_EMPTY = "BACKUP_EMPTY"
|
||||||
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
||||||
@@ -155,6 +157,17 @@ class ErrorCode(str, Enum):
|
|||||||
SERVICE_LOCKED = "SERVICE_LOCKED"
|
SERVICE_LOCKED = "SERVICE_LOCKED"
|
||||||
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
|
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
|
||||||
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED"
|
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED"
|
||||||
|
OFFSITE_TEST_FAILED = "OFFSITE_TEST_FAILED"
|
||||||
|
OIDC_TEST_FAILED = "OIDC_TEST_FAILED"
|
||||||
|
VARIABLES_MISSING = "VARIABLES_MISSING"
|
||||||
|
VARIABLE_INVALID = "VARIABLE_INVALID"
|
||||||
|
OVERLAY_INVALID = "OVERLAY_INVALID"
|
||||||
|
OVERLAY_SUBNET_LOCKED = "OVERLAY_SUBNET_LOCKED"
|
||||||
|
OVERLAY_NAME_TAKEN = "OVERLAY_NAME_TAKEN"
|
||||||
|
OVERLAY_PEER_NOT_FOUND = "OVERLAY_PEER_NOT_FOUND"
|
||||||
|
DEPLOY_NEEDS_ADMIN = "DEPLOY_NEEDS_ADMIN"
|
||||||
|
OIDC_MANAGED_USER = "OIDC_MANAGED_USER"
|
||||||
|
BACKUP_UNLESBAR = "BACKUP_UNLESBAR"
|
||||||
API_TOKEN_NOT_FOUND = "API_TOKEN_NOT_FOUND"
|
API_TOKEN_NOT_FOUND = "API_TOKEN_NOT_FOUND"
|
||||||
API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN"
|
API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN"
|
||||||
API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH"
|
API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH"
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Full OPNexus backup: the database and every secret store in one encrypted file.
|
||||||
|
|
||||||
|
The nightly database dump is not enough to rebuild an OPNexus server: the firewalls' API secrets, TOTP
|
||||||
|
secrets, VPN/certificate keys and the stored config snapshots live in separate volumes. This packs all of it
|
||||||
|
(``database.dump`` + the data directories + ``manifest.json``) into a tar.gz and encrypts it with a passphrase
|
||||||
|
(AES-256-GCM, services/backup_crypto.py). Without the passphrase the file is useless, so keep it elsewhere.
|
||||||
|
|
||||||
|
Normally driven by the host CLI:
|
||||||
|
|
||||||
|
./opnexus backup --full [FILE] # writes an encrypted .opxfull file
|
||||||
|
./opnexus restore FILE # onto a fresh or existing installation
|
||||||
|
|
||||||
|
or directly (passphrase in FULL_BACKUP_PASSPHRASE):
|
||||||
|
|
||||||
|
python -m app.full_backup create > file.opxfull
|
||||||
|
python -m app.full_backup info < file.opxfull # manifest only
|
||||||
|
python -m app.full_backup restore < file.opxfull
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import backup_crypto
|
||||||
|
from app.version import APP_VERSION
|
||||||
|
|
||||||
|
FORMAT = 1
|
||||||
|
MIN_PASSPHRASE = 12
|
||||||
|
# manifest key -> settings attribute holding the directory
|
||||||
|
_DATA_DIRS = {
|
||||||
|
"opnsense_secrets": "opnsense_secret_dir",
|
||||||
|
"opnsense_backups": "opnsense_backup_dir",
|
||||||
|
"ipsec_psk_secrets": "ipsec_psk_secret_dir",
|
||||||
|
"openvpn_key_secrets": "openvpn_key_secret_dir",
|
||||||
|
"wireguard_key_secrets": "wireguard_key_secret_dir",
|
||||||
|
"cert_key_secrets": "cert_key_secret_dir",
|
||||||
|
"acme_secrets": "acme_secret_dir",
|
||||||
|
"totp_secrets": "totp_secret_dir",
|
||||||
|
"notification_secrets": "notification_secret_dir",
|
||||||
|
"overlay": "overlay_dir",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FullBackupError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _dir(name: str) -> Path:
|
||||||
|
return Path(getattr(settings, _DATA_DIRS[name]))
|
||||||
|
|
||||||
|
|
||||||
|
def _db_args() -> tuple[list[str], dict]:
|
||||||
|
url = make_url(settings.database_url)
|
||||||
|
return (
|
||||||
|
["-h", url.host or "localhost", "-p", str(url.port or 5432), "-U", url.username or "", "-d", url.database or ""],
|
||||||
|
{**os.environ, "PGPASSWORD": url.password or ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_database(target: Path) -> None:
|
||||||
|
args, env = _db_args()
|
||||||
|
skip = [f"--exclude-table-data={t}" for t in ("firewall_log_entries", "host_resource_samples")]
|
||||||
|
subprocess.run(["pg_dump", *args, "-F", "c", *skip, "-f", str(target)], env=env, check=True, capture_output=True, timeout=1800)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_database(source: Path) -> None:
|
||||||
|
args, env = _db_args()
|
||||||
|
result = subprocess.run(
|
||||||
|
["pg_restore", *args, "--clean", "--if-exists", "--no-owner", "--exit-on-error", str(source)],
|
||||||
|
env=env, capture_output=True, text=True, timeout=1800,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise FullBackupError(f"pg_restore fehlgeschlagen: {result.stderr.strip()[:400]}")
|
||||||
|
|
||||||
|
|
||||||
|
def _head_revision() -> str:
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from app import migrate
|
||||||
|
return ScriptDirectory.from_config(migrate._config()).get_current_head()
|
||||||
|
|
||||||
|
|
||||||
|
def create(passphrase: str, *, dump=_dump_database, revision=None) -> bytes:
|
||||||
|
if len(passphrase) < MIN_PASSPHRASE:
|
||||||
|
raise FullBackupError(f"Die Passphrase braucht mindestens {MIN_PASSPHRASE} Zeichen")
|
||||||
|
manifest = {
|
||||||
|
"format": FORMAT, "app_version": APP_VERSION, "db_revision": revision or _head_revision(),
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(), "data": sorted(_DATA_DIRS),
|
||||||
|
}
|
||||||
|
plain = io.BytesIO()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=plain, mode="w:gz") as tar:
|
||||||
|
raw = json.dumps(manifest, indent=1).encode()
|
||||||
|
info = tarfile.TarInfo("manifest.json")
|
||||||
|
info.size = len(raw)
|
||||||
|
tar.addfile(info, io.BytesIO(raw))
|
||||||
|
dump_path = Path(tmp) / "database.dump"
|
||||||
|
dump(dump_path)
|
||||||
|
tar.add(dump_path, arcname="database.dump")
|
||||||
|
for name in _DATA_DIRS:
|
||||||
|
path = _dir(name)
|
||||||
|
if path.is_dir():
|
||||||
|
tar.add(path, arcname=f"data/{name}")
|
||||||
|
return backup_crypto.encrypt(plain.getvalue(), passphrase)
|
||||||
|
|
||||||
|
|
||||||
|
def _open(blob: bytes, passphrase: str) -> tarfile.TarFile:
|
||||||
|
try:
|
||||||
|
plain = backup_crypto.decrypt(blob, passphrase)
|
||||||
|
except backup_crypto.DecryptError as exc:
|
||||||
|
raise FullBackupError(str(exc)) from None
|
||||||
|
try:
|
||||||
|
return tarfile.open(fileobj=io.BytesIO(plain), mode="r:gz")
|
||||||
|
except tarfile.TarError:
|
||||||
|
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Archiv unlesbar)") from None
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(tar: tarfile.TarFile) -> dict:
|
||||||
|
try:
|
||||||
|
member = tar.extractfile("manifest.json")
|
||||||
|
manifest = json.loads(member.read()) if member else None
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
manifest = None
|
||||||
|
if not isinstance(manifest, dict) or manifest.get("format") != FORMAT:
|
||||||
|
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Manifest fehlt oder ist neuer)")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def info(blob: bytes, passphrase: str) -> dict:
|
||||||
|
with _open(blob, passphrase) as tar:
|
||||||
|
return _manifest(tar)
|
||||||
|
|
||||||
|
|
||||||
|
def restore(blob: bytes, passphrase: str, *, restore_db=_restore_database, head=None) -> dict:
|
||||||
|
"""Put database and data directories back. The caller stops the backend first."""
|
||||||
|
with _open(blob, passphrase) as tar:
|
||||||
|
manifest = _manifest(tar)
|
||||||
|
if not _older_or_equal(manifest.get("db_revision"), head):
|
||||||
|
raise FullBackupError(
|
||||||
|
f"Die Sicherung stammt aus einer neueren OPNexus-Version ({manifest.get('app_version')}). "
|
||||||
|
"Erst auf diese Version aktualisieren, dann zurückspielen."
|
||||||
|
)
|
||||||
|
members = tar.getmembers()
|
||||||
|
for member in members: # reject anything that could escape: absolute paths, .., links, devices
|
||||||
|
if member.name.startswith(("/", "\\")) or ".." in Path(member.name).parts or not (member.isfile() or member.isdir()):
|
||||||
|
raise FullBackupError(f"Unzulässiger Eintrag im Archiv: {member.name!r}")
|
||||||
|
known = {f"data/{name}" for name in _DATA_DIRS}
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
dump = tar.extractfile("database.dump")
|
||||||
|
if dump is None:
|
||||||
|
raise FullBackupError("Die Sicherung enthält keine Datenbank")
|
||||||
|
dump_path = Path(tmp) / "database.dump"
|
||||||
|
dump_path.write_bytes(dump.read())
|
||||||
|
restore_db(dump_path)
|
||||||
|
restored = []
|
||||||
|
for member in members:
|
||||||
|
parts = Path(member.name).parts
|
||||||
|
if len(parts) < 2 or parts[0] != "data" or f"data/{parts[1]}" not in known:
|
||||||
|
continue
|
||||||
|
target_root = _dir(parts[1])
|
||||||
|
target = target_root.joinpath(*parts[2:])
|
||||||
|
if member.isdir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
source = tar.extractfile(member)
|
||||||
|
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "wb") as out:
|
||||||
|
out.write(source.read())
|
||||||
|
os.utime(target, (member.mtime, member.mtime)) # snapshot ages come from file times
|
||||||
|
restored.append(parts[1])
|
||||||
|
return {**manifest, "files_restored": len(restored)}
|
||||||
|
|
||||||
|
|
||||||
|
def _older_or_equal(revision: str | None, head: str | None) -> bool:
|
||||||
|
"""Revisions are zero-padded numbers ('0009'): a dump from an older schema is fine, the app migrates it."""
|
||||||
|
try:
|
||||||
|
return int(revision) <= int(head or _head_revision())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _main(argv: list[str]) -> int:
|
||||||
|
passphrase = os.environ.get("FULL_BACKUP_PASSPHRASE", "")
|
||||||
|
if len(argv) != 2 or argv[1] not in ("create", "info", "restore"):
|
||||||
|
print(__doc__, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
if argv[1] == "create":
|
||||||
|
sys.stdout.buffer.write(create(passphrase))
|
||||||
|
elif argv[1] == "info":
|
||||||
|
print(json.dumps(info(sys.stdin.buffer.read(), passphrase), indent=1))
|
||||||
|
else:
|
||||||
|
print(json.dumps(restore(sys.stdin.buffer.read(), passphrase), indent=1))
|
||||||
|
except (FullBackupError, subprocess.SubprocessError, OSError) as exc:
|
||||||
|
print(f"Fehler: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(_main(sys.argv))
|
||||||
+58
-2
@@ -18,7 +18,7 @@ from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddle
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app import migrate
|
from app import migrate
|
||||||
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router
|
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router, audit_export as audit_export_router, backups as backups_router, oidc as oidc_router, governance as governance_router, overlay as overlay_router, variables as variables_router
|
||||||
from app.routers import one_to_one
|
from app.routers import one_to_one
|
||||||
from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments
|
from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments
|
||||||
from app.routers import outbound_nat
|
from app.routers import outbound_nat
|
||||||
@@ -51,6 +51,9 @@ from app.services import resource_monitor_service
|
|||||||
from app.routers import scheduled_maintenance as scheduled_maintenance_router
|
from app.routers import scheduled_maintenance as scheduled_maintenance_router
|
||||||
from app.services import scheduled_maintenance_service
|
from app.services import scheduled_maintenance_service
|
||||||
from app.services import compliance_service
|
from app.services import compliance_service
|
||||||
|
from app.services import audit_export_service
|
||||||
|
from app.services import overlay_service, variables_service
|
||||||
|
from app.services import backup_schedule_service
|
||||||
from app.services import carp_event_service, carp_maintenance_window_service
|
from app.services import carp_event_service, carp_maintenance_window_service
|
||||||
|
|
||||||
# Without this, Python's root logger defaults to WARNING with no handler,
|
# Without this, Python's root logger defaults to WARNING with no handler,
|
||||||
@@ -274,11 +277,48 @@ async def _compliance_poll_loop():
|
|||||||
await asyncio.sleep(_COMPLIANCE_INTERVAL_SECONDS)
|
await asyncio.sleep(_COMPLIANCE_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
_AUDIT_EXPORT_POLL_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def _audit_export_poll_loop():
|
||||||
|
"""Seventh background loop: forwards new audit events (syslog / webhook) when configured.
|
||||||
|
Cheap when off (one settings read); keeps draining while batches come back full."""
|
||||||
|
while True:
|
||||||
|
shipped = 0
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
shipped = await audit_export_service.run_once(db)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Audit-Export fehlgeschlagen")
|
||||||
|
await asyncio.sleep(0 if shipped >= audit_export_service.BATCH else _AUDIT_EXPORT_POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
_BACKUP_POLL_SECONDS = 15 * 60
|
||||||
|
|
||||||
|
|
||||||
|
async def _backup_poll_loop():
|
||||||
|
"""Eighth background loop: takes due scheduled config backups, prunes, copies offsite."""
|
||||||
|
await asyncio.sleep(90)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
result = await backup_schedule_service.run_due(db)
|
||||||
|
if result["taken"] or result["failed"] or result["uploaded"]:
|
||||||
|
logger.info("Geplante Backups: %s", result)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Geplante Backups fehlgeschlagen")
|
||||||
|
await asyncio.sleep(_BACKUP_POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await migrate.run()
|
await migrate.run()
|
||||||
async with SessionLocal() as db:
|
async with SessionLocal() as db:
|
||||||
await auth_service.bootstrap_admin(db)
|
await auth_service.bootstrap_admin(db)
|
||||||
|
try:
|
||||||
|
await overlay_service.sync(db) # hub config + proxy rule follow the database after every start
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Overlay-Konfiguration konnte nicht geschrieben werden")
|
||||||
if any(o.startswith("https://") for o in settings.allowed_origins) and not settings.cookie_secure:
|
if any(o.startswith("https://") for o in settings.allowed_origins) and not settings.cookie_secure:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie "
|
"FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie "
|
||||||
@@ -293,6 +333,8 @@ async def lifespan(app: FastAPI):
|
|||||||
resource_task = asyncio.create_task(_resource_poll_loop())
|
resource_task = asyncio.create_task(_resource_poll_loop())
|
||||||
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
|
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
|
||||||
compliance_task = asyncio.create_task(_compliance_poll_loop())
|
compliance_task = asyncio.create_task(_compliance_poll_loop())
|
||||||
|
audit_export_task = asyncio.create_task(_audit_export_poll_loop())
|
||||||
|
backup_task = asyncio.create_task(_backup_poll_loop())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -302,7 +344,9 @@ async def lifespan(app: FastAPI):
|
|||||||
resource_task.cancel()
|
resource_task.cancel()
|
||||||
scheduled_maintenance_task.cancel()
|
scheduled_maintenance_task.cancel()
|
||||||
compliance_task.cancel()
|
compliance_task.cancel()
|
||||||
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task):
|
audit_export_task.cancel()
|
||||||
|
backup_task.cancel()
|
||||||
|
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task, audit_export_task, backup_task):
|
||||||
try:
|
try:
|
||||||
await task
|
await task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -352,6 +396,12 @@ async def _safe_validation_error(request: Request, exc: RequestValidationError):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(variables_service.MissingVariables)
|
||||||
|
async def _missing_variables(request: Request, exc: variables_service.MissingVariables):
|
||||||
|
"""A deploy that needs a value no target host has defined is refused before anything is written."""
|
||||||
|
return JSONResponse(status_code=422, content={"detail": {"code": ErrorCode.VARIABLES_MISSING.value, "params": {"details": str(exc)}}})
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(DBAPIError)
|
@app.exception_handler(DBAPIError)
|
||||||
async def _database_text_errors(request: Request, exc: 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
|
"""A NUL byte in any text field cannot be stored by Postgres and used to
|
||||||
@@ -402,6 +452,12 @@ app.include_router(tokens_router.router, prefix="/api")
|
|||||||
app.include_router(metrics_router.router, prefix="/api")
|
app.include_router(metrics_router.router, prefix="/api")
|
||||||
app.include_router(mcp_router.router, prefix="/api")
|
app.include_router(mcp_router.router, prefix="/api")
|
||||||
app.include_router(compliance_router.router, prefix="/api")
|
app.include_router(compliance_router.router, prefix="/api")
|
||||||
|
app.include_router(audit_export_router.router, prefix="/api")
|
||||||
|
app.include_router(backups_router.router, prefix="/api")
|
||||||
|
app.include_router(oidc_router.router, prefix="/api")
|
||||||
|
app.include_router(governance_router.router, prefix="/api")
|
||||||
|
app.include_router(overlay_router.router, prefix="/api")
|
||||||
|
app.include_router(variables_router.router, prefix="/api")
|
||||||
app.include_router(logs.router, prefix="/api")
|
app.include_router(logs.router, prefix="/api")
|
||||||
app.include_router(groups.router, prefix="/api")
|
app.include_router(groups.router, prefix="/api")
|
||||||
app.include_router(aliases.router, prefix="/api")
|
app.include_router(aliases.router, prefix="/api")
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Forwarding of the audit trail to a syslog server and/or a webhook (singleton row).
|
||||||
|
|
||||||
|
``cursor_*`` is the position of the last shipped event, so delivery resumes where
|
||||||
|
it stopped (at-least-once). Enabling a channel starts at "now": the existing history
|
||||||
|
is not replayed. The webhook signing secret lives on disk, like every other secret
|
||||||
|
(services/notification_secret_store.py).
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditExportSettings(Base):
|
||||||
|
__tablename__ = "audit_export_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
syslog_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
syslog_host: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
syslog_port: Mapped[int] = mapped_column(Integer, nullable=False, default=514)
|
||||||
|
# "udp" | "tcp" | "tls"
|
||||||
|
syslog_protocol: Mapped[str] = mapped_column(String(3), nullable=False, default="udp")
|
||||||
|
webhook_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
webhook_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
webhook_signing_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
cursor_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
cursor_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||||
|
last_success_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||||
|
last_error_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Scheduled config backups and their offsite copy (singleton row + upload ledger).
|
||||||
|
|
||||||
|
The S3 secret key and the encryption passphrase live on disk (notification_secret_store,
|
||||||
|
names ``backup_s3_secret`` / ``backup_passphrase``), never here.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BackupSettings(Base):
|
||||||
|
__tablename__ = "backup_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
scheduled_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
interval_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=24)
|
||||||
|
keep_count: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
|
||||||
|
offsite_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
s3_endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
s3_region: Mapped[str] = mapped_column(String(64), nullable=False, default="us-east-1")
|
||||||
|
s3_bucket: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
s3_prefix: Mapped[str] = mapped_column(String(255), nullable=False, default="opnexus")
|
||||||
|
s3_access_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
s3_path_style: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
s3_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
passphrase_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_offsite_ok_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_offsite_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupUpload(Base):
|
||||||
|
"""Which local snapshot already exists offsite (so nothing is uploaded twice)."""
|
||||||
|
|
||||||
|
__tablename__ = "backup_uploads"
|
||||||
|
__table_args__ = (UniqueConstraint("host_id", "snapshot_id", name="uq_backup_upload"),)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||||
|
snapshot_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
remote_key: Mapped[str] = mapped_column(String(400), nullable=False)
|
||||||
|
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Separation of duties (singleton row): may operators roll changes out themselves?"""
|
||||||
|
from sqlalchemy import Boolean, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceSettings(Base):
|
||||||
|
__tablename__ = "governance_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
# False: an operator prepares changes (edit, preview) but only an admin may deploy them.
|
||||||
|
operators_may_deploy: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
@@ -44,3 +44,7 @@ class OpnsenseHost(Base):
|
|||||||
policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship(
|
policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship(
|
||||||
"PolicyHierarchyGroup", back_populates="hosts"
|
"PolicyHierarchyGroup", back_populates="hosts"
|
||||||
)
|
)
|
||||||
|
# Values for {{NAME}} placeholders (services/variables_service.py); always loaded with the host.
|
||||||
|
variables: Mapped[list["HostVariable"]] = relationship(
|
||||||
|
"HostVariable", lazy="selectin", cascade="all, delete-orphan", order_by="HostVariable.name"
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Per-host values for ``{{NAME}}`` placeholders in centrally authored objects (see services/variables_service.py)."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HostVariable(Base):
|
||||||
|
__tablename__ = "host_variables"
|
||||||
|
__table_args__ = (UniqueConstraint("host_id", "name", name="uq_host_variable"),)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(41), nullable=False)
|
||||||
|
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
@@ -51,6 +51,9 @@ class NotificationSettings(Base):
|
|||||||
network_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")
|
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
|
backup_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
|
# Off by default: switching it on for an existing fleet reports every long-standing finding at once.
|
||||||
|
compliance_alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
|
||||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Single sign-on via OpenID Connect: settings (singleton) and in-flight login attempts.
|
||||||
|
|
||||||
|
The client secret lives on disk (notification_secret_store, name ``oidc_client_secret``), not here.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OidcSettings(Base):
|
||||||
|
__tablename__ = "oidc_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(60), nullable=False, default="SSO")
|
||||||
|
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
|
||||||
|
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
|
||||||
|
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
read_only_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OidcLoginState(Base):
|
||||||
|
"""One row per started login; consumed (deleted) by the callback, expires after minutes."""
|
||||||
|
|
||||||
|
__tablename__ = "oidc_login_states"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
state_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Management overlay: a WireGuard hub so OPNexus can reach firewalls behind NAT/CGNAT.
|
||||||
|
|
||||||
|
The firewall dials OUT to the hub; OPNexus then talks to its API at the firewall's overlay address. Keys live
|
||||||
|
on disk (services/overlay_service.py), never in the database.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OverlaySettings(Base):
|
||||||
|
__tablename__ = "overlay_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# host or host:port that firewalls dial (public name/IP of this server and the published UDP port)
|
||||||
|
endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
subnet: Mapped[str] = mapped_column(String(18), nullable=False, default="10.99.0.0/24")
|
||||||
|
listen_port: Mapped[int] = mapped_column(Integer, nullable=False, default=51820)
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayPeer(Base):
|
||||||
|
__tablename__ = "overlay_peers"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||||
|
ip: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
|
||||||
|
# the firewall's WireGuard public key, entered once the firewall side exists
|
||||||
|
public_key: Mapped[Optional[str]] = mapped_column(String(44), nullable=True, unique=True)
|
||||||
|
host_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""When did a firewall rule last match something? (per host and rule UUID)
|
||||||
|
|
||||||
|
pf's own counters restart whenever the ruleset is reloaded (every apply, every reboot), so
|
||||||
|
"packets = 0" alone proves nothing. OPNexus samples them periodically and keeps the time of the
|
||||||
|
last observed hit, which survives those resets. See services/rule_hit_service.py.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RuleHit(Base):
|
||||||
|
__tablename__ = "rule_hits"
|
||||||
|
__table_args__ = (UniqueConstraint("host_id", "rule_uuid", name="uq_rule_hit"),)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||||
|
rule_uuid: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
last_sampled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
# None = never seen a hit since OPNexus started watching.
|
||||||
|
last_hit_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_packets: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
last_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Version check configured in the web UI (singleton row, id fixed at 1).
|
||||||
|
|
||||||
|
Off by default: OPNexus makes no outgoing request until an admin switches it on. An
|
||||||
|
UPDATE_CHECK_URL in the server's .env still wins over this row (server-managed)."""
|
||||||
|
from sqlalchemy import Boolean, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCheckSettings(Base):
|
||||||
|
__tablename__ = "update_check_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
feed_url: Mapped[str] = mapped_column(String(500), nullable=False, default=DEFAULT_FEED_URL, server_default=DEFAULT_FEED_URL)
|
||||||
@@ -32,6 +32,10 @@ class AdminUser(Base):
|
|||||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
totp_last_step: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
|
totp_last_step: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
|
||||||
totp_recovery_hashes: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
totp_recovery_hashes: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
# "local" (password) or "oidc" (single sign-on: role follows the identity provider's groups at every
|
||||||
|
# login, the password hash is an unusable placeholder). oidc_subject is "<issuer>|<sub>".
|
||||||
|
auth_source: Mapped[str] = mapped_column(String(10), nullable=False, default="local", server_default="local")
|
||||||
|
oidc_subject: Mapped[Optional[str]] = mapped_column(String(600), nullable=True, unique=True, index=True)
|
||||||
|
|
||||||
sessions: Mapped[list["Session"]] = relationship(
|
sessions: Mapped[list["Session"]] = relationship(
|
||||||
"Session", back_populates="user", cascade="all, delete-orphan"
|
"Session", back_populates="user", cascade="all, delete-orphan"
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""The ``wg-hub`` container: WireGuard interface + a CONNECT proxy for the management overlay.
|
||||||
|
|
||||||
|
Runs from the backend image (``python -m app.overlay_hub``) with CAP_NET_ADMIN and the WireGuard UDP port
|
||||||
|
published. Everything it needs comes from ``<overlay_dir>/hub.json`` (written by the backend, see
|
||||||
|
services/overlay_service.py); it reports back through ``status.json``. It never talks to the database.
|
||||||
|
|
||||||
|
* WireGuard: wg0 with the hub address, peers synced with ``wg syncconf`` whenever hub.json changes. Inbound
|
||||||
|
NEW connections from the tunnel are dropped (iptables), so a compromised firewall cannot reach anything on this
|
||||||
|
container; OPNexus' own requests to the firewalls and their replies are unaffected.
|
||||||
|
* Proxy: an HTTP CONNECT proxy (port 8888, compose network only). It is not an open proxy: it only connects to
|
||||||
|
IPv4 addresses inside the overlay subnet from hub.json.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("overlay_hub")
|
||||||
|
INTERFACE = "wg0"
|
||||||
|
PROXY_PORT = 8888
|
||||||
|
APPLY_EVERY = 5
|
||||||
|
|
||||||
|
|
||||||
|
def run(*cmd: str, stdin: str | None = None) -> tuple[int, str]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(list(cmd), input=stdin, capture_output=True, text=True, timeout=20)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
return 127, str(exc)
|
||||||
|
return result.returncode, (result.stdout + result.stderr).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def wg_conf(config: dict) -> str:
|
||||||
|
lines = ["[Interface]", f"PrivateKey = {config['private_key']}", f"ListenPort = {int(config['listen_port'])}"]
|
||||||
|
for peer in config.get("peers", []):
|
||||||
|
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}", f"PresharedKey = {peer['psk']}", f"AllowedIPs = {peer['ip']}/32"]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dump(text: str) -> list[dict]:
|
||||||
|
"""`wg show <if> dump`: first line is the interface, then one tab-separated line per peer."""
|
||||||
|
peers = []
|
||||||
|
for line in text.splitlines()[1:]:
|
||||||
|
f = line.split("\t")
|
||||||
|
if len(f) >= 7:
|
||||||
|
peers.append({"public_key": f[0], "endpoint": None if f[2] == "(none)" else f[2], "handshake_at": int(f[4] or 0),
|
||||||
|
"rx": int(f[5] or 0), "tx": int(f[6] or 0)})
|
||||||
|
return peers
|
||||||
|
|
||||||
|
|
||||||
|
class Hub:
|
||||||
|
def __init__(self, directory: Path, runner=run):
|
||||||
|
self.dir, self.run = directory, runner
|
||||||
|
self.subnet: ipaddress.IPv4Network | None = None
|
||||||
|
self._applied_mtime = 0.0
|
||||||
|
|
||||||
|
def read_config(self) -> dict | None:
|
||||||
|
try:
|
||||||
|
config = json.loads((self.dir / "hub.json").read_text())
|
||||||
|
return config if isinstance(config, dict) else None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def apply(self, config: dict) -> str | None:
|
||||||
|
"""Bring the interface in line with the config. Returns an error text or None."""
|
||||||
|
self.subnet = ipaddress.ip_network(config["subnet"]) if config.get("enabled") and config.get("subnet") else None
|
||||||
|
exists = self.run("ip", "link", "show", INTERFACE)[0] == 0
|
||||||
|
if not config.get("enabled"):
|
||||||
|
if exists:
|
||||||
|
self.run("ip", "link", "del", INTERFACE)
|
||||||
|
return None
|
||||||
|
steps = []
|
||||||
|
if not exists:
|
||||||
|
steps.append(("ip", "link", "add", INTERFACE, "type", "wireguard"))
|
||||||
|
steps += [("ip", "address", "replace", config["address"], "dev", INTERFACE), ("ip", "link", "set", INTERFACE, "up")]
|
||||||
|
for step in steps:
|
||||||
|
code, out = self.run(*step)
|
||||||
|
if code != 0:
|
||||||
|
return f"{' '.join(step[:3])}: {out}"[:300]
|
||||||
|
conf = self.dir / ".wg0.conf"
|
||||||
|
fd = os.open(conf, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w") as handle:
|
||||||
|
handle.write(wg_conf(config))
|
||||||
|
try:
|
||||||
|
code, out = self.run("wg", "syncconf", INTERFACE, str(conf))
|
||||||
|
finally:
|
||||||
|
conf.unlink(missing_ok=True)
|
||||||
|
if code != 0:
|
||||||
|
return f"wg syncconf: {out}"[:300]
|
||||||
|
# Nothing may start a connection INTO this container from the tunnel; replies to ours still pass.
|
||||||
|
rule = ("INPUT", "-i", INTERFACE, "-m", "conntrack", "--ctstate", "NEW", "-j", "DROP")
|
||||||
|
if self.run("iptables", "-C", *rule)[0] != 0:
|
||||||
|
code, out = self.run("iptables", "-I", *rule)
|
||||||
|
if code != 0:
|
||||||
|
return f"iptables: {out}"[:300]
|
||||||
|
# The hub only needs its own proxy: never route between peers or into the compose network.
|
||||||
|
fwd = ("FORWARD", "-j", "DROP")
|
||||||
|
if self.run("iptables", "-C", *fwd)[0] != 0:
|
||||||
|
code, out = self.run("iptables", "-I", *fwd)
|
||||||
|
if code != 0:
|
||||||
|
return f"iptables: {out}"[:300]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def status(self, error: str | None) -> dict:
|
||||||
|
code, out = self.run("wg", "show", INTERFACE, "dump")
|
||||||
|
return {"updated_at": time.time(), "up": code == 0, "peers": parse_dump(out) if code == 0 else [], "error": error}
|
||||||
|
|
||||||
|
def write_status(self, error: str | None) -> None:
|
||||||
|
tmp = self.dir / "status.json.tmp"
|
||||||
|
tmp.write_text(json.dumps(self.status(error)))
|
||||||
|
os.replace(tmp, self.dir / "status.json")
|
||||||
|
|
||||||
|
def tick(self, last_error: str | None) -> str | None:
|
||||||
|
"""One round: re-apply if hub.json changed, then report. Returns the current error."""
|
||||||
|
config = self.read_config()
|
||||||
|
if config is not None:
|
||||||
|
mtime = (self.dir / "hub.json").stat().st_mtime
|
||||||
|
if mtime != self._applied_mtime or last_error:
|
||||||
|
last_error = self.apply(config)
|
||||||
|
self._applied_mtime = mtime
|
||||||
|
self.write_status(last_error)
|
||||||
|
return last_error
|
||||||
|
|
||||||
|
# ── CONNECT proxy ────────────────────────────────────────────────────
|
||||||
|
def allowed(self, host: str) -> bool:
|
||||||
|
try:
|
||||||
|
return self.subnet is not None and ipaddress.ip_address(host) in self.subnet
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
|
try:
|
||||||
|
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 10)
|
||||||
|
method, target, _ = head.split(b"\r\n", 1)[0].decode("latin-1").split(" ", 2)
|
||||||
|
host, _, port_text = target.rpartition(":")
|
||||||
|
if method != "CONNECT" or not port_text.isdigit() or not 0 < int(port_text) < 65536 or not self.allowed(host.strip("[]")):
|
||||||
|
writer.write(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
up_reader, up_writer = await asyncio.wait_for(asyncio.open_connection(host, int(port_text)), 8)
|
||||||
|
except (OSError, asyncio.TimeoutError):
|
||||||
|
writer.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
await asyncio.gather(self._pipe(reader, up_writer), self._pipe(up_reader, writer))
|
||||||
|
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError, ValueError, OSError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
|
||||||
|
try:
|
||||||
|
while data := await src.read(65536):
|
||||||
|
dst.write(data)
|
||||||
|
await dst.drain()
|
||||||
|
except (OSError, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
dst.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
directory = Path(settings.overlay_dir)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
hub = Hub(directory)
|
||||||
|
server = await asyncio.start_server(hub.handle_client, "0.0.0.0", PROXY_PORT)
|
||||||
|
logger.info("Overlay-Hub gestartet (Proxy auf :%d)", PROXY_PORT)
|
||||||
|
error = None
|
||||||
|
async with server:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
new_error = await asyncio.to_thread(hub.tick, error)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Overlay-Hub-Runde fehlgeschlagen")
|
||||||
|
new_error = "interner Fehler"
|
||||||
|
if new_error and new_error != error:
|
||||||
|
logger.warning("Overlay: %s", new_error)
|
||||||
|
error = new_error
|
||||||
|
await asyncio.sleep(APPLY_EVERY)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Audit export settings (admin only): where the audit trail is forwarded to."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.routers.auth import current_user
|
||||||
|
from app.schemas.audit_export import AuditExportRead, AuditExportUpdate
|
||||||
|
from app.services import audit_export_service as svc
|
||||||
|
from app.services import notification_secret_store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings/audit-export", tags=["audit-export"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: AdminUser) -> None:
|
||||||
|
if user.role != "admin":
|
||||||
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=AuditExportRead)
|
||||||
|
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
return await svc.get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=AuditExportRead)
|
||||||
|
async def update(payload: AuditExportUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
row = await svc.get_settings(db)
|
||||||
|
was_active = svc.active(row)
|
||||||
|
for field in ("syslog_enabled", "syslog_host", "syslog_port", "syslog_protocol", "webhook_enabled", "webhook_url"):
|
||||||
|
value = getattr(payload, field)
|
||||||
|
if value is not None:
|
||||||
|
setattr(row, field, value.strip() if isinstance(value, str) else value)
|
||||||
|
if payload.clear_webhook_signing_secret:
|
||||||
|
notification_secret_store.delete_secret(svc.SECRET_NAME)
|
||||||
|
row.webhook_signing_secret_set = False
|
||||||
|
elif payload.webhook_signing_secret is not None:
|
||||||
|
notification_secret_store.save_secret(svc.SECRET_NAME, payload.webhook_signing_secret)
|
||||||
|
row.webhook_signing_secret_set = True
|
||||||
|
if not svc.active(row):
|
||||||
|
row.cursor_created_at = row.cursor_id = None # next enabling starts from "now" again
|
||||||
|
elif not was_active:
|
||||||
|
row.cursor_created_at, row.cursor_id = datetime.now(timezone.utc), None
|
||||||
|
await db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
try:
|
||||||
|
await svc.send_test(await svc.get_settings(db))
|
||||||
|
except svc.ExportError as exc:
|
||||||
|
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||||
|
return {"success": True}
|
||||||
@@ -30,11 +30,11 @@ def client_ip(request: Request) -> str:
|
|||||||
return request.client.host if request.client else "unknown"
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, token: str) -> None:
|
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=COOKIE_NAME,
|
key=COOKIE_NAME,
|
||||||
value=token,
|
value=token,
|
||||||
max_age=settings.session_lifetime,
|
max_age=max_age or settings.session_lifetime,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=settings.cookie_secure,
|
secure=settings.cookie_secure,
|
||||||
@@ -305,6 +305,7 @@ async def change_password(
|
|||||||
# an attacker may hold) and the replacement session commit as one unit.
|
# an attacker may hold) and the replacement session commit as one unit.
|
||||||
user.password_hash = await auth_service.hash_password_async(payload.new_password)
|
user.password_hash = await auth_service.hash_password_async(payload.new_password)
|
||||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||||
|
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
|
||||||
token, _ = await auth_service.create_session(
|
token, _ = await auth_service.create_session(
|
||||||
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Scheduled backups: status for everybody, settings and offsite test for admins, "run now" for operators."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.routers.auth import current_user
|
||||||
|
from app.schemas.backup_settings import BackupSettingsRead, BackupSettingsUpdate, BackupStatusRead
|
||||||
|
from app.services import backup_diff_service, backup_store
|
||||||
|
from app.services import backup_schedule_service as svc
|
||||||
|
from app.services import notification_secret_store
|
||||||
|
from app.services.s3_client import S3Error
|
||||||
|
|
||||||
|
router = APIRouter(tags=["backups"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: AdminUser) -> None:
|
||||||
|
if user.role != "admin":
|
||||||
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backups/status", response_model=BackupStatusRead)
|
||||||
|
async def status(db: AsyncSession = Depends(get_db)):
|
||||||
|
return await svc.get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backups/run")
|
||||||
|
async def run_now(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Back up every enabled host now (and copy offsite if configured)."""
|
||||||
|
return await svc.run_due(db, force=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/backups", response_model=BackupSettingsRead)
|
||||||
|
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
return await svc.get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/backups", response_model=BackupSettingsRead)
|
||||||
|
async def update(payload: BackupSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
row = await svc.get_settings(db)
|
||||||
|
for field in ("scheduled_enabled", "interval_hours", "keep_count", "offsite_enabled", "s3_region", "s3_path_style"):
|
||||||
|
value = getattr(payload, field)
|
||||||
|
if value is not None:
|
||||||
|
setattr(row, field, value)
|
||||||
|
for field in ("s3_endpoint", "s3_bucket", "s3_access_key", "s3_prefix"):
|
||||||
|
value = getattr(payload, field)
|
||||||
|
if value is not None:
|
||||||
|
setattr(row, field, value.strip().rstrip("/") if field == "s3_endpoint" else value.strip())
|
||||||
|
if payload.clear_s3_secret:
|
||||||
|
notification_secret_store.delete_secret(svc.S3_SECRET)
|
||||||
|
row.s3_secret_set = False
|
||||||
|
elif payload.s3_secret_key is not None:
|
||||||
|
notification_secret_store.save_secret(svc.S3_SECRET, payload.s3_secret_key)
|
||||||
|
row.s3_secret_set = True
|
||||||
|
if payload.passphrase is not None:
|
||||||
|
notification_secret_store.save_secret(svc.PASSPHRASE, payload.passphrase)
|
||||||
|
row.passphrase_set = True
|
||||||
|
await db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/backups/test-offsite")
|
||||||
|
async def test_offsite(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
"""Write, read back and delete a probe object in the bucket."""
|
||||||
|
_require_admin(user)
|
||||||
|
cfg = await svc.get_settings(db)
|
||||||
|
try:
|
||||||
|
key = "/".join(p for p in (cfg.s3_prefix.strip("/"), f".opnexus-test-{int(datetime.now(timezone.utc).timestamp())}") if p)
|
||||||
|
await svc.s3_for(cfg).test(key)
|
||||||
|
except S3Error as exc:
|
||||||
|
raise api_error(502, ErrorCode.OFFSITE_TEST_FAILED, reason=str(exc)) from exc
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hosts/{host_id}/backup-diff")
|
||||||
|
async def backup_diff(
|
||||||
|
host_id: uuid.UUID,
|
||||||
|
a: str | None = Query(default=None, description="Older backup id (default: the one before b)"),
|
||||||
|
b: str | None = Query(default=None, description="Newer backup id (default: the newest)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""What changed between two stored backups of this host. Secrets are replaced by hashes."""
|
||||||
|
if await db.get(OpnsenseHost, host_id) is None:
|
||||||
|
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||||
|
snapshots = [s["id"] for s in backup_store.list_snapshots(host_id)] # newest first
|
||||||
|
new_id = b or (snapshots[0] if snapshots else None)
|
||||||
|
old_id = a or (snapshots[snapshots.index(new_id) + 1] if new_id in snapshots and snapshots.index(new_id) + 1 < len(snapshots) else None)
|
||||||
|
if not new_id or not old_id:
|
||||||
|
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
||||||
|
old, new = backup_store.read_snapshot(host_id, old_id), backup_store.read_snapshot(host_id, new_id)
|
||||||
|
if old is None or new is None:
|
||||||
|
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
||||||
|
try:
|
||||||
|
result = backup_diff_service.diff(old, new)
|
||||||
|
except backup_diff_service.InvalidConfig as exc:
|
||||||
|
raise api_error(422, ErrorCode.BACKUP_UNLESBAR, reason=str(exc)) from exc
|
||||||
|
return {"a": old_id, "b": new_id, **result}
|
||||||
@@ -5,6 +5,7 @@ for dashboards, Prometheus and MCP. A check (POST) downloads the host's config
|
|||||||
read-only, evaluates it and stores the result; see services/compliance_service.py.
|
read-only, evaluates it and stores the result; see services/compliance_service.py.
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -14,7 +15,8 @@ from app.database import get_db
|
|||||||
from app.errors import ErrorCode, api_error
|
from app.errors import ErrorCode, api_error
|
||||||
from app.models.compliance_result import ComplianceResult
|
from app.models.compliance_result import ComplianceResult
|
||||||
from app.models.host import OpnsenseHost
|
from app.models.host import OpnsenseHost
|
||||||
from app.services import compliance_service
|
from app.models.rule_hit import RuleHit
|
||||||
|
from app.services import compliance_service, rule_hit_service
|
||||||
|
|
||||||
router = APIRouter(tags=["compliance"])
|
router = APIRouter(tags=["compliance"])
|
||||||
|
|
||||||
@@ -65,3 +67,22 @@ async def check_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
|||||||
host = await _host(db, host_id)
|
host = await _host(db, host_id)
|
||||||
row = await compliance_service.check_host(db, host)
|
row = await compliance_service.check_host(db, host)
|
||||||
return _read(host, row, detail=True)
|
return _read(host, row, detail=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hosts/{host_id}/rule-hits")
|
||||||
|
async def rule_hits(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""When each rule last matched something, as observed by OPNexus (see services/rule_hit_service.py)."""
|
||||||
|
await _host(db, host_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
rows = (await db.scalars(select(RuleHit).where(RuleHit.host_id == host_id))).all()
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"rule_uuid": r.rule_uuid, "description": r.description, "enabled": r.enabled,
|
||||||
|
"state": rule_hit_service.classify(r, now), "packets": r.last_packets, "bytes": r.last_bytes,
|
||||||
|
"last_hit_at": r.last_hit_at, "first_seen_at": r.first_seen_at, "last_sampled_at": r.last_sampled_at,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
key=lambda item: (item["state"] != "unused", item["last_hit_at"] or datetime.min.replace(tzinfo=timezone.utc), item["description"]),
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Separation of duties: with ``operators_may_deploy`` off, only admins roll changes out (AuthMiddleware enforces it)."""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.governance import GovernanceSettings
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.routers.auth import current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings/governance", tags=["governance"])
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceRead(BaseModel):
|
||||||
|
operators_may_deploy: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings(db: AsyncSession) -> GovernanceSettings:
|
||||||
|
row = await db.get(GovernanceSettings, 1)
|
||||||
|
if row is None:
|
||||||
|
row = GovernanceSettings(id=1, operators_may_deploy=True)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=GovernanceRead)
|
||||||
|
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
"""Readable by everybody signed in (the UI explains why a deploy was refused)."""
|
||||||
|
return await get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=GovernanceRead)
|
||||||
|
async def update(payload: GovernanceRead, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
if user.role != "admin":
|
||||||
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||||
|
row = await get_settings(db)
|
||||||
|
row.operators_may_deploy = payload.operators_may_deploy
|
||||||
|
await db.commit()
|
||||||
|
return row
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.routers.auth import _set_session_cookie, client_ip, current_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
|
||||||
|
from app.security_middleware import _origin_allowed
|
||||||
|
from app.services import auth_service, notification_secret_store, oidc_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["oidc"])
|
||||||
|
CALLBACK_PATH = "/api/auth/oidc/callback"
|
||||||
|
STATE_COOKIE = "opnexus-oidc-state"
|
||||||
|
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
|
||||||
|
# must not keep a 30-day session.
|
||||||
|
SSO_SESSION_SECONDS = 12 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _login_error(code: str) -> RedirectResponse:
|
||||||
|
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
||||||
|
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _printable(value: str) -> str:
|
||||||
|
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
|
||||||
|
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/oidc/config")
|
||||||
|
async def public_config(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""For the login page: is there an SSO button, and what does it say. Nothing else is revealed."""
|
||||||
|
cfg = await oidc_service.get_settings(db)
|
||||||
|
ready = oidc_service.configured(cfg)
|
||||||
|
return {"enabled": ready, "name": cfg.display_name if ready else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/oidc/start")
|
||||||
|
async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
cfg = await oidc_service.get_settings(db)
|
||||||
|
parts = urlsplit(origin)
|
||||||
|
# The redirect URI is built from the browser's own origin (so any hostname the operator uses works), but only
|
||||||
|
# an origin this server is actually reachable at is accepted: no path, no credentials, same host as the request.
|
||||||
|
if (not oidc_service.configured(cfg) or parts.path not in ("", "/") or parts.query or parts.fragment or parts.username
|
||||||
|
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
|
||||||
|
return _login_error("disabled")
|
||||||
|
try:
|
||||||
|
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
||||||
|
except oidc_service.OidcError as exc:
|
||||||
|
logger.warning("OIDC start fehlgeschlagen: %s", exc)
|
||||||
|
return _login_error(exc.code)
|
||||||
|
response = RedirectResponse(url, status_code=302)
|
||||||
|
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
|
||||||
|
# code+state pair is useless without the cookie.
|
||||||
|
response.set_cookie(STATE_COOKIE, state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure, path="/api/auth/oidc")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/oidc/callback")
|
||||||
|
async def callback(request: Request, code: str = "", state: str = "", error: str = "", db: AsyncSession = Depends(get_db)):
|
||||||
|
ip = client_ip(request)
|
||||||
|
if auth_service.throttle.retry_after(ip, "oidc") > 0:
|
||||||
|
return _login_error("throttled")
|
||||||
|
cfg = await oidc_service.get_settings(db)
|
||||||
|
if not oidc_service.configured(cfg):
|
||||||
|
return _login_error("disabled")
|
||||||
|
try:
|
||||||
|
if error or not code or not state:
|
||||||
|
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
|
||||||
|
if not secrets.compare_digest(request.cookies.get(STATE_COOKIE, ""), state):
|
||||||
|
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
|
||||||
|
user = await oidc_service.complete(db, cfg, code, state)
|
||||||
|
except oidc_service.OidcError as exc:
|
||||||
|
auth_service.throttle.record_failure(ip, "oidc")
|
||||||
|
logger.warning("OIDC-Anmeldung abgelehnt (%s): %s", exc.code, exc)
|
||||||
|
return _login_error(exc.code)
|
||||||
|
auth_service.throttle.reset(ip, "oidc")
|
||||||
|
await auth_service.finalize_login(db, user, ip)
|
||||||
|
token, _ = await auth_service.create_session(
|
||||||
|
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
|
||||||
|
)
|
||||||
|
response = RedirectResponse("/dashboard", status_code=302)
|
||||||
|
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
|
||||||
|
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
||||||
|
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: AdminUser) -> None:
|
||||||
|
if user.role != "admin":
|
||||||
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/oidc", response_model=OidcSettingsRead)
|
||||||
|
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
return await oidc_service.get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/oidc", response_model=OidcSettingsRead)
|
||||||
|
async def update(payload: OidcSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
cfg = await oidc_service.get_settings(db)
|
||||||
|
for field in ("enabled", "display_name", "issuer", "client_id", "scopes", "groups_claim", "admin_group", "operator_group", "read_only_group"):
|
||||||
|
value = getattr(payload, field)
|
||||||
|
if value is not None:
|
||||||
|
setattr(cfg, field, value.strip() if isinstance(value, str) else value)
|
||||||
|
if payload.clear_client_secret:
|
||||||
|
notification_secret_store.delete_secret(oidc_service.SECRET_NAME)
|
||||||
|
cfg.client_secret_set = False
|
||||||
|
elif payload.client_secret is not None:
|
||||||
|
notification_secret_store.save_secret(oidc_service.SECRET_NAME, payload.client_secret)
|
||||||
|
cfg.client_secret_set = True
|
||||||
|
await db.commit()
|
||||||
|
oidc_service.clear_caches()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/oidc/test")
|
||||||
|
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
"""Fetch the provider's discovery document and signing keys: proves the issuer URL, not the login."""
|
||||||
|
_require_admin(user)
|
||||||
|
cfg = await oidc_service.get_settings(db)
|
||||||
|
if not cfg.issuer:
|
||||||
|
raise api_error(422, ErrorCode.OIDC_TEST_FAILED, reason="Kein Aussteller eingetragen")
|
||||||
|
try:
|
||||||
|
oidc_service.clear_caches()
|
||||||
|
doc = await oidc_service.discovery(cfg.issuer)
|
||||||
|
await oidc_service._jwk_for(doc, None)
|
||||||
|
except oidc_service.OidcError as exc:
|
||||||
|
raise api_error(502, ErrorCode.OIDC_TEST_FAILED, reason=str(exc)) from exc
|
||||||
|
return {"success": True, "authorization_endpoint": doc["authorization_endpoint"]}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Management overlay API (see services/overlay_service.py). Writes and the pre-shared key are admin-only."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.models.overlay import OverlayPeer
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.routers.auth import current_user
|
||||||
|
from app.schemas.overlay import OverlaySettingsRead, OverlaySettingsUpdate, PeerCreate, PeerRead, PeerUpdate
|
||||||
|
from app.services import overlay_service as svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/overlay", tags=["overlay"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: AdminUser) -> None:
|
||||||
|
if user.role != "admin":
|
||||||
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||||
|
|
||||||
|
|
||||||
|
async def _settings_read(db: AsyncSession) -> dict:
|
||||||
|
cfg = await svc.get_settings(db)
|
||||||
|
net = svc.parse_subnet(cfg.subnet)
|
||||||
|
return {
|
||||||
|
"enabled": cfg.enabled, "endpoint": cfg.endpoint, "subnet": cfg.subnet, "listen_port": cfg.listen_port,
|
||||||
|
"hub_public_key": svc.hub_public_key() if cfg.enabled else None, "hub_address": str(svc.hub_address(net)),
|
||||||
|
"hub_state": svc.hub_state(svc.read_status()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _peers(db: AsyncSession) -> list[dict]:
|
||||||
|
status = svc.read_status()
|
||||||
|
rows = (await db.execute(select(OverlayPeer, OpnsenseHost.name).outerjoin(OpnsenseHost, OverlayPeer.host_id == OpnsenseHost.id).order_by(OverlayPeer.ip))).all()
|
||||||
|
return [
|
||||||
|
{"id": p.id, "name": p.name, "ip": p.ip, "public_key": p.public_key, "host_id": p.host_id, "host_name": host_name,
|
||||||
|
**svc.peer_status(status, p.public_key)}
|
||||||
|
for p, host_name in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def overview(db: AsyncSession = Depends(get_db)):
|
||||||
|
return {"settings": await _settings_read(db), "peers": await _peers(db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings", response_model=OverlaySettingsRead)
|
||||||
|
async def update_settings(payload: OverlaySettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
cfg = await svc.get_settings(db)
|
||||||
|
if payload.subnet is not None and payload.subnet.strip() != cfg.subnet:
|
||||||
|
try:
|
||||||
|
svc.parse_subnet(payload.subnet)
|
||||||
|
except svc.OverlayError as exc:
|
||||||
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
||||||
|
if (await db.scalar(select(OverlayPeer.id).limit(1))) is not None:
|
||||||
|
raise api_error(409, ErrorCode.OVERLAY_SUBNET_LOCKED) # peers already hold addresses in the old network
|
||||||
|
cfg.subnet = payload.subnet.strip()
|
||||||
|
for field in ("enabled", "listen_port"):
|
||||||
|
value = getattr(payload, field)
|
||||||
|
if value is not None:
|
||||||
|
setattr(cfg, field, value)
|
||||||
|
if payload.endpoint is not None:
|
||||||
|
cfg.endpoint = payload.endpoint.strip() or None
|
||||||
|
if cfg.enabled and not cfg.endpoint:
|
||||||
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Ohne Endpunkt wissen die Firewalls nicht, wohin sie sich verbinden sollen")
|
||||||
|
await db.commit()
|
||||||
|
await svc.sync(db)
|
||||||
|
return await _settings_read(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/peers", status_code=201)
|
||||||
|
async def create_peer(payload: PeerCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
cfg = await svc.get_settings(db)
|
||||||
|
taken = set((await db.scalars(select(OverlayPeer.ip))).all())
|
||||||
|
try:
|
||||||
|
ip = svc.next_free_ip(svc.parse_subnet(cfg.subnet), taken)
|
||||||
|
except svc.OverlayError as exc:
|
||||||
|
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
||||||
|
peer = OverlayPeer(name=payload.name.strip(), ip=ip)
|
||||||
|
db.add(peer)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise api_error(409, ErrorCode.OVERLAY_NAME_TAKEN) from None
|
||||||
|
await svc.sync(db)
|
||||||
|
return {"id": peer.id, "name": peer.name, "ip": peer.ip, "instructions": svc.instructions(cfg, peer)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/peers/{peer_id}/instructions")
|
||||||
|
async def peer_instructions(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
peer = await db.get(OverlayPeer, peer_id)
|
||||||
|
if peer is None:
|
||||||
|
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||||
|
return svc.instructions(await svc.get_settings(db), peer)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/peers/{peer_id}")
|
||||||
|
async def update_peer(peer_id: uuid.UUID, payload: PeerUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
peer = await db.get(OverlayPeer, peer_id)
|
||||||
|
if peer is None:
|
||||||
|
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||||
|
if payload.public_key is not None:
|
||||||
|
key = payload.public_key.strip()
|
||||||
|
if key and not svc.valid_public_key(key):
|
||||||
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist kein WireGuard-Schlüssel (44 Zeichen, Base64)")
|
||||||
|
if key and key == svc.hub_public_key():
|
||||||
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist der Schlüssel des Hubs, nicht der der Firewall")
|
||||||
|
peer.public_key = key or None
|
||||||
|
if payload.clear_host:
|
||||||
|
peer.host_id = None
|
||||||
|
elif payload.host_id is not None:
|
||||||
|
if await db.get(OpnsenseHost, payload.host_id) is None:
|
||||||
|
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||||
|
peer.host_id = payload.host_id
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason="Dieser Schlüssel gehört schon zu einem anderen Eintrag") from None
|
||||||
|
await svc.sync(db)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/peers/{peer_id}", status_code=204)
|
||||||
|
async def delete_peer(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
_require_admin(user)
|
||||||
|
peer = await db.get(OverlayPeer, peer_id)
|
||||||
|
if peer is None:
|
||||||
|
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
||||||
|
await db.delete(peer)
|
||||||
|
await db.commit()
|
||||||
|
svc.delete_peer_secret(peer_id)
|
||||||
|
await svc.sync(db)
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
from app.errors import ErrorCode, api_error
|
from app.errors import ErrorCode, api_error
|
||||||
from app.models.user import AdminUser
|
from app.models.user import AdminUser
|
||||||
from app.routers.auth import current_user
|
from app.routers.auth import current_user
|
||||||
@@ -10,10 +13,10 @@ router = APIRouter(prefix="/system", tags=["system"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/version-check")
|
@router.get("/version-check")
|
||||||
async def version_check() -> dict:
|
async def version_check(db: AsyncSession = Depends(get_db)) -> dict:
|
||||||
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
||||||
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
||||||
return await update_check_service.check()
|
return await update_check_service.check(db)
|
||||||
|
|
||||||
|
|
||||||
def _admin(user: AdminUser) -> AdminUser:
|
def _admin(user: AdminUser) -> AdminUser:
|
||||||
@@ -31,16 +34,63 @@ def _major(version: str | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCheckRead(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
feed_url: str
|
||||||
|
managed_by_server: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCheckUpdate(BaseModel):
|
||||||
|
enabled: bool | None = None
|
||||||
|
feed_url: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_check_settings(db: AsyncSession) -> UpdateCheckRead:
|
||||||
|
row = await update_check_service.get_settings(db)
|
||||||
|
managed = update_check_service.managed_by_server()
|
||||||
|
return UpdateCheckRead(
|
||||||
|
enabled=True if managed else row.enabled,
|
||||||
|
feed_url=settings.update_check_url.strip() if managed else row.feed_url,
|
||||||
|
managed_by_server=managed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/update-check", response_model=UpdateCheckRead)
|
||||||
|
async def read_update_check(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||||
|
"""Versionsprüfung für „Einstellungen › OPNexus aktualisieren“ (nur Admins)."""
|
||||||
|
_admin(user)
|
||||||
|
return await _update_check_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/update-check", response_model=UpdateCheckRead)
|
||||||
|
async def update_update_check(payload: UpdateCheckUpdate, db: AsyncSession = Depends(get_db),
|
||||||
|
user: AdminUser = Depends(current_user)):
|
||||||
|
_admin(user)
|
||||||
|
if update_check_service.managed_by_server():
|
||||||
|
raise api_error(409, ErrorCode.UPDATE_CHECK_MANAGED_BY_SERVER)
|
||||||
|
row = await update_check_service.get_settings(db)
|
||||||
|
if payload.feed_url is not None:
|
||||||
|
url = payload.feed_url.strip()
|
||||||
|
if not update_check_service.valid_feed_url(url):
|
||||||
|
raise api_error(422, ErrorCode.UPDATE_CHECK_URL_INVALID)
|
||||||
|
row.feed_url = url
|
||||||
|
if payload.enabled is not None:
|
||||||
|
row.enabled = payload.enabled
|
||||||
|
await db.commit()
|
||||||
|
update_check_service.reset_cache()
|
||||||
|
return await _update_check_settings(db)
|
||||||
|
|
||||||
|
|
||||||
class UpdateRequest(BaseModel):
|
class UpdateRequest(BaseModel):
|
||||||
version: str = Field(min_length=5, max_length=20)
|
version: str = Field(min_length=5, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/updater")
|
@router.get("/updater")
|
||||||
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
async def updater_status(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)) -> dict:
|
||||||
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
|
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
|
||||||
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
|
||||||
_admin(user)
|
_admin(user)
|
||||||
check = await update_check_service.check()
|
check = await update_check_service.check(db)
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"available": False, "reason": None, "mode": None,
|
"available": False, "reason": None, "mode": None,
|
||||||
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
|
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
|
||||||
@@ -54,6 +104,9 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
|||||||
updater = await updater_client.status()
|
updater = await updater_client.status()
|
||||||
except updater_client.UpdaterUnavailable as exc:
|
except updater_client.UpdaterUnavailable as exc:
|
||||||
result["reason"] = str(exc)
|
result["reason"] = str(exc)
|
||||||
|
# Without the updater the install mode is still known from the image registry setting; the UI uses it
|
||||||
|
# to not offer enabling the updater on an install that builds from source (it could not update anyway).
|
||||||
|
result["mode"] = "release" if settings.opnexus_image_repo.strip() else "local"
|
||||||
return result
|
return result
|
||||||
except updater_client.UpdaterRejected:
|
except updater_client.UpdaterRejected:
|
||||||
result["reason"] = "unreachable"
|
result["reason"] = "unreachable"
|
||||||
@@ -77,11 +130,12 @@ async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/updater/update", status_code=202)
|
@router.post("/updater/update", status_code=202)
|
||||||
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
|
async def updater_update(payload: UpdateRequest, db: AsyncSession = Depends(get_db),
|
||||||
|
user: AdminUser = Depends(current_user)) -> dict:
|
||||||
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
|
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
|
||||||
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
Version. Beliebige Versionen werden bewusst nicht angenommen."""
|
||||||
_admin(user)
|
_admin(user)
|
||||||
check = await update_check_service.check()
|
check = await update_check_service.check(db)
|
||||||
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
|
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
|
||||||
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
|
||||||
if _major(check["latest"]) != _major(check["current"]):
|
if _major(check["latest"]) != _major(check["current"]):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Session-only: an API token cannot reach these endpoints (AuthMiddleware), so a
|
Session-only: an API token cannot reach these endpoints (AuthMiddleware), so a
|
||||||
leaked token can never mint a replacement or list/revoke its siblings. A
|
leaked token can never mint a replacement or list/revoke its siblings. A
|
||||||
read_only user may manage their own tokens, but only with read scope.
|
read_only user may manage their own tokens, but only with read scope (an operator up to deploy).
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -43,7 +43,8 @@ async def list_tokens(db: AsyncSession = Depends(get_db), user: AdminUser = Depe
|
|||||||
async def create_token(
|
async def create_token(
|
||||||
payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
|
payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
|
||||||
):
|
):
|
||||||
if user.role != "admin" and payload.scope != TokenScope.read:
|
allowed = {"admin": (TokenScope.read, TokenScope.deploy, TokenScope.admin), "operator": (TokenScope.read, TokenScope.deploy)}
|
||||||
|
if payload.scope not in allowed.get(user.role, (TokenScope.read,)):
|
||||||
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_TOO_HIGH)
|
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_TOO_HIGH)
|
||||||
_check_account_budget(user, "create-token")
|
_check_account_budget(user, "create-token")
|
||||||
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
|
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ async def update_role(user_id: uuid.UUID, payload: UserRoleUpdate, db: AsyncSess
|
|||||||
target = await db.get(AdminUser, user_id)
|
target = await db.get(AdminUser, user_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
|
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
|
||||||
|
if target.auth_source == "oidc":
|
||||||
|
raise api_error(409, ErrorCode.OIDC_MANAGED_USER) # the identity provider's groups decide, at every login
|
||||||
if target.role == "admin" and payload.role.value != "admin" and await _admin_count(db, exclude_id=target.id) == 0:
|
if target.role == "admin" and payload.role.value != "admin" and await _admin_count(db, exclude_id=target.id) == 0:
|
||||||
raise api_error(409, ErrorCode.MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN)
|
raise api_error(409, ErrorCode.MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN)
|
||||||
target.role = payload.role.value
|
target.role = payload.role.value
|
||||||
@@ -75,6 +77,8 @@ async def reset_password(user_id: uuid.UUID, payload: UserPasswordReset, db: Asy
|
|||||||
target = await db.get(AdminUser, user_id)
|
target = await db.get(AdminUser, user_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
|
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
|
||||||
|
if target.auth_source == "oidc":
|
||||||
|
raise api_error(409, ErrorCode.OIDC_MANAGED_USER)
|
||||||
target.password_hash = await auth_service.hash_password_async(payload.new_password)
|
target.password_hash = await auth_service.hash_password_async(payload.new_password)
|
||||||
# A reset is the answer to a suspected compromise: every session of the
|
# A reset is the answer to a suspected compromise: every session of the
|
||||||
# account ends with the old password, atomically with the new hash.
|
# account ends with the old password, atomically with the new hash.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Site variables of one host: the values for {{NAME}} placeholders, and which placeholders its objects use."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.alias import FirewallAlias
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.models.host_variable import HostVariable
|
||||||
|
from app.models.one_to_one import OneToOneRule
|
||||||
|
from app.models.outbound_nat import OutboundNatRule
|
||||||
|
from app.models.policy import DeviceGroup
|
||||||
|
from app.models.port_forward import PortForwardRule
|
||||||
|
from app.models.rule import FirewallRule
|
||||||
|
from app.services import alias_deploy_service, one_to_one_deploy_service, outbound_nat_deploy_service
|
||||||
|
from app.services import port_forward_deploy_service, rule_deploy_service, variables_service
|
||||||
|
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
|
||||||
|
from app.services.policy_service import effective_hosts
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/hosts/{host_id}/variables", tags=["variables"])
|
||||||
|
|
||||||
|
_KINDS = (
|
||||||
|
("rule", FirewallRule, rule_deploy_service),
|
||||||
|
("alias", FirewallAlias, alias_deploy_service),
|
||||||
|
("port_forward", PortForwardRule, port_forward_deploy_service),
|
||||||
|
("one_to_one", OneToOneRule, one_to_one_deploy_service),
|
||||||
|
("outbound_nat", OutboundNatRule, outbound_nat_deploy_service),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VariablesUpdate(BaseModel):
|
||||||
|
variables: dict[str, str] = Field(default_factory=dict, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||||
|
host = await db.get(OpnsenseHost, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
async def _usage(db: AsyncSession, host: OpnsenseHost) -> dict[str, list[dict]]:
|
||||||
|
"""name -> objects (that are deployed to this host) which use it."""
|
||||||
|
used: dict[str, list[dict]] = {}
|
||||||
|
for kind, model, service in _KINDS:
|
||||||
|
entities = (await db.execute(
|
||||||
|
select(model).options(selectinload(model.targets), selectinload(model.groups).selectinload(DeviceGroup.hosts))
|
||||||
|
)).scalars().unique().all()
|
||||||
|
await hydrate_hierarchy_targets(db, entities)
|
||||||
|
for entity in entities:
|
||||||
|
if host.id not in {h.id for h in effective_hosts(entity)}:
|
||||||
|
continue
|
||||||
|
label = getattr(entity, "description", None) or getattr(entity, "name", None) or str(entity.id)
|
||||||
|
for name in variables_service.names_in_payload(service._base_payload(entity)):
|
||||||
|
used.setdefault(name, []).append({"kind": kind, "id": str(entity.id), "label": label})
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
async def _read(db: AsyncSession, host: OpnsenseHost) -> dict:
|
||||||
|
values = variables_service.host_values(host)
|
||||||
|
used = await _usage(db, host)
|
||||||
|
names = sorted(set(values) | set(used))
|
||||||
|
return {"variables": [
|
||||||
|
{"name": n, "value": values.get(n), "used_by": used.get(n, []), "missing": n in used and n not in values}
|
||||||
|
for n in names
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def read(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
return await _read(db, await _host(db, host_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("")
|
||||||
|
async def replace(host_id: uuid.UUID, payload: VariablesUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Replace this host's variables with exactly the given set."""
|
||||||
|
host = await _host(db, host_id)
|
||||||
|
for name, value in payload.variables.items():
|
||||||
|
if not variables_service.NAME_RE.fullmatch(name):
|
||||||
|
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Name „{name}“: Großbuchstaben, Ziffern und _, beginnend mit einem Buchstaben")
|
||||||
|
if not variables_service.VALUE_RE.fullmatch(value):
|
||||||
|
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Wert von {name}: 1–255 Zeichen aus Buchstaben, Ziffern und . : / , @ + - _ Leerzeichen")
|
||||||
|
existing = {v.name: v for v in host.variables}
|
||||||
|
for name in set(existing) - set(payload.variables):
|
||||||
|
host.variables.remove(existing[name])
|
||||||
|
for name, value in payload.variables.items():
|
||||||
|
if name in existing:
|
||||||
|
existing[name].value = value
|
||||||
|
else:
|
||||||
|
host.variables.append(HostVariable(name=name, value=value))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(host)
|
||||||
|
return await _read(db, host)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AuditExportRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
syslog_enabled: bool
|
||||||
|
syslog_host: str | None
|
||||||
|
syslog_port: int
|
||||||
|
syslog_protocol: Literal["udp", "tcp", "tls"]
|
||||||
|
webhook_enabled: bool
|
||||||
|
webhook_url: str | None
|
||||||
|
webhook_signing_secret_set: bool
|
||||||
|
last_success_at: datetime | None
|
||||||
|
last_error: str | None
|
||||||
|
last_error_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditExportUpdate(BaseModel):
|
||||||
|
"""Partial update; the signing secret is write-only (set it or clear it)."""
|
||||||
|
|
||||||
|
syslog_enabled: bool | None = None
|
||||||
|
syslog_host: str | None = Field(default=None, max_length=255)
|
||||||
|
syslog_port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
syslog_protocol: Literal["udp", "tcp", "tls"] | None = None
|
||||||
|
webhook_enabled: bool | None = None
|
||||||
|
webhook_url: str | None = Field(default=None, max_length=500)
|
||||||
|
webhook_signing_secret: str | None = Field(default=None, min_length=1)
|
||||||
|
clear_webhook_signing_secret: bool = False
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BackupStatusRead(BaseModel):
|
||||||
|
"""What every signed-in user may see: no endpoints, no keys."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
scheduled_enabled: bool
|
||||||
|
interval_hours: int
|
||||||
|
keep_count: int
|
||||||
|
offsite_enabled: bool
|
||||||
|
last_run_at: datetime | None
|
||||||
|
last_offsite_ok_at: datetime | None
|
||||||
|
last_offsite_error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupSettingsRead(BackupStatusRead):
|
||||||
|
s3_endpoint: str | None
|
||||||
|
s3_region: str
|
||||||
|
s3_bucket: str | None
|
||||||
|
s3_prefix: str
|
||||||
|
s3_access_key: str | None
|
||||||
|
s3_path_style: bool
|
||||||
|
s3_secret_set: bool
|
||||||
|
passphrase_set: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BackupSettingsUpdate(BaseModel):
|
||||||
|
"""Partial update; the S3 secret key and the passphrase are write-only."""
|
||||||
|
|
||||||
|
scheduled_enabled: bool | None = None
|
||||||
|
interval_hours: int | None = Field(default=None, ge=1, le=24 * 30)
|
||||||
|
keep_count: int | None = Field(default=None, ge=3, le=1000)
|
||||||
|
offsite_enabled: bool | None = None
|
||||||
|
s3_endpoint: str | None = Field(default=None, max_length=255)
|
||||||
|
s3_region: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
s3_bucket: str | None = Field(default=None, max_length=255)
|
||||||
|
s3_prefix: str | None = Field(default=None, max_length=255)
|
||||||
|
s3_access_key: str | None = Field(default=None, max_length=255)
|
||||||
|
s3_path_style: bool | None = None
|
||||||
|
s3_secret_key: str | None = Field(default=None, min_length=1, max_length=512)
|
||||||
|
clear_s3_secret: bool = False
|
||||||
|
passphrase: str | None = Field(default=None, min_length=12, max_length=512)
|
||||||
@@ -11,6 +11,9 @@ ProblemType = Literal[
|
|||||||
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
||||||
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
|
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
|
||||||
"scheduled_maintenance_failed",
|
"scheduled_maintenance_failed",
|
||||||
|
"backup_stale",
|
||||||
|
"backup_offsite_failed",
|
||||||
|
"compliance_finding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +43,8 @@ class NotificationSettingsRead(BaseModel):
|
|||||||
network_drift_enabled: bool = True
|
network_drift_enabled: bool = True
|
||||||
carp_auto_revert_failed_enabled: bool
|
carp_auto_revert_failed_enabled: bool
|
||||||
scheduled_maintenance_failed_enabled: bool = True
|
scheduled_maintenance_failed_enabled: bool = True
|
||||||
|
backup_failed_enabled: bool = True
|
||||||
|
compliance_alerts_enabled: bool = False
|
||||||
|
|
||||||
poll_interval_seconds: int
|
poll_interval_seconds: int
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -77,6 +82,8 @@ class NotificationSettingsUpdate(BaseModel):
|
|||||||
network_drift_enabled: bool | None = None
|
network_drift_enabled: bool | None = None
|
||||||
carp_auto_revert_failed_enabled: bool | None = None
|
carp_auto_revert_failed_enabled: bool | None = None
|
||||||
scheduled_maintenance_failed_enabled: bool | None = None
|
scheduled_maintenance_failed_enabled: bool | None = None
|
||||||
|
backup_failed_enabled: bool | None = None
|
||||||
|
compliance_alerts_enabled: bool | None = None
|
||||||
|
|
||||||
poll_interval_seconds: int | None = Field(
|
poll_interval_seconds: int | None = Field(
|
||||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class OidcSettingsRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
display_name: str
|
||||||
|
issuer: str | None
|
||||||
|
client_id: str | None
|
||||||
|
client_secret_set: bool
|
||||||
|
scopes: str
|
||||||
|
groups_claim: str
|
||||||
|
admin_group: str | None
|
||||||
|
operator_group: str | None
|
||||||
|
read_only_group: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class OidcSettingsUpdate(BaseModel):
|
||||||
|
"""Partial update; the client secret is write-only (set it or clear it)."""
|
||||||
|
|
||||||
|
enabled: bool | None = None
|
||||||
|
display_name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||||
|
issuer: str | None = Field(default=None, max_length=500)
|
||||||
|
client_id: str | None = Field(default=None, max_length=255)
|
||||||
|
client_secret: str | None = Field(default=None, min_length=1, max_length=512)
|
||||||
|
clear_client_secret: bool = False
|
||||||
|
scopes: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
groups_claim: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
admin_group: str | None = Field(default=None, max_length=255)
|
||||||
|
operator_group: str | None = Field(default=None, max_length=255)
|
||||||
|
read_only_group: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("issuer")
|
||||||
|
@classmethod
|
||||||
|
def https_only(cls, value):
|
||||||
|
if value and not value.startswith("https://"):
|
||||||
|
raise ValueError("issuer must be an https:// URL")
|
||||||
|
return value.strip().rstrip("/") if value else value
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OverlaySettingsRead(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
endpoint: str | None
|
||||||
|
subnet: str
|
||||||
|
listen_port: int
|
||||||
|
hub_public_key: str | None
|
||||||
|
hub_address: str
|
||||||
|
hub_state: str # running | stale | absent
|
||||||
|
|
||||||
|
|
||||||
|
class OverlaySettingsUpdate(BaseModel):
|
||||||
|
enabled: bool | None = None
|
||||||
|
endpoint: str | None = Field(default=None, max_length=255)
|
||||||
|
subnet: str | None = Field(default=None, max_length=18)
|
||||||
|
listen_port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
|
||||||
|
|
||||||
|
class PeerCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class PeerUpdate(BaseModel):
|
||||||
|
public_key: str | None = Field(default=None, max_length=44)
|
||||||
|
host_id: uuid.UUID | None = None
|
||||||
|
clear_host: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PeerRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
ip: str
|
||||||
|
public_key: str | None
|
||||||
|
host_id: uuid.UUID | None
|
||||||
|
host_name: str | None
|
||||||
|
connected: bool
|
||||||
|
last_handshake_at: datetime | None
|
||||||
|
rx_bytes: int
|
||||||
|
tx_bytes: int
|
||||||
|
remote_endpoint: str | None
|
||||||
@@ -7,6 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
class UserRole(str, Enum):
|
class UserRole(str, Enum):
|
||||||
admin = "admin"
|
admin = "admin"
|
||||||
|
# Rolls out and operates (rules, NAT, VPN, maintenance, tokens of its own) but cannot
|
||||||
|
# change who may access OPNexus or where it sends data (users, settings, hosts).
|
||||||
|
operator = "operator"
|
||||||
read_only = "read_only"
|
read_only = "read_only"
|
||||||
|
|
||||||
|
|
||||||
@@ -36,3 +39,4 @@ class UserRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login_at: datetime | None
|
last_login_at: datetime | None
|
||||||
totp_enabled: bool = False
|
totp_enabled: bool = False
|
||||||
|
auth_source: str = "local"
|
||||||
@@ -18,7 +18,7 @@ from app.models.host import OpnsenseHost
|
|||||||
from app.models.alias import FirewallAlias
|
from app.models.alias import FirewallAlias
|
||||||
from app.models.alias_deployment import AliasDeployment
|
from app.models.alias_deployment import AliasDeployment
|
||||||
from app.services.audit_service import tracked_rollback
|
from app.services.audit_service import tracked_rollback
|
||||||
from app.services import opnsense_client
|
from app.services import opnsense_client, variables_service
|
||||||
from app.services.deploy_flow import push_to_host
|
from app.services.deploy_flow import push_to_host
|
||||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||||
|
|
||||||
@@ -27,7 +27,12 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(alias: FirewallAlias) -> dict:
|
def _build_payload(alias: FirewallAlias, host: OpnsenseHost | None = None) -> dict:
|
||||||
|
payload = _base_payload(alias)
|
||||||
|
return variables_service.resolve(payload, host) if host is not None else payload
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(alias: FirewallAlias) -> dict:
|
||||||
return {
|
return {
|
||||||
"enabled": "1" if alias.enabled else "0",
|
"enabled": "1" if alias.enabled else "0",
|
||||||
"name": alias.name,
|
"name": alias.name,
|
||||||
@@ -38,7 +43,7 @@ def _build_payload(alias: FirewallAlias) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: OpnsenseHost) -> AliasDeployment:
|
async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: OpnsenseHost) -> AliasDeployment:
|
||||||
payload = _build_payload(alias)
|
payload = _build_payload(alias, host)
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id)
|
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id)
|
||||||
)
|
)
|
||||||
@@ -79,6 +84,7 @@ async def deploy_alias(db: AsyncSession, alias: FirewallAlias, hosts: list[Opnse
|
|||||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||||
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
||||||
await ensure_no_maintenance(db, hosts)
|
await ensure_no_maintenance(db, hosts)
|
||||||
|
variables_service.ensure_resolvable(_build_payload, alias, hosts) # before anything is written
|
||||||
deployments = [await _deploy_to_host(db, alias, host) for host in hosts]
|
deployments = [await _deploy_to_host(db, alias, host) for host in hosts]
|
||||||
await db.flush()
|
await db.flush()
|
||||||
for host, deployment in zip(hosts, deployments):
|
for host, deployment in zip(hosts, deployments):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import json
|
|||||||
|
|
||||||
from app.models.policy import AliasRevision
|
from app.models.policy import AliasRevision
|
||||||
from app.services.alias_deploy_service import _build_payload
|
from app.services.alias_deploy_service import _build_payload
|
||||||
|
from app.services import variables_service
|
||||||
from app.services.policy_service import effective_hosts
|
from app.services.policy_service import effective_hosts
|
||||||
|
|
||||||
ALIAS_FIELDS = ("name", "type", "content", "description", "enabled")
|
ALIAS_FIELDS = ("name", "type", "content", "description", "enabled")
|
||||||
@@ -28,13 +29,17 @@ def revision(db, alias, note):
|
|||||||
|
|
||||||
|
|
||||||
def preview(alias):
|
def preview(alias):
|
||||||
payload = _build_payload(alias)
|
|
||||||
deployments = {d.host_id: d for d in alias.deployments}
|
deployments = {d.host_id: d for d in alias.deployments}
|
||||||
hosts = effective_hosts(alias)
|
hosts = effective_hosts(alias)
|
||||||
blocked = any(d.status in ("pending", "revert_failed") for d in alias.deployments)
|
blocked = any(d.status in ("pending", "revert_failed") for d in alias.deployments)
|
||||||
rows = []
|
rows = []
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
dep = deployments.get(host.id)
|
dep = deployments.get(host.id)
|
||||||
|
try:
|
||||||
|
payload = _build_payload(alias, host)
|
||||||
|
except variables_service.MissingVariables as exc:
|
||||||
|
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||||
|
continue
|
||||||
before = dep.last_payload if dep else None
|
before = dep.last_payload if dep else None
|
||||||
action = "create" if not dep or not dep.opnsense_alias_uuid else "update"
|
action = "create" if not dep or not dep.opnsense_alias_uuid else "update"
|
||||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||||
@@ -61,6 +66,8 @@ def preview(alias):
|
|||||||
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
||||||
if any(r["action"] == "retained" for r in rows):
|
if any(r["action"] == "retained" for r in rows):
|
||||||
warnings.append("Frühere Ziele behalten diesen Alias, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
warnings.append("Frühere Ziele behalten diesen Alias, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
||||||
|
if any(r["action"] == "missing_variable" for r in rows):
|
||||||
|
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||||
if any(r["action"] == "disabled" for r in rows):
|
if any(r["action"] == "disabled" for r in rows):
|
||||||
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
||||||
if any(d.status == "error" for d in alias.deployments):
|
if any(d.status == "error" for d in alias.deployments):
|
||||||
@@ -71,7 +78,7 @@ def preview(alias):
|
|||||||
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in alias.deployments], key=lambda row: row[0])}
|
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in alias.deployments], key=lambda row: row[0])}
|
||||||
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
return {"token": token, "hosts": rows, "warnings": warnings,
|
return {"token": token, "hosts": rows, "warnings": warnings,
|
||||||
"can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows),
|
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||||
|
|
||||||
|
|
||||||
@@ -79,12 +86,11 @@ def workflow(alias, deployments):
|
|||||||
hosts = effective_hosts(alias)
|
hosts = effective_hosts(alias)
|
||||||
active = [h for h in hosts if h.enabled]
|
active = [h for h in hosts if h.enabled]
|
||||||
by_host = {d.host_id: d for d in deployments}
|
by_host = {d.host_id: d for d in deployments}
|
||||||
payload = _build_payload(alias)
|
|
||||||
target_ids = {h.id for h in hosts}
|
target_ids = {h.id for h in hosts}
|
||||||
counts = {
|
counts = {
|
||||||
"targets": len(hosts),
|
"targets": len(hosts),
|
||||||
"active_targets": len(active),
|
"active_targets": len(active),
|
||||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload),
|
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, alias, h)),
|
||||||
"pending": sum(d.status == "pending" for d in deployments),
|
"pending": sum(d.status == "pending" for d in deployments),
|
||||||
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
||||||
"retained": sum(d.host_id not in target_ids for d in deployments),
|
"retained": sum(d.host_id not in target_ids for d in deployments),
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ _SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
|
|||||||
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
|
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
|
||||||
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
|
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
|
||||||
# notification settings the webhook URL/SMTP data): admin scope only.
|
# notification settings the webhook URL/SMTP data): admin scope only.
|
||||||
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)")
|
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)|^/api/settings/|^/api/overlay/peers/[^/]+/instructions$")
|
||||||
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
|
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
|
||||||
_MCP_PATH = "/api/mcp"
|
_MCP_PATH = "/api/mcp"
|
||||||
_ADMIN_ONLY_WRITES = re.compile(
|
_ADMIN_ONLY_WRITES = re.compile(
|
||||||
r"^/api/(users|settings|system|notifications)(/|$)"
|
r"^/api/(users|settings|system|notifications|overlay)(/|$)"
|
||||||
r"|^/api/logs/settings$"
|
r"|^/api/logs/settings$"
|
||||||
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
|
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
|
||||||
)
|
)
|
||||||
@@ -74,6 +74,37 @@ def denies(scope: str, method: str, path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def operator_denies(method: str, path: str) -> bool:
|
||||||
|
"""True if an `operator` account must not make this request: the writes that
|
||||||
|
change who may access OPNexus or where it sends data. (Their own theme at
|
||||||
|
PUT /api/settings stays open; account endpoints are not matched here.)"""
|
||||||
|
return method not in _SAFE_METHODS and path != "/api/settings" and bool(_ADMIN_ONLY_WRITES.match(path))
|
||||||
|
|
||||||
|
|
||||||
|
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
|
||||||
|
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
|
||||||
|
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
|
||||||
|
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
|
||||||
|
_ROLLOUT_POST = re.compile(
|
||||||
|
r"/(deploy|push|sign|poll|register|reassert-ordering"
|
||||||
|
r"|deployments/[^/]+/(remove|retry-rollback)"
|
||||||
|
r"|maintenance/(execute|carp-execute|schedule)"
|
||||||
|
r"|network/changes"
|
||||||
|
r"|services/[^/]+/(start|stop|restart))$"
|
||||||
|
)
|
||||||
|
_ROLLOUT_DELETE = re.compile(
|
||||||
|
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
|
||||||
|
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
|
||||||
|
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_rollout(method: str, path: str) -> bool:
|
||||||
|
if method == "POST":
|
||||||
|
return bool(_ROLLOUT_POST.search(path))
|
||||||
|
return method == "DELETE" and bool(_ROLLOUT_DELETE.match(path))
|
||||||
|
|
||||||
|
|
||||||
def bearer_from_header(header: str) -> str | None:
|
def bearer_from_header(header: str) -> str | None:
|
||||||
"""The token of an ``Authorization: Bearer opx_...`` header, else None."""
|
"""The token of an ``Authorization: Bearer opx_...`` header, else None."""
|
||||||
scheme, _, value = header.partition(" ")
|
scheme, _, value = header.partition(" ")
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Ships new audit events to syslog (RFC 5424, UDP / TCP / TLS) and/or a webhook.
|
||||||
|
|
||||||
|
A poll loop (main.py) calls ``run_once``: events after the stored cursor, oldest
|
||||||
|
first, in batches. A channel failing leaves the cursor where it is, so the same
|
||||||
|
events are tried again next round (at-least-once; every event carries its id so a
|
||||||
|
receiver can de-duplicate). If both channels are on, both must succeed to advance.
|
||||||
|
|
||||||
|
What is sent: who, what kind/action/status, which entity and host, the message and
|
||||||
|
a correlation id. NOT ``details`` — those hold before/after payloads of firewall
|
||||||
|
objects, which have no business in a log aggregator.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import and_, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.audit_export import AuditExportSettings
|
||||||
|
from app.services import notification_secret_store
|
||||||
|
from app.version import APP_VERSION
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SINGLETON_ID = 1
|
||||||
|
BATCH = 200
|
||||||
|
SECRET_NAME = "audit_webhook_signing_secret"
|
||||||
|
_FACILITY_LOCAL0 = 16
|
||||||
|
_BAD_STATUSES = {"denied", "rejected", "error", "revert_failed", "failed"}
|
||||||
|
|
||||||
|
|
||||||
|
class ExportError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings(db: AsyncSession) -> AuditExportSettings:
|
||||||
|
row = await db.get(AuditExportSettings, SINGLETON_ID)
|
||||||
|
if row is None:
|
||||||
|
row = AuditExportSettings(
|
||||||
|
id=SINGLETON_ID, syslog_enabled=False, syslog_port=514, syslog_protocol="udp",
|
||||||
|
webhook_enabled=False, webhook_signing_secret_set=False,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def event_dict(event: AuditEvent) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(event.id), "time": event.created_at.isoformat(), "correlation_id": event.correlation_id,
|
||||||
|
"actor": event.actor, "kind": event.kind, "action": event.action, "status": event.status,
|
||||||
|
"entity_id": event.entity_id, "entity_name": event.entity_name,
|
||||||
|
"host_id": event.host_id, "host_name": event.host_name, "message": event.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def syslog_line(event: dict, hostname: str = "opnexus") -> bytes:
|
||||||
|
"""RFC 5424: <PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID - MSG (JSON)."""
|
||||||
|
severity = 4 if event["status"] in _BAD_STATUSES else 6
|
||||||
|
pri = _FACILITY_LOCAL0 * 8 + severity
|
||||||
|
return f"<{pri}>1 {event['time']} {hostname} opnexus - AUDIT - {json.dumps(event, ensure_ascii=False)}".encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_tcp(lines: list[bytes]) -> bytes:
|
||||||
|
"""RFC 6587 octet counting: '<len> <message>' per message."""
|
||||||
|
return b"".join(str(len(line)).encode() + b" " + line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_syslog_sync(row: AuditExportSettings, lines: list[bytes]) -> None:
|
||||||
|
host, port = row.syslog_host, row.syslog_port
|
||||||
|
if row.syslog_protocol == "udp":
|
||||||
|
with socket.socket(socket.AF_INET6 if ":" in (host or "") else socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||||
|
for line in lines:
|
||||||
|
sock.sendto(line[:8000], (host, port))
|
||||||
|
return
|
||||||
|
with socket.create_connection((host, port), timeout=settings.opnsense_timeout) as raw:
|
||||||
|
if row.syslog_protocol == "tls":
|
||||||
|
context = ssl.create_default_context(cafile=settings.smtp_ca_file or None)
|
||||||
|
with context.wrap_socket(raw, server_hostname=host) as tls:
|
||||||
|
tls.sendall(_frame_tcp(lines))
|
||||||
|
else:
|
||||||
|
raw.sendall(_frame_tcp(lines))
|
||||||
|
|
||||||
|
|
||||||
|
async def send_syslog(row: AuditExportSettings, events: list[dict]) -> None:
|
||||||
|
if not row.syslog_host:
|
||||||
|
raise ExportError("Kein Syslog-Ziel konfiguriert")
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_send_syslog_sync, row, [syslog_line(e) for e in events])
|
||||||
|
except (OSError, ssl.SSLError) as exc:
|
||||||
|
raise ExportError(f"Syslog-Zustellung fehlgeschlagen ({type(exc).__name__})") from None
|
||||||
|
|
||||||
|
|
||||||
|
async def send_webhook(row: AuditExportSettings, events: list[dict]) -> None:
|
||||||
|
if not row.webhook_url:
|
||||||
|
raise ExportError("Keine Webhook-URL konfiguriert")
|
||||||
|
raw = json.dumps({"source": "opnexus", "version": APP_VERSION, "events": events}, ensure_ascii=False).encode()
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if row.webhook_signing_secret_set:
|
||||||
|
secret = notification_secret_store.read_secret(SECRET_NAME)
|
||||||
|
if not secret:
|
||||||
|
raise ExportError("Signaturschlüssel ist konfiguriert, aber nicht lesbar – bitte neu setzen")
|
||||||
|
headers["X-OPNexus-Signature"] = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=settings.opnsense_timeout) as client:
|
||||||
|
(await client.post(row.webhook_url, content=raw, headers=headers)).raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
# The URL often is the credential: never put it in a message.
|
||||||
|
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
|
||||||
|
raise ExportError(f"Webhook-Zustellung fehlgeschlagen ({reason})") from None
|
||||||
|
|
||||||
|
|
||||||
|
def active(row: AuditExportSettings) -> bool:
|
||||||
|
return bool((row.syslog_enabled and row.syslog_host) or (row.webhook_enabled and row.webhook_url))
|
||||||
|
|
||||||
|
|
||||||
|
async def _ship(row: AuditExportSettings, events: list[dict]) -> None:
|
||||||
|
if row.syslog_enabled and row.syslog_host:
|
||||||
|
await send_syslog(row, events)
|
||||||
|
if row.webhook_enabled and row.webhook_url:
|
||||||
|
await send_webhook(row, events)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_once(db: AsyncSession, lag_seconds: float = 3.0) -> int:
|
||||||
|
"""Ship one batch; returns how many events went out (0 when idle or failing).
|
||||||
|
Only events at least ``lag_seconds`` old are shipped: an event is stamped when it
|
||||||
|
is created, a moment before its transaction commits, and the cursor must not
|
||||||
|
jump past one that becomes visible a little later."""
|
||||||
|
row = await get_settings(db)
|
||||||
|
if not active(row):
|
||||||
|
return 0
|
||||||
|
if row.cursor_created_at is None:
|
||||||
|
# First round after enabling: start from now, do not replay history.
|
||||||
|
row.cursor_created_at = datetime.now(timezone.utc)
|
||||||
|
row.cursor_id = None
|
||||||
|
await db.commit()
|
||||||
|
return 0
|
||||||
|
after = AuditEvent.created_at > row.cursor_created_at
|
||||||
|
if row.cursor_id:
|
||||||
|
after = or_(after, and_(AuditEvent.created_at == row.cursor_created_at, AuditEvent.id > uuid.UUID(row.cursor_id)))
|
||||||
|
ripe = AuditEvent.created_at <= datetime.now(timezone.utc) - timedelta(seconds=lag_seconds)
|
||||||
|
# Ordering by (created_at, id) must match the cursor comparison above.
|
||||||
|
events = (await db.scalars(select(AuditEvent).where(after, ripe).order_by(AuditEvent.created_at, AuditEvent.id).limit(BATCH))).all()
|
||||||
|
if not events:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
await _ship(row, [event_dict(e) for e in events])
|
||||||
|
except ExportError as exc:
|
||||||
|
row.last_error, row.last_error_at = str(exc)[:300], datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
logger.warning("Audit-Export: %s", exc)
|
||||||
|
return 0
|
||||||
|
row.cursor_created_at, row.cursor_id = events[-1].created_at, str(events[-1].id)
|
||||||
|
row.last_success_at, row.last_error = datetime.now(timezone.utc), None
|
||||||
|
await db.commit()
|
||||||
|
return len(events)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_test(row: AuditExportSettings) -> None:
|
||||||
|
event = {
|
||||||
|
"id": "00000000-0000-0000-0000-000000000000", "time": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"correlation_id": "test", "actor": "opnexus", "kind": "test", "action": "test", "status": "accepted",
|
||||||
|
"entity_id": None, "entity_name": None, "host_id": None, "host_name": None,
|
||||||
|
"message": "Dies ist ein Test des Audit-Exports von OPNexus.",
|
||||||
|
}
|
||||||
|
if not (row.syslog_enabled or row.webhook_enabled):
|
||||||
|
raise ExportError("Kein Kanal eingeschaltet")
|
||||||
|
await _ship(row, [event])
|
||||||
@@ -88,7 +88,10 @@ def deployment_history(session, flush_context, instances):
|
|||||||
if entity is not None and action == "deploy":
|
if entity is not None and action == "deploy":
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
builder = import_module(f"app.services.{kind}_deploy_service")._build_payload
|
builder = import_module(f"app.services.{kind}_deploy_service")._build_payload
|
||||||
details["requested_payload"] = builder(entity)
|
try:
|
||||||
|
details["requested_payload"] = builder(entity, host) if host is not None else builder(entity)
|
||||||
|
except Exception: # a missing variable must not break the audit write; record the unresolved intent instead
|
||||||
|
details["requested_payload"] = builder(entity)
|
||||||
session.add(make_event(kind=kind, action=action, status="removed" if deleted else dep.status,
|
session.add(make_event(kind=kind, action=action, status="removed" if deleted else dep.status,
|
||||||
entity_id=str(entity_id), entity_name=(getattr(entity, "name", None) or getattr(entity, "description", None)),
|
entity_id=str(entity_id), entity_name=(getattr(entity, "name", None) or getattr(entity, "description", None)),
|
||||||
host_id=str(dep.host_id), host_name=host.name if host else None,
|
host_id=str(dep.host_id), host_name=host.name if host else None,
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ async def create_session(
|
|||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
ip_address: str | None = None,
|
ip_address: str | None = None,
|
||||||
verified_hash: str | None = None,
|
verified_hash: str | None = None,
|
||||||
|
lifetime_seconds: int | None = None,
|
||||||
) -> tuple[str, datetime]:
|
) -> tuple[str, datetime]:
|
||||||
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
|
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
|
||||||
|
|
||||||
@@ -253,7 +254,7 @@ async def create_session(
|
|||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
||||||
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
|
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
|
||||||
expires_at = _now() + timedelta(seconds=settings.session_lifetime)
|
expires_at = _now() + timedelta(seconds=lifetime_seconds or settings.session_lifetime)
|
||||||
session = Session(
|
session = Session(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
token_hash=_hash_token(token),
|
token_hash=_hash_token(token),
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Client-side encryption of config backups before they leave the server.
|
||||||
|
|
||||||
|
File format (``.opxbak``): ``OPXBAK1`` | salt (16) | nonce (12) | AES-256-GCM ciphertext+tag.
|
||||||
|
The key is scrypt(passphrase, salt). The header is authenticated as associated data, so a
|
||||||
|
file cannot be re-labelled. The passphrase never leaves OPNexus' secret store; without it
|
||||||
|
an offsite copy is unreadable, which is the point — and also why it must be kept somewhere
|
||||||
|
else too (see the handbook). Decrypt with ``python -m app.backup_decrypt``.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||||
|
|
||||||
|
MAGIC = b"OPXBAK1"
|
||||||
|
_SALT, _NONCE = 16, 12
|
||||||
|
# scrypt cost: ~64 MiB, well above interactive defaults; a backup is encrypted rarely.
|
||||||
|
_N, _R, _P = 2**16, 8, 1
|
||||||
|
|
||||||
|
|
||||||
|
class DecryptError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _key(passphrase: str, salt: bytes) -> bytes:
|
||||||
|
return Scrypt(salt=salt, length=32, n=_N, r=_R, p=_P).derive(passphrase.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext: bytes, passphrase: str) -> bytes:
|
||||||
|
salt, nonce = os.urandom(_SALT), os.urandom(_NONCE)
|
||||||
|
header = MAGIC + salt + nonce
|
||||||
|
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, plaintext, header)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(blob: bytes, passphrase: str) -> bytes:
|
||||||
|
header_len = len(MAGIC) + _SALT + _NONCE
|
||||||
|
if len(blob) < header_len + 16 or not blob.startswith(MAGIC):
|
||||||
|
raise DecryptError("Keine OPNexus-Backup-Datei (Kopfzeile fehlt).")
|
||||||
|
header, body = blob[:header_len], blob[header_len:]
|
||||||
|
salt = header[len(MAGIC): len(MAGIC) + _SALT]
|
||||||
|
nonce = header[len(MAGIC) + _SALT:]
|
||||||
|
try:
|
||||||
|
return AESGCM(_key(passphrase, salt)).decrypt(nonce, body, header)
|
||||||
|
except InvalidTag:
|
||||||
|
raise DecryptError("Falsche Passphrase oder beschädigte Datei.") from None
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Readable, redacted diff between two stored config backups of the same host.
|
||||||
|
|
||||||
|
Both files are parsed (defusedxml), the volatile ``revision`` block is dropped, and every leaf that
|
||||||
|
looks like a secret (password, key, token, PSK, community...) or is a long blob (certificates,
|
||||||
|
private keys) is replaced by a short hash. A changed secret therefore still shows up as a change,
|
||||||
|
but its value never leaves the server. Output is a unified diff, capped in size.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from xml.etree.ElementTree import Element, indent, tostring
|
||||||
|
|
||||||
|
import defusedxml.ElementTree as safe_ET
|
||||||
|
|
||||||
|
MAX_LINES = 3000
|
||||||
|
MAX_BYTES = 32 * 1024 * 1024
|
||||||
|
_SECRET = re.compile(r"pass|pwd|psk|secret|key|token|prv|hash|community|bindpw|privpass|otp|seed|cred", re.I)
|
||||||
|
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
|
||||||
|
_BLOB = 120
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidConfig(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(root: Element, salt: bytes) -> None:
|
||||||
|
for el in root.iter():
|
||||||
|
if len(el) == 0 and el.text and el.text.strip():
|
||||||
|
text = el.text.strip()
|
||||||
|
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
|
||||||
|
# Salted per diff: both sides of one diff share the salt (so a change shows), but the value cannot be
|
||||||
|
# looked up in a dictionary or compared across hosts/requests.
|
||||||
|
el.text = "‹" + hmac.new(salt, text.encode(), hashlib.sha256).hexdigest()[:12] + "›"
|
||||||
|
|
||||||
|
|
||||||
|
def normalise(xml_bytes: bytes, salt: bytes) -> list[str]:
|
||||||
|
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
|
||||||
|
raise InvalidConfig("leer oder zu groß")
|
||||||
|
try:
|
||||||
|
root = safe_ET.fromstring(xml_bytes)
|
||||||
|
except Exception as exc: # defusedxml raises several parse/forbidden-entity types
|
||||||
|
raise InvalidConfig(type(exc).__name__) from None
|
||||||
|
if root.tag != "opnsense":
|
||||||
|
raise InvalidConfig("keine OPNsense-Konfiguration")
|
||||||
|
for volatile in root.findall("revision"):
|
||||||
|
root.remove(volatile)
|
||||||
|
_redact(root, salt)
|
||||||
|
indent(root, space=" ")
|
||||||
|
return tostring(root, encoding="unicode").splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def diff(old: bytes, new: bytes) -> dict:
|
||||||
|
salt = os.urandom(16)
|
||||||
|
a, b = normalise(old, salt), normalise(new, salt)
|
||||||
|
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
|
||||||
|
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||||||
|
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||||||
|
return {"identical": not lines, "added": added, "removed": removed, "truncated": len(lines) > MAX_LINES, "lines": lines[:MAX_LINES]}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Scheduled config backups: take, keep the newest N, copy encrypted offsite.
|
||||||
|
|
||||||
|
Called by a poll loop (main.py). Per enabled host a new snapshot is taken when the newest one
|
||||||
|
is older than the interval; the oldest beyond ``keep_count`` are deleted. When an offsite target
|
||||||
|
is set, every local snapshot that is not offsite yet is encrypted (backup_crypto) and uploaded
|
||||||
|
(s3_client); the ledger ``backup_uploads`` remembers what is already there. Nothing is ever
|
||||||
|
deleted offsite: use the bucket's lifecycle rules for that.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.backup_settings import BackupSettings, BackupUpload
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.services import backup_crypto, backup_store, notification_secret_store
|
||||||
|
from app.services import opnsense_client as client
|
||||||
|
from app.services.opnsense_client import OpnsenseAPIError
|
||||||
|
from app.services.s3_client import S3Client, S3Error
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SINGLETON_ID = 1
|
||||||
|
S3_SECRET, PASSPHRASE = "backup_s3_secret", "backup_passphrase"
|
||||||
|
MAX_UPLOADS_PER_RUN = 20
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings(db: AsyncSession) -> BackupSettings:
|
||||||
|
row = await db.get(BackupSettings, SINGLETON_ID)
|
||||||
|
if row is None:
|
||||||
|
row = BackupSettings(
|
||||||
|
id=SINGLETON_ID, scheduled_enabled=True, interval_hours=24, keep_count=30, offsite_enabled=False,
|
||||||
|
s3_region="us-east-1", s3_prefix="opnexus", s3_path_style=True, s3_secret_set=False, passphrase_set=False,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def offsite_ready(cfg: BackupSettings) -> bool:
|
||||||
|
return bool(cfg.offsite_enabled and cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and cfg.s3_secret_set and cfg.passphrase_set)
|
||||||
|
|
||||||
|
|
||||||
|
def s3_for(cfg: BackupSettings) -> S3Client:
|
||||||
|
secret = notification_secret_store.read_secret(S3_SECRET)
|
||||||
|
if not (cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and secret):
|
||||||
|
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten)")
|
||||||
|
return S3Client(endpoint=cfg.s3_endpoint, region=cfg.s3_region, bucket=cfg.s3_bucket, access_key=cfg.s3_access_key,
|
||||||
|
secret_key=secret, path_style=cfg.s3_path_style)
|
||||||
|
|
||||||
|
|
||||||
|
def remote_key(cfg: BackupSettings, host: OpnsenseHost, snapshot_id: str) -> str:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", host.name).strip("-") or "host"
|
||||||
|
prefix = cfg.s3_prefix.strip("/")
|
||||||
|
return "/".join(p for p in (prefix, f"{safe}-{str(host.id)[:8]}", snapshot_id.removesuffix(".xml") + ".opxbak") if p)
|
||||||
|
|
||||||
|
|
||||||
|
async def _take(host: OpnsenseHost) -> str:
|
||||||
|
content = await client.download_backup(host)
|
||||||
|
if b"<opnsense" not in content[:4096]:
|
||||||
|
raise OpnsenseAPIError("Antwort ist keine OPNsense-Konfiguration")
|
||||||
|
return backup_store.save_snapshot(host.id, content)
|
||||||
|
|
||||||
|
|
||||||
|
async def _prune(db: AsyncSession, host: OpnsenseHost, keep: int) -> int:
|
||||||
|
old = backup_store.list_snapshots(host.id)[keep:]
|
||||||
|
for snapshot in old:
|
||||||
|
backup_store.delete_snapshot(host.id, snapshot["id"])
|
||||||
|
if old:
|
||||||
|
rows = (await db.scalars(select(BackupUpload).where(BackupUpload.host_id == host.id, BackupUpload.snapshot_id.in_([s["id"] for s in old])))).all()
|
||||||
|
for row in rows:
|
||||||
|
await db.delete(row)
|
||||||
|
return len(old)
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_pending(db: AsyncSession, cfg: BackupSettings, hosts: list[OpnsenseHost]) -> int:
|
||||||
|
"""Encrypt and upload local snapshots that are not offsite yet (newest first, bounded per run)."""
|
||||||
|
passphrase = notification_secret_store.read_secret(PASSPHRASE)
|
||||||
|
if not passphrase:
|
||||||
|
raise S3Error("Passphrase fehlt oder ist nicht lesbar – bitte neu setzen")
|
||||||
|
s3 = s3_for(cfg)
|
||||||
|
done, budget = 0, MAX_UPLOADS_PER_RUN
|
||||||
|
for host in hosts:
|
||||||
|
have = set((await db.scalars(select(BackupUpload.snapshot_id).where(BackupUpload.host_id == host.id))).all())
|
||||||
|
for snapshot in backup_store.list_snapshots(host.id):
|
||||||
|
if snapshot["id"] in have:
|
||||||
|
continue
|
||||||
|
if budget == 0:
|
||||||
|
return done
|
||||||
|
content = backup_store.read_snapshot(host.id, snapshot["id"])
|
||||||
|
if content is None:
|
||||||
|
continue
|
||||||
|
key = remote_key(cfg, host, snapshot["id"])
|
||||||
|
await s3.put(key, backup_crypto.encrypt(content, passphrase))
|
||||||
|
db.add(BackupUpload(host_id=host.id, snapshot_id=snapshot["id"], remote_key=key, uploaded_at=datetime.now(timezone.utc)))
|
||||||
|
await db.commit()
|
||||||
|
budget -= 1
|
||||||
|
done += 1
|
||||||
|
return done
|
||||||
|
|
||||||
|
|
||||||
|
async def run_due(db: AsyncSession, *, force: bool = False) -> dict:
|
||||||
|
cfg = await get_settings(db)
|
||||||
|
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
|
||||||
|
taken = failed = pruned = 0
|
||||||
|
if cfg.scheduled_enabled or force:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for host in hosts:
|
||||||
|
snapshots = backup_store.list_snapshots(host.id)
|
||||||
|
if not force and snapshots and now - snapshots[0]["created_at"] < timedelta(hours=cfg.interval_hours):
|
||||||
|
pruned += await _prune(db, host, cfg.keep_count)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await _take(host)
|
||||||
|
taken += 1
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
failed += 1
|
||||||
|
logger.warning("Geplantes Backup von %s fehlgeschlagen: %s", host.name, exc)
|
||||||
|
pruned += await _prune(db, host, cfg.keep_count)
|
||||||
|
cfg.last_run_at = now
|
||||||
|
uploaded = 0
|
||||||
|
if cfg.offsite_enabled:
|
||||||
|
try:
|
||||||
|
if not offsite_ready(cfg):
|
||||||
|
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten, Passphrase)")
|
||||||
|
uploaded = await upload_pending(db, cfg, hosts)
|
||||||
|
cfg.last_offsite_error = None
|
||||||
|
if uploaded or cfg.last_offsite_ok_at is None:
|
||||||
|
cfg.last_offsite_ok_at = datetime.now(timezone.utc)
|
||||||
|
except S3Error as exc:
|
||||||
|
cfg.last_offsite_error = str(exc)[:300]
|
||||||
|
logger.warning("Offsite-Backup: %s", exc)
|
||||||
|
await db.commit()
|
||||||
|
return {"hosts": len(hosts), "taken": taken, "failed": failed, "pruned": pruned, "uploaded": uploaded}
|
||||||
@@ -42,7 +42,8 @@ def list_snapshots(host_id: uuid.UUID) -> list[dict]:
|
|||||||
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
})
|
})
|
||||||
return sorted(items, key=lambda i: i["created_at"], reverse=True)
|
# Names are sortable timestamps: they break ties between files written within one mtime tick.
|
||||||
|
return sorted(items, key=lambda i: (i["created_at"], i["id"]), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
||||||
@@ -53,3 +54,11 @@ def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
|||||||
if backup_id != path.name or not path.is_file():
|
if backup_id != path.name or not path.is_file():
|
||||||
return None
|
return None
|
||||||
return path.read_bytes()
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_snapshot(host_id: uuid.UUID, backup_id: str) -> bool:
|
||||||
|
path = _host_dir(host_id) / backup_id
|
||||||
|
if backup_id != path.name or not path.is_file():
|
||||||
|
return False
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
@@ -12,6 +12,7 @@ in ``filter/rule`` (legacy). Both are normalised to the same shape below.
|
|||||||
decided. The score starts at 100 and loses a penalty per failed check, so one
|
decided. The score starts at 100 and loses a penalty per failed check, so one
|
||||||
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
@@ -22,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models.compliance_result import ComplianceResult
|
from app.models.compliance_result import ComplianceResult
|
||||||
from app.models.host import OpnsenseHost
|
from app.models.host import OpnsenseHost
|
||||||
from app.services import backup_store
|
from app.services import backup_store, rule_hit_service
|
||||||
from app.services import opnsense_client as client
|
from app.services import opnsense_client as client
|
||||||
from app.services.opnsense_client import OpnsenseAPIError
|
from app.services.opnsense_client import OpnsenseAPIError
|
||||||
|
|
||||||
@@ -31,6 +32,15 @@ BACKUP_MAX_AGE_DAYS = 7
|
|||||||
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
|
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
|
||||||
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
||||||
MAX_EXAMPLES = 5
|
MAX_EXAMPLES = 5
|
||||||
|
# German titles for notifications (the UI translates the check ids itself).
|
||||||
|
TITLES_DE = {
|
||||||
|
"webgui_https": "Web-Oberfläche nur über HTTPS", "ssh_hardened": "SSH abgesichert", "snmp_community": "SNMP-Community nicht „public“",
|
||||||
|
"wan_any_any": "Keine Allow-all-Regel am WAN", "internal_any_any": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
|
||||||
|
"rules_shadowed": "Keine verdeckten Regeln", "rules_duplicate": "Keine doppelten Regeln", "rules_documented": "Regeln sind beschriftet",
|
||||||
|
"rules_unused": "Regeln in Benutzung", "ntp_configured": "Zeitserver gesetzt", "remote_logging": "Logs liegen auch woanders",
|
||||||
|
"ids_enabled": "Einbruchserkennung aktiv", "firmware_current": "Firmware aktuell", "backup_recent": "Aktuelles Backup",
|
||||||
|
}
|
||||||
|
SEVERITIES_DE = {"critical": "kritisch", "high": "hoch", "medium": "mittel", "low": "niedrig", "info": "Hinweis"}
|
||||||
|
|
||||||
|
|
||||||
def grade(score: int) -> str:
|
def grade(score: int) -> str:
|
||||||
@@ -171,7 +181,8 @@ def _listing(items: list[str]) -> dict:
|
|||||||
return {"count": len(items), "examples": items[:MAX_EXAMPLES]}
|
return {"count": len(items), "examples": items[:MAX_EXAMPLES]}
|
||||||
|
|
||||||
|
|
||||||
def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None) -> list[dict]:
|
def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None,
|
||||||
|
unused_rules: dict | None = None) -> list[dict]:
|
||||||
if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip():
|
if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip():
|
||||||
raise ValueError("config.xml missing or too large")
|
raise ValueError("config.xml missing or too large")
|
||||||
root = safe_ET.fromstring(xml_bytes)
|
root = safe_ET.fromstring(xml_bytes)
|
||||||
@@ -212,6 +223,11 @@ def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age
|
|||||||
undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]]
|
undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]]
|
||||||
findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented)))
|
findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented)))
|
||||||
|
|
||||||
|
# Usage (from the pf counters OPNexus has been sampling; None = no data yet)
|
||||||
|
if unused_rules is not None:
|
||||||
|
names = unused_rules["unused"]
|
||||||
|
findings.append(_finding("rules_unused", "low", None if (not names and unused_rules["collecting"]) else not names, **_listing(names)))
|
||||||
|
|
||||||
# Operations
|
# Operations
|
||||||
findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers"))))
|
findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers"))))
|
||||||
syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"]
|
syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"]
|
||||||
@@ -257,14 +273,22 @@ async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
|
|||||||
firmware_status = None
|
firmware_status = None
|
||||||
snapshots = backup_store.list_snapshots(host.id)
|
snapshots = backup_store.list_snapshots(host.id)
|
||||||
age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None
|
age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None
|
||||||
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age)
|
try:
|
||||||
|
await rule_hit_service.sample_host(db, host)
|
||||||
|
except OpnsenseAPIError:
|
||||||
|
pass # usage data is a bonus; the check does not depend on it
|
||||||
|
# Untrusted XML, O(n^2) rule analysis: keep it off the event loop, and treat ANY parse failure as 'invalid'.
|
||||||
|
findings = await asyncio.to_thread(
|
||||||
|
evaluate, xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||||
|
unused_rules=await rule_hit_service.unused_summary(db, host.id),
|
||||||
|
)
|
||||||
row.findings, row.score, row.error = findings, score(findings), None
|
row.findings, row.score, row.error = findings, score(findings), None
|
||||||
except OpnsenseAPIError as exc:
|
except OpnsenseAPIError as exc:
|
||||||
row.findings, row.score, row.error = [], None, "unreachable"
|
row.findings, row.score, row.error = [], None, "unreachable"
|
||||||
row.error_detail = str(exc)[:300]
|
row.error_detail = str(exc)[:300]
|
||||||
except ValueError as exc:
|
except Exception as exc: # ParseError is a SyntaxError, not a ValueError; one bad config must not stop the other hosts
|
||||||
row.findings, row.score, row.error = [], None, "invalid_config"
|
row.findings, row.score, row.error = [], None, "invalid_config"
|
||||||
row.error_detail = str(exc)[:300]
|
row.error_detail = f"{type(exc).__name__}: {exc}"[:300]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ def _extract_carp_status(data: dict | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vip_states(data: dict | None) -> dict[str, str]:
|
||||||
|
"""Per-VHID CARP state ("MASTER", "BACKUP", "DISABLED", "INIT", ...). The host-level carp_status above
|
||||||
|
collapses these, so one VHID that is down on a node is invisible there."""
|
||||||
|
rows = data.get("rows") if isinstance(data, dict) else None
|
||||||
|
return {str(row.get("vhid")): str(row.get("status", "")).upper()
|
||||||
|
for row in rows or [] if isinstance(row, dict) and row.get("vhid") not in (None, "")}
|
||||||
|
|
||||||
|
|
||||||
async def _status_for(host: OpnsenseHost) -> dict:
|
async def _status_for(host: OpnsenseHost) -> dict:
|
||||||
if not host.enabled:
|
if not host.enabled:
|
||||||
return {"status": "disabled", "carp_status": None, "status_detail": None}
|
return {"status": "disabled", "carp_status": None, "status_detail": None}
|
||||||
@@ -43,6 +51,7 @@ async def _status_for(host: OpnsenseHost) -> dict:
|
|||||||
return {
|
return {
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"carp_status": _extract_carp_status(data),
|
"carp_status": _extract_carp_status(data),
|
||||||
|
"carp_vips": _vip_states(data),
|
||||||
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
|
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
|
||||||
"carp_allowed": _truthy(carp.get("allow"), default=True),
|
"carp_allowed": _truthy(carp.get("allow"), default=True),
|
||||||
"carp_demotion": carp.get("demotion"),
|
"carp_demotion": carp.get("demotion"),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ taken before the change is the way back.
|
|||||||
Secrets (the CARP password) are never returned, hashed into the token only, and
|
Secrets (the CARP password) are never returned, hashed into the token only, and
|
||||||
never written to the audit log.
|
never written to the audit log.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
@@ -362,6 +363,20 @@ async def _verify(host: OpnsenseHost, kind: str, action: str, uuid: str | None,
|
|||||||
return False, ["unreadable"]
|
return False, ["unreadable"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _carp_vip_active(host: OpnsenseHost, vhid: str, attempts: int = 5) -> bool:
|
||||||
|
"""True once the firewall reports the VHID as MASTER or BACKUP, i.e. the VIP really is on the interface and
|
||||||
|
taking part in CARP (not DISABLED/INIT). The config can look right while the live VIP is gone."""
|
||||||
|
for attempt in range(attempts):
|
||||||
|
if attempt:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
data = await opnsense_client.get_vip_status(host)
|
||||||
|
rows = data.get("rows") if isinstance(data, dict) else None
|
||||||
|
states = {str(r.get("vhid")): str(r.get("status", "")).upper() for r in rows or [] if isinstance(r, dict)}
|
||||||
|
if states.get(vhid) in ("MASTER", "BACKUP"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
|
async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str, uuid: str | None, fields: dict,
|
||||||
token: str, acknowledged: bool) -> dict:
|
token: str, acknowledged: bool) -> dict:
|
||||||
await ensure_no_maintenance(db, [host])
|
await ensure_no_maintenance(db, [host])
|
||||||
@@ -384,6 +399,12 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
|
|||||||
# VipSettingsController::getVipOverlay blanks address and prefix whenever "network" is missing from
|
# 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).
|
# 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"])
|
send.setdefault("network", plan["_before"]["network"])
|
||||||
|
# VipSettingsController::setItemAction queues "delete the old address" whenever the posted interface differs
|
||||||
|
# from the stored one, and a partial update that leaves "interface" out counts as different. The following
|
||||||
|
# reconfigure then removes the live VIP and, working from a stale address list, never puts it back (found
|
||||||
|
# live 2026-10-20: CARP VIP stuck on DISABLED after a description edit). So repeat the interface as well.
|
||||||
|
if plan["_before"].get("interface"):
|
||||||
|
send.setdefault("interface", plan["_before"]["interface"])
|
||||||
# What the object must look like afterwards: for an edit that is everything it had, plus the change.
|
# 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)
|
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
|
entity = fields.get("name") or fields.get("descr") or plan["_before"].get("name") or plan["_before"].get("descr") or kind
|
||||||
@@ -407,14 +428,27 @@ async def execute(db: AsyncSession, host: OpnsenseHost, kind: str, action: str,
|
|||||||
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details=details)
|
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
|
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)
|
new_uuid = uuid or (result.get("uuid") if isinstance(result, dict) else None)
|
||||||
|
# A description is not wired into anything, so there is nothing to re-apply for it.
|
||||||
|
cosmetic = kind == "vip" and action == "set" and {c["field"] for c in plan["changes"]} <= {"descr"}
|
||||||
|
carp_live = kind == "vip" and action != "del" and expected.get("mode") == "carp" and not cosmetic
|
||||||
try:
|
try:
|
||||||
await opnsense_client.reconfigure_network(host, kind)
|
if not cosmetic:
|
||||||
|
await opnsense_client.reconfigure_network(host, kind)
|
||||||
|
live_ok = True
|
||||||
|
if carp_live:
|
||||||
|
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||||
|
if not live_ok: # one more apply usually brings it back up
|
||||||
|
await opnsense_client.reconfigure_network(host, kind)
|
||||||
|
live_ok = await _carp_vip_active(host, str(expected.get("vhid")))
|
||||||
except OpnsenseAPIError as exc:
|
except OpnsenseAPIError as exc:
|
||||||
# Written but not (provably) applied: say so instead of reporting success.
|
# 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)
|
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
|
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)
|
verified, mismatches = await _verify(host, kind, action, new_uuid, expected)
|
||||||
|
if not live_ok:
|
||||||
|
verified = False
|
||||||
|
mismatches = [*mismatches, "carp_vip_inactive"]
|
||||||
await audit_service.persist(**audit, status="applied" if verified else "unverified",
|
await audit_service.persist(**audit, status="applied" if verified else "unverified",
|
||||||
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
message=None if verified else ", ".join(mismatches)[:500], details=details)
|
||||||
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
|
return {"status": "applied", "uuid": new_uuid, "verified": verified, "mismatches": mismatches, "backup_id": backup_id}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.alias_deployment import AliasDeployment
|
from app.models.alias_deployment import AliasDeployment
|
||||||
|
from app.models.backup_settings import BackupSettings
|
||||||
|
from app.models.compliance_result import ComplianceResult
|
||||||
from app.models.host import OpnsenseHost
|
from app.models.host import OpnsenseHost
|
||||||
from app.models.host_operation import HostOperation
|
from app.models.host_operation import HostOperation
|
||||||
from app.models.one_to_one_deployment import OneToOneDeployment
|
from app.models.one_to_one_deployment import OneToOneDeployment
|
||||||
@@ -16,7 +18,7 @@ from app.models.outbound_nat_deployment import OutboundNatDeployment
|
|||||||
from app.models.port_forward_deployment import PortForwardDeployment
|
from app.models.port_forward_deployment import PortForwardDeployment
|
||||||
from app.models.rule_deployment import RuleDeployment
|
from app.models.rule_deployment import RuleDeployment
|
||||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||||
from app.services import nat_drift_service, network_drift_service, rule_drift_service
|
from app.services import backup_store, nat_drift_service, network_drift_service, rule_drift_service
|
||||||
from app.services.notification_dedup import Problems
|
from app.services.notification_dedup import Problems
|
||||||
from app.services.host_status_service import get_statuses
|
from app.services.host_status_service import get_statuses
|
||||||
from app.services.live_certificates_service import get_live_certificates
|
from app.services.live_certificates_service import get_live_certificates
|
||||||
@@ -234,6 +236,28 @@ async def check_carp_health(db: AsyncSession) -> dict[str, dict]:
|
|||||||
"condition": "no_master",
|
"condition": "no_master",
|
||||||
"host_names": sorted(h.name for h in online),
|
"host_names": sorted(h.name for h in online),
|
||||||
}
|
}
|
||||||
|
problems.update(_carp_vip_problems(cluster_name, online, statuses, split_brain=len(masters) == 2))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _carp_vip_problems(cluster_name: str, online: list[OpnsenseHost], statuses: dict, split_brain: bool) -> dict[str, dict]:
|
||||||
|
"""Per-VHID checks. The host-level role is "MASTER" as soon as one VIP is, so a VIP that is down on a
|
||||||
|
node (a node without the VIP never advertises it, the peer takes it over) looks healthy in the totals."""
|
||||||
|
problems: dict[str, dict] = {}
|
||||||
|
for host in online:
|
||||||
|
vips = statuses.get(host.id, {}).get("carp_vips") or {}
|
||||||
|
down = sorted(v for v, state in vips.items() if state not in ("MASTER", "BACKUP"))
|
||||||
|
if down:
|
||||||
|
problems[f"{cluster_name}:vip_down:{host.name}"] = {
|
||||||
|
"cluster_name": cluster_name, "condition": "vip_down", "host_names": [host.name], "vhids": down,
|
||||||
|
}
|
||||||
|
# One node MASTER on some VHIDs and BACKUP on others: a partial failover. Already covered by the
|
||||||
|
# split-brain alert when both nodes hold a MASTER.
|
||||||
|
if not split_brain and {"MASTER", "BACKUP"} <= set(vips.values()):
|
||||||
|
problems[f"{cluster_name}:role_mismatch:{host.name}"] = {
|
||||||
|
"cluster_name": cluster_name, "condition": "role_mismatch", "host_names": [host.name],
|
||||||
|
"vhids": sorted(v for v, state in vips.items() if state == "MASTER"),
|
||||||
|
}
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
@@ -290,3 +314,52 @@ async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict
|
|||||||
"message": (sched.result or {}).get("error"),
|
"message": (sched.result or {}).get("error"),
|
||||||
}
|
}
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
async def check_backup_stale(db: AsyncSession) -> dict[str, dict]:
|
||||||
|
"""Scheduled backups are on but a host has no recent one: three missed intervals
|
||||||
|
(at least 6 hours) cover a reboot or a short outage without crying wolf."""
|
||||||
|
cfg = await db.get(BackupSettings, 1)
|
||||||
|
if cfg is not None and not cfg.scheduled_enabled:
|
||||||
|
return {}
|
||||||
|
interval = cfg.interval_hours if cfg is not None else 24
|
||||||
|
limit = timedelta(hours=max(3 * interval, 6))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
problems: dict[str, dict] = {}
|
||||||
|
for host in await _enabled_hosts(db):
|
||||||
|
snapshots = backup_store.list_snapshots(host.id)
|
||||||
|
newest = snapshots[0]["created_at"] if snapshots else None
|
||||||
|
if (newest is None and now - host.created_at > limit) or (newest is not None and now - newest > limit):
|
||||||
|
problems[str(host.id)] = {
|
||||||
|
"host_id": str(host.id), "host_name": host.name,
|
||||||
|
"last_backup": newest.isoformat() if newest else "noch nie",
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
async def check_backup_offsite(db: AsyncSession) -> dict[str, dict]:
|
||||||
|
cfg = await db.get(BackupSettings, 1)
|
||||||
|
if cfg is None or not cfg.offsite_enabled or not cfg.last_offsite_error:
|
||||||
|
return {}
|
||||||
|
return {"offsite": {"message": cfg.last_offsite_error}}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_compliance(db: AsyncSession) -> dict[str, dict]:
|
||||||
|
"""Critical and high findings of the last security check, one problem per (host, check); resolved when it passes."""
|
||||||
|
from app.services import compliance_service as cs
|
||||||
|
hosts = {h.id: h for h in await _enabled_hosts(db)}
|
||||||
|
problems: dict[str, dict] = {}
|
||||||
|
for row in (await db.scalars(select(ComplianceResult))).all():
|
||||||
|
host = hosts.get(row.host_id)
|
||||||
|
if host is None or row.error:
|
||||||
|
continue
|
||||||
|
for finding in row.findings:
|
||||||
|
if finding["passed"] is False and finding["severity"] in ("critical", "high"):
|
||||||
|
params = finding.get("params") or {}
|
||||||
|
problems[f"{host.id}:{finding['id']}"] = {
|
||||||
|
"host_id": str(host.id), "host_name": host.name, "check": finding["id"],
|
||||||
|
"befund": cs.TITLES_DE.get(finding["id"], finding["id"]),
|
||||||
|
"schweregrad": cs.SEVERITIES_DE[finding["severity"]],
|
||||||
|
"beispiele": ", ".join(params.get("examples", [])) or None,
|
||||||
|
}
|
||||||
|
return problems
|
||||||
@@ -34,6 +34,9 @@ _LABELS = {
|
|||||||
"network_drift": "Netzwerk-Abweichung im Cluster",
|
"network_drift": "Netzwerk-Abweichung im Cluster",
|
||||||
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
||||||
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
|
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
|
||||||
|
"backup_stale": "Kein aktuelles Backup",
|
||||||
|
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
|
||||||
|
"compliance_finding": "Sicherheitsbefund",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ _CHECKS = [
|
|||||||
("network_drift", "network_drift_enabled", notification_checks.check_network_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),
|
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
|
||||||
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
|
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
|
||||||
|
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
|
||||||
|
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
|
||||||
|
("compliance_finding", "compliance_alerts_enabled", notification_checks.check_compliance),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Event-like problems: when they drop out of the window nothing was "fixed", so no
|
# Event-like problems: when they drop out of the window nothing was "fixed", so no
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
|
|||||||
"smtp_from_address", "smtp_to_addresses",
|
"smtp_from_address", "smtp_to_addresses",
|
||||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
||||||
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
||||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled",
|
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
|
||||||
"poll_interval_seconds",
|
"poll_interval_seconds",
|
||||||
]
|
]
|
||||||
for field in simple_fields:
|
for field in simple_fields:
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""OpenID Connect login (authorization code flow with PKCE) and role mapping.
|
||||||
|
|
||||||
|
What is checked on the ID token, in this order of suspicion: the signature (against the provider's JWKS,
|
||||||
|
asymmetric algorithms only: ``none`` and HMAC are never accepted), ``iss`` equal to the configured issuer,
|
||||||
|
``aud`` containing our client id (and ``azp`` equal to it if there are several audiences), ``exp``/``nbf``,
|
||||||
|
and the ``nonce`` we sent. The role comes from the groups claim (ID token, else userinfo) at EVERY login, so
|
||||||
|
removing someone from the group takes the access away at their next sign-in. Local accounts are never linked
|
||||||
|
to or taken over by an identity: an OIDC user is keyed by ``<issuer>|<sub>`` and a name clash with a local
|
||||||
|
account gets a suffix. Without a mapped group nobody is let in.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWK
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.oidc import OidcLoginState, OidcSettings
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.services import notification_secret_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SINGLETON_ID = 1
|
||||||
|
SECRET_NAME = "oidc_client_secret"
|
||||||
|
STATE_TTL = timedelta(minutes=10)
|
||||||
|
ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"]
|
||||||
|
UNUSABLE_PASSWORD = "!oidc"
|
||||||
|
_ROLE_ORDER = ("admin", "operator", "read_only")
|
||||||
|
|
||||||
|
|
||||||
|
class OidcError(Exception):
|
||||||
|
"""Carries a short, user-safe code (shown on the login page), never provider internals."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, detail: str = ""):
|
||||||
|
super().__init__(detail or code)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings(db: AsyncSession) -> OidcSettings:
|
||||||
|
row = await db.get(OidcSettings, SINGLETON_ID)
|
||||||
|
if row is None:
|
||||||
|
row = OidcSettings(
|
||||||
|
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
|
||||||
|
scopes="openid profile email", groups_claim="groups",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def configured(cfg: OidcSettings) -> bool:
|
||||||
|
return bool(cfg.enabled and cfg.issuer and cfg.client_id and (cfg.admin_group or cfg.operator_group or cfg.read_only_group))
|
||||||
|
|
||||||
|
|
||||||
|
# ── provider metadata (cached) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
_discovery_cache: dict[str, tuple[float, dict]] = {}
|
||||||
|
_jwks_cache: dict[str, tuple[float, dict]] = {}
|
||||||
|
_DISCOVERY_TTL, _JWKS_TTL, _JWKS_MIN_REFETCH = 600, 3600, 60
|
||||||
|
|
||||||
|
|
||||||
|
def clear_caches() -> None:
|
||||||
|
_discovery_cache.clear()
|
||||||
|
_jwks_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_json(url: str, **kw) -> dict:
|
||||||
|
if not url.startswith("https://"):
|
||||||
|
raise OidcError("provider", "Nur https:// wird akzeptiert")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
|
||||||
|
response = await client.get(url, **kw)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
raise OidcError("provider", "Der Identitätsanbieter ist nicht erreichbar oder antwortet unverständlich") from None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise OidcError("provider", "Unerwartete Antwort des Identitätsanbieters")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def discovery(issuer: str) -> dict:
|
||||||
|
cached = _discovery_cache.get(issuer)
|
||||||
|
if cached and time.monotonic() - cached[0] < _DISCOVERY_TTL:
|
||||||
|
return cached[1]
|
||||||
|
doc = await _get_json(issuer.rstrip("/") + "/.well-known/openid-configuration")
|
||||||
|
if doc.get("issuer") != issuer and doc.get("issuer", "").rstrip("/") != issuer.rstrip("/"):
|
||||||
|
raise OidcError("provider", "Der Aussteller in der Discovery-Antwort passt nicht zur eingetragenen URL")
|
||||||
|
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||||
|
if not str(doc.get(key, "")).startswith("https://"):
|
||||||
|
raise OidcError("provider", f"Discovery enthält kein gültiges {key}")
|
||||||
|
_discovery_cache[issuer] = (time.monotonic(), doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
async def _jwk_for(doc: dict, kid: str | None) -> PyJWK:
|
||||||
|
uri = doc["jwks_uri"]
|
||||||
|
for attempt in (0, 1):
|
||||||
|
cached = _jwks_cache.get(uri)
|
||||||
|
stale = cached is None or time.monotonic() - cached[0] > _JWKS_TTL
|
||||||
|
if stale or attempt == 1:
|
||||||
|
if attempt == 1 and cached and time.monotonic() - cached[0] < _JWKS_MIN_REFETCH:
|
||||||
|
break # unknown kid, but we just fetched: do not let a forged kid hammer the provider
|
||||||
|
_jwks_cache[uri] = (time.monotonic(), await _get_json(uri))
|
||||||
|
for key in _jwks_cache[uri][1].get("keys", []):
|
||||||
|
if (kid is None or key.get("kid") == kid) and key.get("use", "sig") == "sig":
|
||||||
|
try:
|
||||||
|
return PyJWK(key)
|
||||||
|
except Exception: # unsupported key type: try the next one
|
||||||
|
continue
|
||||||
|
raise OidcError("token", "Kein passender Signaturschlüssel")
|
||||||
|
|
||||||
|
|
||||||
|
# ── the flow ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _hash(value: str) -> str:
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _challenge(verifier: str) -> str:
|
||||||
|
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> tuple[str, str]:
|
||||||
|
"""Remember a fresh login attempt and return the provider URL to send the browser to."""
|
||||||
|
doc = await discovery(cfg.issuer)
|
||||||
|
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(24), secrets.token_urlsafe(48)
|
||||||
|
await db.execute(delete(OidcLoginState).where(OidcLoginState.created_at < datetime.now(timezone.utc) - STATE_TTL))
|
||||||
|
db.add(OidcLoginState(state_hash=_hash(state), nonce=nonce, code_verifier=verifier, redirect_uri=redirect_uri))
|
||||||
|
await db.commit()
|
||||||
|
query = urlencode({
|
||||||
|
"response_type": "code", "client_id": cfg.client_id, "redirect_uri": redirect_uri, "scope": cfg.scopes,
|
||||||
|
"state": state, "nonce": nonce, "code_challenge": _challenge(verifier), "code_challenge_method": "S256",
|
||||||
|
})
|
||||||
|
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}", state
|
||||||
|
|
||||||
|
|
||||||
|
async def _consume_state(db: AsyncSession, state: str) -> OidcLoginState:
|
||||||
|
row = await db.scalar(select(OidcLoginState).where(OidcLoginState.state_hash == _hash(state)).with_for_update())
|
||||||
|
if row is None:
|
||||||
|
raise OidcError("state", "Unbekannter oder bereits benutzter Anmeldeversuch")
|
||||||
|
created = row.created_at
|
||||||
|
await db.delete(row) # single use, whatever happens next
|
||||||
|
await db.commit()
|
||||||
|
if datetime.now(timezone.utc) - created > STATE_TTL:
|
||||||
|
raise OidcError("state", "Der Anmeldeversuch ist abgelaufen")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def _exchange(cfg: OidcSettings, doc: dict, code: str, state: OidcLoginState) -> dict:
|
||||||
|
data = {"grant_type": "authorization_code", "code": code, "redirect_uri": state.redirect_uri,
|
||||||
|
"client_id": cfg.client_id, "code_verifier": state.code_verifier}
|
||||||
|
secret = notification_secret_store.read_secret(SECRET_NAME) if cfg.client_secret_set else None
|
||||||
|
auth = None
|
||||||
|
if secret:
|
||||||
|
methods = doc.get("token_endpoint_auth_methods_supported") or ["client_secret_basic"]
|
||||||
|
if "client_secret_basic" in methods:
|
||||||
|
auth = httpx.BasicAuth(cfg.client_id, secret)
|
||||||
|
else:
|
||||||
|
data["client_secret"] = secret
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
|
||||||
|
response = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise OidcError("token", f"Token-Endpunkt antwortet mit HTTP {response.status_code}")
|
||||||
|
tokens = response.json()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise OidcError("provider", "Token-Endpunkt nicht erreichbar") from None
|
||||||
|
except ValueError:
|
||||||
|
raise OidcError("token", "Unverständliche Token-Antwort") from None
|
||||||
|
if not isinstance(tokens, dict) or not tokens.get("id_token"):
|
||||||
|
raise OidcError("token", "Die Antwort enthält kein ID-Token")
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_id_token(cfg: OidcSettings, doc: dict, id_token: str, nonce: str) -> dict:
|
||||||
|
try:
|
||||||
|
header = jwt.get_unverified_header(id_token)
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise OidcError("token", "ID-Token unlesbar") from None
|
||||||
|
if header.get("alg") not in ALGORITHMS:
|
||||||
|
raise OidcError("token", f"Nicht erlaubter Signaturalgorithmus {header.get('alg')!r}")
|
||||||
|
key = await _jwk_for(doc, header.get("kid"))
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(
|
||||||
|
id_token, key.key, algorithms=ALGORITHMS, audience=cfg.client_id, issuer=cfg.issuer,
|
||||||
|
options={"require": ["exp", "iss", "aud", "sub"]}, leeway=60,
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise OidcError("token", f"ID-Token ungültig ({type(exc).__name__})") from None
|
||||||
|
audience = claims.get("aud")
|
||||||
|
if isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != cfg.client_id:
|
||||||
|
raise OidcError("token", "azp passt nicht zur Client-ID")
|
||||||
|
if not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
|
||||||
|
raise OidcError("token", "Nonce stimmt nicht")
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def _as_groups(value) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
# Group names may contain spaces ("Network Admins"): a string claim is split on commas and line breaks only.
|
||||||
|
return [g.strip() for g in re.split(r"[,\n]+", value) if g.strip()]
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(g) for g in value]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def role_for(cfg: OidcSettings, groups: list[str]) -> str | None:
|
||||||
|
"""The most privileged role whose group the user is in; None = not allowed in."""
|
||||||
|
wanted = {"admin": cfg.admin_group, "operator": cfg.operator_group, "read_only": cfg.read_only_group}
|
||||||
|
have = {g.lower() for g in groups}
|
||||||
|
for role in _ROLE_ORDER:
|
||||||
|
group = wanted[role]
|
||||||
|
if group and group.strip().lower() in have:
|
||||||
|
return role
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _userinfo(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||||
|
"""The provider's userinfo answer for THIS identity ({} if there is none or it belongs to somebody else)."""
|
||||||
|
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
|
||||||
|
info = await _get_json(doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"})
|
||||||
|
if info.get("sub") == claims.get("sub"): # a userinfo answer for someone else is worthless
|
||||||
|
return info
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
|
||||||
|
if cfg.groups_claim in claims:
|
||||||
|
return _as_groups(claims[cfg.groups_claim])
|
||||||
|
return _as_groups((await _userinfo(doc, claims, access_token)).get(cfg.groups_claim))
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_display_name(doc: dict, claims: dict, access_token: str | None) -> dict:
|
||||||
|
"""Some providers (Authelia) keep preferred_username/email out of the ID token: ask userinfo, else the user would be named
|
||||||
|
after the opaque ``sub``. Best effort: a failing userinfo call must not block a login that already has its role."""
|
||||||
|
if claims.get("preferred_username") or claims.get("email"):
|
||||||
|
return claims
|
||||||
|
try:
|
||||||
|
info = await _userinfo(doc, claims, access_token)
|
||||||
|
except OidcError:
|
||||||
|
return claims
|
||||||
|
return {**claims, **{k: info[k] for k in ("preferred_username", "email") if info.get(k)}}
|
||||||
|
|
||||||
|
|
||||||
|
def _username(claims: dict) -> str:
|
||||||
|
raw = claims.get("preferred_username") or claims.get("email") or claims["sub"]
|
||||||
|
return re.sub(r"[\x00-\x1f]", "", str(raw)).strip()[:140] or "sso-user"
|
||||||
|
|
||||||
|
|
||||||
|
async def provision(db: AsyncSession, cfg: OidcSettings, claims: dict, role: str) -> AdminUser:
|
||||||
|
"""The account for this identity: created on first login, role synced on every login."""
|
||||||
|
subject = f"{cfg.issuer}|{claims['sub']}"[:600]
|
||||||
|
user = await db.scalar(select(AdminUser).where(AdminUser.oidc_subject == subject))
|
||||||
|
if user is None:
|
||||||
|
base, name, n = _username(claims), None, 0
|
||||||
|
while True:
|
||||||
|
name = base if n == 0 else f"{base}~sso{n if n > 1 else ''}"
|
||||||
|
if await db.scalar(select(AdminUser.id).where(AdminUser.username == name)) is None:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
user = AdminUser(username=name, password_hash=UNUSABLE_PASSWORD, role=role, auth_source="oidc", oidc_subject=subject)
|
||||||
|
db.add(user)
|
||||||
|
else:
|
||||||
|
user.role = role
|
||||||
|
await db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def complete(db: AsyncSession, cfg: OidcSettings, code: str, state_value: str) -> AdminUser:
|
||||||
|
"""Callback: state -> tokens -> verified identity -> role -> account. Raises OidcError."""
|
||||||
|
state = await _consume_state(db, state_value)
|
||||||
|
doc = await discovery(cfg.issuer)
|
||||||
|
tokens = await _exchange(cfg, doc, code, state)
|
||||||
|
claims = await _verify_id_token(cfg, doc, tokens["id_token"], state.nonce)
|
||||||
|
role = role_for(cfg, await _groups(cfg, doc, claims, tokens.get("access_token")))
|
||||||
|
if role is None:
|
||||||
|
raise OidcError("no_role", "Kein zugeordnete Gruppe")
|
||||||
|
return await provision(db, cfg, await _with_display_name(doc, claims, tokens.get("access_token")), role)
|
||||||
@@ -9,7 +9,7 @@ from app.models.host import OpnsenseHost
|
|||||||
from app.models.one_to_one import OneToOneRule
|
from app.models.one_to_one import OneToOneRule
|
||||||
from app.models.one_to_one_deployment import OneToOneDeployment
|
from app.models.one_to_one_deployment import OneToOneDeployment
|
||||||
from app.services.audit_service import tracked_rollback
|
from app.services.audit_service import tracked_rollback
|
||||||
from app.services import opnsense_client
|
from app.services import opnsense_client, variables_service
|
||||||
from app.services.deploy_flow import push_to_host
|
from app.services.deploy_flow import push_to_host
|
||||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||||
|
|
||||||
@@ -18,7 +18,12 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(rule: OneToOneRule) -> dict:
|
def _build_payload(rule: OneToOneRule, host: OpnsenseHost | None = None) -> dict:
|
||||||
|
payload = _base_payload(rule)
|
||||||
|
return variables_service.resolve(payload, host) if host is not None else payload
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(rule: OneToOneRule) -> dict:
|
||||||
return {
|
return {
|
||||||
"description": rule.description,
|
"description": rule.description,
|
||||||
"interface": rule.interface,
|
"interface": rule.interface,
|
||||||
@@ -35,7 +40,7 @@ def _build_payload(rule: OneToOneRule) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def _deploy_to_host(db: AsyncSession, rule: OneToOneRule, host: OpnsenseHost) -> OneToOneDeployment:
|
async def _deploy_to_host(db: AsyncSession, rule: OneToOneRule, host: OpnsenseHost) -> OneToOneDeployment:
|
||||||
payload = _build_payload(rule)
|
payload = _build_payload(rule, host)
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(OneToOneDeployment).where(
|
select(OneToOneDeployment).where(
|
||||||
OneToOneDeployment.one_to_one_id == rule.id, OneToOneDeployment.host_id == host.id
|
OneToOneDeployment.one_to_one_id == rule.id, OneToOneDeployment.host_id == host.id
|
||||||
@@ -78,6 +83,7 @@ async def deploy_one_to_one(db: AsyncSession, rule: OneToOneRule, hosts: list[Op
|
|||||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||||
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
||||||
await ensure_no_maintenance(db, hosts)
|
await ensure_no_maintenance(db, hosts)
|
||||||
|
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||||
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
||||||
await db.flush()
|
await db.flush()
|
||||||
for host, deployment in zip(hosts, deployments):
|
for host, deployment in zip(hosts, deployments):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
|
|
||||||
from app.models.policy import OneToOneRevision
|
from app.models.policy import OneToOneRevision
|
||||||
from app.services.one_to_one_deploy_service import _build_payload
|
from app.services.one_to_one_deploy_service import _build_payload
|
||||||
|
from app.services import variables_service
|
||||||
from app.services.policy_service import effective_hosts
|
from app.services.policy_service import effective_hosts
|
||||||
|
|
||||||
ONE_TO_ONE_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'type', 'external', 'natreflection', 'log')
|
ONE_TO_ONE_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'type', 'external', 'natreflection', 'log')
|
||||||
@@ -27,13 +28,17 @@ def revision(db, rule, note):
|
|||||||
|
|
||||||
|
|
||||||
def preview(rule):
|
def preview(rule):
|
||||||
payload = _build_payload(rule)
|
|
||||||
deployments = {d.host_id: d for d in rule.deployments}
|
deployments = {d.host_id: d for d in rule.deployments}
|
||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
||||||
rows = []
|
rows = []
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
dep = deployments.get(host.id)
|
dep = deployments.get(host.id)
|
||||||
|
try:
|
||||||
|
payload = _build_payload(rule, host)
|
||||||
|
except variables_service.MissingVariables as exc:
|
||||||
|
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||||
|
continue
|
||||||
before = dep.last_payload if dep else None
|
before = dep.last_payload if dep else None
|
||||||
action = "create" if not dep or not dep.opnsense_one_to_one_uuid else "update"
|
action = "create" if not dep or not dep.opnsense_one_to_one_uuid else "update"
|
||||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||||
@@ -60,6 +65,8 @@ def preview(rule):
|
|||||||
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
||||||
if any(r["action"] == "retained" for r in rows):
|
if any(r["action"] == "retained" for r in rows):
|
||||||
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
||||||
|
if any(r["action"] == "missing_variable" for r in rows):
|
||||||
|
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||||
if any(r["action"] == "disabled" for r in rows):
|
if any(r["action"] == "disabled" for r in rows):
|
||||||
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
||||||
if any(d.status == "error" for d in rule.deployments):
|
if any(d.status == "error" for d in rule.deployments):
|
||||||
@@ -70,7 +77,7 @@ def preview(rule):
|
|||||||
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
||||||
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
return {"token": token, "hosts": rows, "warnings": warnings,
|
return {"token": token, "hosts": rows, "warnings": warnings,
|
||||||
"can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows),
|
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||||
|
|
||||||
|
|
||||||
@@ -78,12 +85,11 @@ def workflow(rule, deployments):
|
|||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
active = [h for h in hosts if h.enabled]
|
active = [h for h in hosts if h.enabled]
|
||||||
by_host = {d.host_id: d for d in deployments}
|
by_host = {d.host_id: d for d in deployments}
|
||||||
payload = _build_payload(rule)
|
|
||||||
target_ids = {h.id for h in hosts}
|
target_ids = {h.id for h in hosts}
|
||||||
counts = {
|
counts = {
|
||||||
"targets": len(hosts),
|
"targets": len(hosts),
|
||||||
"active_targets": len(active),
|
"active_targets": len(active),
|
||||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload),
|
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||||
"pending": sum(d.status == "pending" for d in deployments),
|
"pending": sum(d.status == "pending" for d in deployments),
|
||||||
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
||||||
"retained": sum(d.host_id not in target_ids for d in deployments),
|
"retained": sum(d.host_id not in target_ids for d in deployments),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import httpx
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.host import OpnsenseHost
|
from app.models.host import OpnsenseHost
|
||||||
from app.services import firewall_url, opnsense_secret_store, tls_pin_service
|
from app.services import firewall_url, opnsense_secret_store, overlay_service, tls_pin_service
|
||||||
from app.services.redaction import redact_data, redact_text
|
from app.services.redaction import redact_data, redact_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,10 +46,23 @@ def _client(host: OpnsenseHost) -> httpx.AsyncClient:
|
|||||||
auth=(host.api_key, secret),
|
auth=(host.api_key, secret),
|
||||||
verify=tls_pin_service.verify_argument(host.verify_tls, host.tls_ca_pem),
|
verify=tls_pin_service.verify_argument(host.verify_tls, host.tls_ca_pem),
|
||||||
timeout=settings.opnsense_timeout,
|
timeout=settings.opnsense_timeout,
|
||||||
|
# Firewalls reached through the management overlay (WireGuard hub) go via the hub's CONNECT proxy.
|
||||||
|
proxy=overlay_service.proxy_for(host.base_url),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse_unresolved_placeholders(method: str, path: str, kwargs: dict) -> None:
|
||||||
|
"""Last line of defence for site variables: a payload that still contains a {{NAME}} placeholder was never
|
||||||
|
resolved for this host and must not reach a firewall."""
|
||||||
|
if method.upper() == "GET" or "json" not in kwargs:
|
||||||
|
return
|
||||||
|
from app.services.variables_service import PLACEHOLDER
|
||||||
|
if PLACEHOLDER.search(json.dumps(kwargs["json"], default=str)):
|
||||||
|
raise OpnsenseAPIError(f"{path}: Die Anfrage enthält eine nicht aufgelöste Variable ({{{{NAME}}}}) und wird nicht gesendet")
|
||||||
|
|
||||||
|
|
||||||
async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> httpx.Response:
|
async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> httpx.Response:
|
||||||
|
_refuse_unresolved_placeholders(method, path, kwargs)
|
||||||
async with _client(host) as client:
|
async with _client(host) as client:
|
||||||
try:
|
try:
|
||||||
resp = await client.request(method, path, **kwargs)
|
resp = await client.request(method, path, **kwargs)
|
||||||
@@ -369,6 +382,15 @@ async def get_source_nat_settings(host: OpnsenseHost) -> dict:
|
|||||||
# Core controllers, checked against OPNsense core source:
|
# Core controllers, checked against OPNsense core source:
|
||||||
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/FirmwareController.php
|
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/FirmwareController.php
|
||||||
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php
|
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php
|
||||||
|
async def get_pf_rule_statistics(host: OpnsenseHost) -> dict:
|
||||||
|
"""`diagnostics/firewall/pfStatistics/rules` (Diagnostics Api FirewallController):
|
||||||
|
{"rules": {"filter rules": {"@N <pf rule text> label \"<uuid>\"": {evaluations, packets,
|
||||||
|
bytes, states, ...}}}}. Verified live on OPNsense 26.7: the pf label of a rule created in
|
||||||
|
the rule editor/API is its UUID, so counters can be tied to rules; automatic rules carry
|
||||||
|
an md5 label instead."""
|
||||||
|
return await _request(host, "GET", "/diagnostics/firewall/pfStatistics/rules")
|
||||||
|
|
||||||
|
|
||||||
async def get_firmware_status(host: OpnsenseHost) -> dict:
|
async def get_firmware_status(host: OpnsenseHost) -> dict:
|
||||||
# GET reads cached results. POST would trigger a synchronous probe.
|
# GET reads cached results. POST would trigger a synchronous probe.
|
||||||
return await _request(host, "GET", "/core/firmware/status")
|
return await _request(host, "GET", "/core/firmware/status")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.models.outbound_nat import OutboundNatRule
|
|||||||
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||||
from app.services.outbound_nat_mode import require_manual_mode
|
from app.services.outbound_nat_mode import require_manual_mode
|
||||||
from app.services.audit_service import tracked_rollback
|
from app.services.audit_service import tracked_rollback
|
||||||
from app.services import opnsense_client
|
from app.services import opnsense_client, variables_service
|
||||||
from app.services.deploy_flow import push_to_host
|
from app.services.deploy_flow import push_to_host
|
||||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||||
|
|
||||||
@@ -19,7 +19,12 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(rule: OutboundNatRule) -> dict:
|
def _build_payload(rule: OutboundNatRule, host: OpnsenseHost | None = None) -> dict:
|
||||||
|
payload = _base_payload(rule)
|
||||||
|
return variables_service.resolve(payload, host) if host is not None else payload
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(rule: OutboundNatRule) -> dict:
|
||||||
return {
|
return {
|
||||||
"description": rule.description,
|
"description": rule.description,
|
||||||
"interface": rule.interface,
|
"interface": rule.interface,
|
||||||
@@ -39,7 +44,7 @@ def _build_payload(rule: OutboundNatRule) -> dict:
|
|||||||
|
|
||||||
async def _deploy_to_host(db: AsyncSession, rule: OutboundNatRule, host: OpnsenseHost) -> OutboundNatDeployment:
|
async def _deploy_to_host(db: AsyncSession, rule: OutboundNatRule, host: OpnsenseHost) -> OutboundNatDeployment:
|
||||||
await require_manual_mode(host)
|
await require_manual_mode(host)
|
||||||
payload = _build_payload(rule)
|
payload = _build_payload(rule, host)
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(OutboundNatDeployment).where(
|
select(OutboundNatDeployment).where(
|
||||||
OutboundNatDeployment.outbound_nat_id == rule.id, OutboundNatDeployment.host_id == host.id
|
OutboundNatDeployment.outbound_nat_id == rule.id, OutboundNatDeployment.host_id == host.id
|
||||||
@@ -82,6 +87,7 @@ async def deploy_outbound_nat(db: AsyncSession, rule: OutboundNatRule, hosts: li
|
|||||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||||
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
||||||
await ensure_no_maintenance(db, hosts)
|
await ensure_no_maintenance(db, hosts)
|
||||||
|
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||||
deployments = []
|
deployments = []
|
||||||
try:
|
try:
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
|
|
||||||
from app.models.policy import OutboundNatRevision
|
from app.models.policy import OutboundNatRevision
|
||||||
from app.services.outbound_nat_deploy_service import _build_payload
|
from app.services.outbound_nat_deploy_service import _build_payload
|
||||||
|
from app.services import variables_service
|
||||||
from app.services.policy_service import effective_hosts
|
from app.services.policy_service import effective_hosts
|
||||||
|
|
||||||
OUTBOUND_NAT_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'ipprotocol', 'protocol', 'source_port', 'destination_port', 'target', 'target_port')
|
OUTBOUND_NAT_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'ipprotocol', 'protocol', 'source_port', 'destination_port', 'target', 'target_port')
|
||||||
@@ -27,13 +28,17 @@ def revision(db, rule, note):
|
|||||||
|
|
||||||
|
|
||||||
def preview(rule):
|
def preview(rule):
|
||||||
payload = _build_payload(rule)
|
|
||||||
deployments = {d.host_id: d for d in rule.deployments}
|
deployments = {d.host_id: d for d in rule.deployments}
|
||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
||||||
rows = []
|
rows = []
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
dep = deployments.get(host.id)
|
dep = deployments.get(host.id)
|
||||||
|
try:
|
||||||
|
payload = _build_payload(rule, host)
|
||||||
|
except variables_service.MissingVariables as exc:
|
||||||
|
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||||
|
continue
|
||||||
before = dep.last_payload if dep else None
|
before = dep.last_payload if dep else None
|
||||||
action = "create" if not dep or not dep.opnsense_outbound_nat_uuid else "update"
|
action = "create" if not dep or not dep.opnsense_outbound_nat_uuid else "update"
|
||||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||||
@@ -60,6 +65,8 @@ def preview(rule):
|
|||||||
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
||||||
if any(r["action"] == "retained" for r in rows):
|
if any(r["action"] == "retained" for r in rows):
|
||||||
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
||||||
|
if any(r["action"] == "missing_variable" for r in rows):
|
||||||
|
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||||
if any(r["action"] == "disabled" for r in rows):
|
if any(r["action"] == "disabled" for r in rows):
|
||||||
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
||||||
if any(d.status == "error" for d in rule.deployments):
|
if any(d.status == "error" for d in rule.deployments):
|
||||||
@@ -70,7 +77,7 @@ def preview(rule):
|
|||||||
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
||||||
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
return {"token": token, "hosts": rows, "warnings": warnings,
|
return {"token": token, "hosts": rows, "warnings": warnings,
|
||||||
"can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows),
|
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||||
|
|
||||||
|
|
||||||
@@ -78,12 +85,11 @@ def workflow(rule, deployments):
|
|||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
active = [h for h in hosts if h.enabled]
|
active = [h for h in hosts if h.enabled]
|
||||||
by_host = {d.host_id: d for d in deployments}
|
by_host = {d.host_id: d for d in deployments}
|
||||||
payload = _build_payload(rule)
|
|
||||||
target_ids = {h.id for h in hosts}
|
target_ids = {h.id for h in hosts}
|
||||||
counts = {
|
counts = {
|
||||||
"targets": len(hosts),
|
"targets": len(hosts),
|
||||||
"active_targets": len(active),
|
"active_targets": len(active),
|
||||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload),
|
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||||
"pending": sum(d.status == "pending" for d in deployments),
|
"pending": sum(d.status == "pending" for d in deployments),
|
||||||
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
||||||
"retained": sum(d.host_id not in target_ids for d in deployments),
|
"retained": sum(d.host_id not in target_ids for d in deployments),
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Management overlay: keys, addressing, the hub's config file and the live status.
|
||||||
|
|
||||||
|
How it fits together
|
||||||
|
--------------------
|
||||||
|
* The ``wg-hub`` container (app/overlay_hub.py, same image as the backend, NET_ADMIN, UDP port published)
|
||||||
|
owns the WireGuard interface. It reads ``<overlay_dir>/hub.json`` — written here whenever settings or peers
|
||||||
|
change — and writes ``<overlay_dir>/status.json`` (handshakes, traffic) every few seconds.
|
||||||
|
* The backend itself needs no extra privileges: requests to a firewall whose address lies in the overlay subnet
|
||||||
|
go through the hub's small CONNECT proxy (``proxy_for``), which only lets traffic into that subnet.
|
||||||
|
* The firewall dials out to the hub. Its private key never leaves it; OPNexus only learns its public key. A
|
||||||
|
per-peer pre-shared key is generated here and shown to an admin for entering on the firewall.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.overlay import OverlayPeer, OverlaySettings
|
||||||
|
from app.services import secure_files
|
||||||
|
|
||||||
|
SINGLETON_ID = 1
|
||||||
|
KEEPALIVE = 25
|
||||||
|
CONNECTED_WITHIN = 180 # seconds since the last handshake
|
||||||
|
_B64_KEY = re.compile(r"^[A-Za-z0-9+/]{43}=$")
|
||||||
|
|
||||||
|
_active_network: ipaddress.IPv4Network | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayError(Exception):
|
||||||
|
"""A user-facing problem; ``code`` is stable, ``detail`` is for the log."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, detail: str = ""):
|
||||||
|
super().__init__(detail or code)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _dir() -> Path:
|
||||||
|
return Path(settings.overlay_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# ── keys ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def valid_public_key(value: str) -> bool:
|
||||||
|
if not _B64_KEY.match(value or ""):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return len(base64.b64decode(value, validate=True)) == 32
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_private_key() -> str:
|
||||||
|
key = X25519PrivateKey.generate()
|
||||||
|
return _raw_b64(key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()))
|
||||||
|
|
||||||
|
|
||||||
|
def public_from_private(private_b64: str) -> str:
|
||||||
|
key = X25519PrivateKey.from_private_bytes(base64.b64decode(private_b64))
|
||||||
|
return _raw_b64(key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw))
|
||||||
|
|
||||||
|
|
||||||
|
def hub_private_key() -> str:
|
||||||
|
"""The hub's private key, created on first use."""
|
||||||
|
path = _dir() / "hub.key"
|
||||||
|
if not path.exists():
|
||||||
|
secure_files.write_private(path, generate_private_key())
|
||||||
|
return path.read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def hub_public_key() -> str:
|
||||||
|
return public_from_private(hub_private_key())
|
||||||
|
|
||||||
|
|
||||||
|
def peer_psk(peer_id) -> str:
|
||||||
|
path = _dir() / f"psk-{peer_id}"
|
||||||
|
if not path.exists():
|
||||||
|
secure_files.write_private(path, _raw_b64(secrets.token_bytes(32)))
|
||||||
|
return path.read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_peer_secret(peer_id) -> None:
|
||||||
|
(_dir() / f"psk-{peer_id}").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── addressing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_subnet(value: str) -> ipaddress.IPv4Network:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(value.strip(), strict=True)
|
||||||
|
except ValueError:
|
||||||
|
raise OverlayError("subnet", "kein gültiges Netz") from None
|
||||||
|
if (not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29)
|
||||||
|
or net.is_loopback or net.is_link_local or net.is_multicast or net.is_unspecified):
|
||||||
|
raise OverlayError("subnet", "ein privates IPv4-Netz zwischen /16 und /29 ist nötig")
|
||||||
|
return net
|
||||||
|
|
||||||
|
|
||||||
|
def hub_address(net: ipaddress.IPv4Network) -> ipaddress.IPv4Address:
|
||||||
|
return net.network_address + 1
|
||||||
|
|
||||||
|
|
||||||
|
def next_free_ip(net: ipaddress.IPv4Network, taken: set[str]) -> str:
|
||||||
|
hub = hub_address(net)
|
||||||
|
for candidate in net.hosts():
|
||||||
|
if candidate != hub and str(candidate) not in taken:
|
||||||
|
return str(candidate)
|
||||||
|
raise OverlayError("full", "Im Overlay-Netz ist keine Adresse mehr frei")
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_for(base_url: str) -> str | None:
|
||||||
|
"""The hub's proxy if this firewall address is inside the overlay subnet, else None (direct)."""
|
||||||
|
if _active_network is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
host = urlsplit(base_url).hostname
|
||||||
|
return settings.overlay_proxy_url if host and ipaddress.ip_address(host) in _active_network else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── settings, hub config ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_settings(db: AsyncSession) -> OverlaySettings:
|
||||||
|
row = await db.get(OverlaySettings, SINGLETON_ID)
|
||||||
|
if row is None:
|
||||||
|
row = OverlaySettings(id=SINGLETON_ID, enabled=False, subnet="10.99.0.0/24", listen_port=51820)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint_with_port(cfg: OverlaySettings) -> str | None:
|
||||||
|
if not cfg.endpoint:
|
||||||
|
return None
|
||||||
|
return cfg.endpoint if re.search(r":\d+$", cfg.endpoint) else f"{cfg.endpoint}:{cfg.listen_port}"
|
||||||
|
|
||||||
|
|
||||||
|
async def sync(db: AsyncSession) -> None:
|
||||||
|
"""Make hub.json match the database (call after every change and at startup) and refresh the proxy rule."""
|
||||||
|
global _active_network
|
||||||
|
cfg = await get_settings(db)
|
||||||
|
net = parse_subnet(cfg.subnet)
|
||||||
|
_active_network = net if cfg.enabled else None
|
||||||
|
peers = (await db.scalars(select(OverlayPeer).order_by(OverlayPeer.ip))).all()
|
||||||
|
config = {
|
||||||
|
"enabled": bool(cfg.enabled), "address": f"{hub_address(net)}/{net.prefixlen}", "subnet": str(net),
|
||||||
|
"listen_port": cfg.listen_port,
|
||||||
|
"private_key": hub_private_key() if cfg.enabled else None,
|
||||||
|
"peers": [{"public_key": p.public_key, "psk": peer_psk(p.id), "ip": p.ip} for p in peers if p.public_key] if cfg.enabled else [],
|
||||||
|
}
|
||||||
|
_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
tmp = _dir() / "hub.json.tmp"
|
||||||
|
secure_files.write_private(tmp, json.dumps(config))
|
||||||
|
os.replace(tmp, _dir() / "hub.json")
|
||||||
|
|
||||||
|
|
||||||
|
# ── live status (written by the hub) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def read_status() -> dict | None:
|
||||||
|
try:
|
||||||
|
data = json.loads((_dir() / "status.json").read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def hub_state(status: dict | None, now: float | None = None) -> str:
|
||||||
|
"""'running' | 'stale' (the hub stopped reporting) | 'absent' (never reported: container not running)"""
|
||||||
|
if not status:
|
||||||
|
return "absent"
|
||||||
|
now = now or datetime.now(timezone.utc).timestamp()
|
||||||
|
return "running" if now - float(status.get("updated_at", 0)) < 30 else "stale"
|
||||||
|
|
||||||
|
|
||||||
|
def peer_status(status: dict | None, public_key: str | None, now: float | None = None) -> dict:
|
||||||
|
now = now or datetime.now(timezone.utc).timestamp()
|
||||||
|
entry = next((p for p in (status or {}).get("peers", []) if p.get("public_key") == public_key), None) if public_key else None
|
||||||
|
handshake = int(entry.get("handshake_at", 0)) if entry else 0
|
||||||
|
return {
|
||||||
|
"connected": bool(handshake and now - handshake < CONNECTED_WITHIN and hub_state(status, now) == "running"),
|
||||||
|
"last_handshake_at": datetime.fromtimestamp(handshake, timezone.utc) if handshake else None,
|
||||||
|
"rx_bytes": int(entry.get("rx", 0)) if entry else 0, "tx_bytes": int(entry.get("tx", 0)) if entry else 0,
|
||||||
|
"remote_endpoint": entry.get("endpoint") if entry else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def instructions(cfg: OverlaySettings, peer: OverlayPeer) -> dict:
|
||||||
|
net = parse_subnet(cfg.subnet)
|
||||||
|
return {
|
||||||
|
"hub_public_key": hub_public_key(), "endpoint": endpoint_with_port(cfg),
|
||||||
|
"tunnel_address": f"{peer.ip}/32", "allowed_ips": f"{hub_address(net)}/32", "psk": peer_psk(peer.id),
|
||||||
|
"keepalive": KEEPALIVE, "hub_ip": str(hub_address(net)), "host_url": f"https://{peer.ip}",
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from app.models.policy import RuleRevision
|
from app.models.policy import RuleRevision
|
||||||
from app.services.rule_deploy_service import _build_payload
|
from app.services.rule_deploy_service import _build_payload
|
||||||
|
from app.services import variables_service
|
||||||
|
|
||||||
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log")
|
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log")
|
||||||
|
|
||||||
@@ -45,13 +46,17 @@ def ensure_editable(rule):
|
|||||||
|
|
||||||
|
|
||||||
def preview(rule):
|
def preview(rule):
|
||||||
payload = _build_payload(rule)
|
|
||||||
deployments = {d.host_id: d for d in rule.deployments}
|
deployments = {d.host_id: d for d in rule.deployments}
|
||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
||||||
rows = []
|
rows = []
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
dep = deployments.get(host.id)
|
dep = deployments.get(host.id)
|
||||||
|
try:
|
||||||
|
payload = _build_payload(rule, host)
|
||||||
|
except variables_service.MissingVariables as exc:
|
||||||
|
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||||
|
continue
|
||||||
before = dep.last_payload if dep else None
|
before = dep.last_payload if dep else None
|
||||||
action = "create" if not dep or not dep.opnsense_rule_uuid else "update"
|
action = "create" if not dep or not dep.opnsense_rule_uuid else "update"
|
||||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||||
@@ -78,6 +83,8 @@ def preview(rule):
|
|||||||
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
||||||
if any(r["action"] == "retained" for r in rows):
|
if any(r["action"] == "retained" for r in rows):
|
||||||
warnings.append("Frühere Ziele behalten ihre Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
warnings.append("Frühere Ziele behalten ihre Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
||||||
|
if any(r["action"] == "missing_variable" for r in rows):
|
||||||
|
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||||
if any(r["action"] == "disabled" for r in rows):
|
if any(r["action"] == "disabled" for r in rows):
|
||||||
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
||||||
if any(d.status == "error" for d in rule.deployments):
|
if any(d.status == "error" for d in rule.deployments):
|
||||||
@@ -89,7 +96,7 @@ def preview(rule):
|
|||||||
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
||||||
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
return {"token": token, "hosts": rows, "warnings": warnings,
|
return {"token": token, "hosts": rows, "warnings": warnings,
|
||||||
"can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows),
|
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||||
|
|
||||||
|
|
||||||
@@ -98,12 +105,11 @@ def workflow(rule, deployments):
|
|||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
active = [h for h in hosts if h.enabled]
|
active = [h for h in hosts if h.enabled]
|
||||||
by_host = {d.host_id: d for d in deployments}
|
by_host = {d.host_id: d for d in deployments}
|
||||||
payload = _build_payload(rule)
|
|
||||||
target_ids = {h.id for h in hosts}
|
target_ids = {h.id for h in hosts}
|
||||||
counts = {
|
counts = {
|
||||||
"targets": len(hosts),
|
"targets": len(hosts),
|
||||||
"active_targets": len(active),
|
"active_targets": len(active),
|
||||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload),
|
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||||
"pending": sum(d.status == "pending" for d in deployments),
|
"pending": sum(d.status == "pending" for d in deployments),
|
||||||
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
||||||
"retained": sum(d.host_id not in target_ids for d in deployments),
|
"retained": sum(d.host_id not in target_ids for d in deployments),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.models.host import OpnsenseHost
|
|||||||
from app.models.port_forward import PortForwardRule
|
from app.models.port_forward import PortForwardRule
|
||||||
from app.models.port_forward_deployment import PortForwardDeployment
|
from app.models.port_forward_deployment import PortForwardDeployment
|
||||||
from app.services.audit_service import tracked_rollback
|
from app.services.audit_service import tracked_rollback
|
||||||
from app.services import opnsense_client
|
from app.services import opnsense_client, variables_service
|
||||||
from app.services.deploy_flow import push_to_host
|
from app.services.deploy_flow import push_to_host
|
||||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||||
|
|
||||||
@@ -30,7 +30,12 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(rule: PortForwardRule) -> dict:
|
def _build_payload(rule: PortForwardRule, host: OpnsenseHost | None = None) -> dict:
|
||||||
|
payload = _base_payload(rule)
|
||||||
|
return variables_service.resolve(payload, host) if host is not None else payload
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(rule: PortForwardRule) -> dict:
|
||||||
return {
|
return {
|
||||||
"disabled": "0" if rule.enabled else "1",
|
"disabled": "0" if rule.enabled else "1",
|
||||||
"interface": rule.interface,
|
"interface": rule.interface,
|
||||||
@@ -47,7 +52,7 @@ def _build_payload(rule: PortForwardRule) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def _deploy_to_host(db: AsyncSession, rule: PortForwardRule, host: OpnsenseHost) -> PortForwardDeployment:
|
async def _deploy_to_host(db: AsyncSession, rule: PortForwardRule, host: OpnsenseHost) -> PortForwardDeployment:
|
||||||
payload = _build_payload(rule)
|
payload = _build_payload(rule, host)
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(PortForwardDeployment).where(
|
select(PortForwardDeployment).where(
|
||||||
PortForwardDeployment.port_forward_id == rule.id, PortForwardDeployment.host_id == host.id
|
PortForwardDeployment.port_forward_id == rule.id, PortForwardDeployment.host_id == host.id
|
||||||
@@ -90,6 +95,7 @@ async def deploy_port_forward(db: AsyncSession, rule: PortForwardRule, hosts: li
|
|||||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||||
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
||||||
await ensure_no_maintenance(db, hosts)
|
await ensure_no_maintenance(db, hosts)
|
||||||
|
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||||
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
||||||
await db.flush()
|
await db.flush()
|
||||||
for host, deployment in zip(hosts, deployments):
|
for host, deployment in zip(hosts, deployments):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
|
|
||||||
from app.models.policy import PortForwardRevision
|
from app.models.policy import PortForwardRevision
|
||||||
from app.services.port_forward_deploy_service import _build_payload
|
from app.services.port_forward_deploy_service import _build_payload
|
||||||
|
from app.services import variables_service
|
||||||
from app.services.policy_service import effective_hosts
|
from app.services.policy_service import effective_hosts
|
||||||
|
|
||||||
PORT_FORWARD_FIELDS = (
|
PORT_FORWARD_FIELDS = (
|
||||||
@@ -30,13 +31,17 @@ def revision(db, rule, note):
|
|||||||
|
|
||||||
|
|
||||||
def preview(rule):
|
def preview(rule):
|
||||||
payload = _build_payload(rule)
|
|
||||||
deployments = {d.host_id: d for d in rule.deployments}
|
deployments = {d.host_id: d for d in rule.deployments}
|
||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
|
||||||
rows = []
|
rows = []
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
dep = deployments.get(host.id)
|
dep = deployments.get(host.id)
|
||||||
|
try:
|
||||||
|
payload = _build_payload(rule, host)
|
||||||
|
except variables_service.MissingVariables as exc:
|
||||||
|
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
|
||||||
|
continue
|
||||||
before = dep.last_payload if dep else None
|
before = dep.last_payload if dep else None
|
||||||
action = "create" if not dep or not dep.opnsense_port_forward_uuid else "update"
|
action = "create" if not dep or not dep.opnsense_port_forward_uuid else "update"
|
||||||
if before == payload and dep and dep.status in ("success", "reverted"):
|
if before == payload and dep and dep.status in ("success", "reverted"):
|
||||||
@@ -63,6 +68,8 @@ def preview(rule):
|
|||||||
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
|
||||||
if any(r["action"] == "retained" for r in rows):
|
if any(r["action"] == "retained" for r in rows):
|
||||||
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
|
||||||
|
if any(r["action"] == "missing_variable" for r in rows):
|
||||||
|
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
|
||||||
if any(r["action"] == "disabled" for r in rows):
|
if any(r["action"] == "disabled" for r in rows):
|
||||||
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
warnings.append("Deaktivierte Hosts werden übersprungen.")
|
||||||
if any(d.status == "error" for d in rule.deployments):
|
if any(d.status == "error" for d in rule.deployments):
|
||||||
@@ -73,7 +80,7 @@ def preview(rule):
|
|||||||
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
|
||||||
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
return {"token": token, "hosts": rows, "warnings": warnings,
|
return {"token": token, "hosts": rows, "warnings": warnings,
|
||||||
"can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows),
|
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
|
||||||
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
|
||||||
|
|
||||||
|
|
||||||
@@ -81,12 +88,11 @@ def workflow(rule, deployments):
|
|||||||
hosts = effective_hosts(rule)
|
hosts = effective_hosts(rule)
|
||||||
active = [h for h in hosts if h.enabled]
|
active = [h for h in hosts if h.enabled]
|
||||||
by_host = {d.host_id: d for d in deployments}
|
by_host = {d.host_id: d for d in deployments}
|
||||||
payload = _build_payload(rule)
|
|
||||||
target_ids = {h.id for h in hosts}
|
target_ids = {h.id for h in hosts}
|
||||||
counts = {
|
counts = {
|
||||||
"targets": len(hosts),
|
"targets": len(hosts),
|
||||||
"active_targets": len(active),
|
"active_targets": len(active),
|
||||||
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload),
|
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, rule, h)),
|
||||||
"pending": sum(d.status == "pending" for d in deployments),
|
"pending": sum(d.status == "pending" for d in deployments),
|
||||||
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
|
||||||
"retained": sum(d.host_id not in target_ids for d in deployments),
|
"retained": sum(d.host_id not in target_ids for d in deployments),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""HTTP CONNECT through the overlay hub's proxy, for the few places that open a raw TLS connection."""
|
||||||
|
import asyncio
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
async def open_tunnel(proxy_url: str, host: str, port: int, timeout: float = 8.0):
|
||||||
|
"""(reader, writer) connected to host:port through the proxy; TLS can be started on top with ``writer.start_tls``."""
|
||||||
|
proxy = urlsplit(proxy_url)
|
||||||
|
reader, writer = await asyncio.wait_for(asyncio.open_connection(proxy.hostname, proxy.port or 8888), timeout)
|
||||||
|
try:
|
||||||
|
writer.write(f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n".encode())
|
||||||
|
await writer.drain()
|
||||||
|
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout)
|
||||||
|
status = int(head.split(b" ", 2)[1])
|
||||||
|
if status != 200:
|
||||||
|
raise OSError(f"Der Overlay-Proxy lehnt die Verbindung ab (HTTP {status})")
|
||||||
|
except Exception:
|
||||||
|
writer.close()
|
||||||
|
raise
|
||||||
|
return reader, writer
|
||||||
@@ -27,7 +27,7 @@ from app.models.host import OpnsenseHost
|
|||||||
from app.models.rule import FirewallRule
|
from app.models.rule import FirewallRule
|
||||||
from app.models.rule_deployment import RuleDeployment
|
from app.models.rule_deployment import RuleDeployment
|
||||||
from app.services.audit_service import tracked_rollback
|
from app.services.audit_service import tracked_rollback
|
||||||
from app.services import opnsense_client
|
from app.services import opnsense_client, variables_service
|
||||||
from app.services.deploy_flow import push_to_host
|
from app.services.deploy_flow import push_to_host
|
||||||
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
|
||||||
|
|
||||||
@@ -36,7 +36,12 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(rule: FirewallRule) -> dict:
|
def _build_payload(rule: FirewallRule, host: OpnsenseHost | None = None) -> dict:
|
||||||
|
payload = _base_payload(rule)
|
||||||
|
return variables_service.resolve(payload, host) if host is not None else payload
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(rule: FirewallRule) -> dict:
|
||||||
return {
|
return {
|
||||||
"enabled": "1" if rule.enabled else "0",
|
"enabled": "1" if rule.enabled else "0",
|
||||||
"action": rule.action,
|
"action": rule.action,
|
||||||
@@ -55,7 +60,7 @@ def _build_payload(rule: FirewallRule) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def _deploy_to_host(db: AsyncSession, rule: FirewallRule, host: OpnsenseHost) -> RuleDeployment:
|
async def _deploy_to_host(db: AsyncSession, rule: FirewallRule, host: OpnsenseHost) -> RuleDeployment:
|
||||||
payload = _build_payload(rule)
|
payload = _build_payload(rule, host)
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(RuleDeployment).where(RuleDeployment.rule_id == rule.id, RuleDeployment.host_id == host.id)
|
select(RuleDeployment).where(RuleDeployment.rule_id == rule.id, RuleDeployment.host_id == host.id)
|
||||||
)
|
)
|
||||||
@@ -98,6 +103,7 @@ async def deploy_rule(db: AsyncSession, rule: FirewallRule, hosts: list[Opnsense
|
|||||||
from app.services.host_maintenance_service import ensure_no_maintenance
|
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||||
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
|
||||||
await ensure_no_maintenance(db, hosts)
|
await ensure_no_maintenance(db, hosts)
|
||||||
|
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
|
||||||
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
|
||||||
await db.flush()
|
await db.flush()
|
||||||
for host, deployment in zip(hosts, deployments):
|
for host, deployment in zip(hosts, deployments):
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Rule usage from pf's counters: which rules never match anything?
|
||||||
|
|
||||||
|
``sample_host`` reads the per-rule packet counters (``diagnostics/firewall/pfStatistics/rules``) and the
|
||||||
|
configured rules (``searchRule``) and updates one row per rule: the time of the last observed hit.
|
||||||
|
|
||||||
|
* A counter that grew since the last sample is a hit "now" (at most one sampling interval late).
|
||||||
|
* A counter that is *lower* than before means pf reloaded its ruleset (an apply or a reboot), which
|
||||||
|
zeroes everything. Whatever it shows above zero was hit since the reload, so that also counts as a hit now;
|
||||||
|
zero tells nothing, the old ``last_hit_at`` stays.
|
||||||
|
* A rule is called unused only after OPNexus has watched it for ``UNUSED_AFTER_DAYS`` without a hit.
|
||||||
|
A new rule, or a brand-new installation, is "still collecting data" instead of a false alarm.
|
||||||
|
|
||||||
|
Only rules with a UUID label are tied to a configured rule (rules created in the rule editor or API; OPNsense's
|
||||||
|
built-in automatic rules carry hash labels and are not user rules).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.models.rule_hit import RuleHit
|
||||||
|
from app.services import opnsense_client as client
|
||||||
|
from app.services.opnsense_client import OpnsenseAPIError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNUSED_AFTER_DAYS = 30
|
||||||
|
_LABEL = re.compile(r'label "([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_counters(stats: dict) -> dict[str, dict]:
|
||||||
|
"""{rule uuid: {packets, bytes, evaluations}} summed over every pf line a rule expands to."""
|
||||||
|
lines = ((stats or {}).get("rules") or {}).get("filter rules") or {}
|
||||||
|
result: dict[str, dict] = {}
|
||||||
|
for text, values in lines.items():
|
||||||
|
match = _LABEL.search(text)
|
||||||
|
if not match or not isinstance(values, dict):
|
||||||
|
continue
|
||||||
|
entry = result.setdefault(match.group(1).lower(), {"packets": 0, "bytes": 0, "evaluations": 0})
|
||||||
|
for key in entry:
|
||||||
|
entry[key] += int(values.get(key) or 0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def configured_rules(search: dict) -> dict[str, dict]:
|
||||||
|
rows = (search or {}).get("rows") or []
|
||||||
|
return {
|
||||||
|
str(r["uuid"]).lower(): {"description": r.get("description") or "", "enabled": r.get("enabled") in ("1", 1, True)}
|
||||||
|
for r in rows if r.get("uuid") and not r.get("is_automatic")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def sample_host(db: AsyncSession, host: OpnsenseHost, now: datetime | None = None) -> int:
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
counters = parse_counters(await client.get_pf_rule_statistics(host))
|
||||||
|
rules = configured_rules(await client.search_rules(host, all_rows=True))
|
||||||
|
rows = {r.rule_uuid: r for r in (await db.scalars(select(RuleHit).where(RuleHit.host_id == host.id))).all()}
|
||||||
|
for uuid_, meta in rules.items():
|
||||||
|
counter = counters.get(uuid_, {"packets": 0, "bytes": 0})
|
||||||
|
row = rows.get(uuid_)
|
||||||
|
if row is None:
|
||||||
|
row = RuleHit(host_id=host.id, rule_uuid=uuid_, first_seen_at=now, last_packets=0, last_bytes=0, last_sampled_at=now)
|
||||||
|
db.add(row)
|
||||||
|
if counter["packets"] > 0:
|
||||||
|
row.last_hit_at = now # already matching when first seen
|
||||||
|
elif counter["packets"] > row.last_packets or (counter["packets"] < row.last_packets and counter["packets"] > 0):
|
||||||
|
row.last_hit_at = now
|
||||||
|
row.description = meta["description"][:255]
|
||||||
|
row.enabled = meta["enabled"]
|
||||||
|
row.last_packets, row.last_bytes, row.last_sampled_at = counter["packets"], counter["bytes"], now
|
||||||
|
for uuid_, row in rows.items(): # rule deleted on the firewall
|
||||||
|
if uuid_ not in rules:
|
||||||
|
await db.delete(row)
|
||||||
|
await db.commit()
|
||||||
|
return len(rules)
|
||||||
|
|
||||||
|
|
||||||
|
async def sample_all(db: AsyncSession) -> int:
|
||||||
|
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)))).all()
|
||||||
|
sampled = 0
|
||||||
|
for host in hosts:
|
||||||
|
try:
|
||||||
|
await sample_host(db, host)
|
||||||
|
sampled += 1
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
logger.warning("Regelnutzung von %s nicht lesbar: %s", host.name, exc)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Regelnutzung von %s fehlgeschlagen", host.name)
|
||||||
|
return sampled
|
||||||
|
|
||||||
|
|
||||||
|
def classify(row: RuleHit, now: datetime) -> str:
|
||||||
|
"""'used' | 'unused' | 'collecting' | 'disabled'"""
|
||||||
|
if not row.enabled:
|
||||||
|
return "disabled"
|
||||||
|
if row.last_hit_at is not None and now - row.last_hit_at < timedelta(days=UNUSED_AFTER_DAYS):
|
||||||
|
return "used"
|
||||||
|
watched = now - row.first_seen_at
|
||||||
|
if watched < timedelta(days=UNUSED_AFTER_DAYS):
|
||||||
|
return "used" if row.last_hit_at is not None else "collecting"
|
||||||
|
return "unused"
|
||||||
|
|
||||||
|
|
||||||
|
async def unused_summary(db: AsyncSession, host_id) -> dict | None:
|
||||||
|
"""For the security check: None while nothing is known yet, else {"ready": bool, "rules": [names]}."""
|
||||||
|
rows = (await db.scalars(select(RuleHit).where(RuleHit.host_id == host_id))).all()
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
states = [(r, classify(r, now)) for r in rows]
|
||||||
|
unused = [r.description or r.rule_uuid for r, s in states if s == "unused"]
|
||||||
|
watching = [s for _, s in states if s == "collecting"]
|
||||||
|
return {"unused": unused, "collecting": len(watching)}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Minimal S3-compatible object client (AWS Signature V4) for offsite backups.
|
||||||
|
|
||||||
|
Just what is needed — PUT, GET, DELETE and a connection test — so no AWS SDK is pulled
|
||||||
|
into the image. Works with AWS S3, MinIO, Backblaze B2, Wasabi, Cloudflare R2, Hetzner
|
||||||
|
Object Storage... Path-style addressing (``https://endpoint/bucket/key``) is the default
|
||||||
|
because it works everywhere; virtual-hosted style is available for providers that need it.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class S3Error(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _hmac(key: bytes, message: str) -> bytes:
|
||||||
|
return hmac.new(key, message.encode(), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _quote(path: str) -> str:
|
||||||
|
return quote(path, safe="/-_.~")
|
||||||
|
|
||||||
|
|
||||||
|
def sign(*, method: str, host: str, path: str, query: str, payload_hash: str, access_key: str, secret_key: str,
|
||||||
|
region: str, now: datetime, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
|
||||||
|
"""SigV4 headers for one request (the algorithm of the AWS docs, service ``s3``)."""
|
||||||
|
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
day = now.strftime("%Y%m%d")
|
||||||
|
headers = {"host": host, "x-amz-content-sha256": payload_hash, "x-amz-date": amz_date}
|
||||||
|
headers.update({k.lower(): v.strip() for k, v in (extra_headers or {}).items()})
|
||||||
|
names = sorted(headers)
|
||||||
|
canonical_headers = "".join(f"{n}:{headers[n]}\n" for n in names)
|
||||||
|
signed = ";".join(names)
|
||||||
|
canonical_request = "\n".join([method, _quote(path), query, canonical_headers, signed, payload_hash])
|
||||||
|
scope = f"{day}/{region}/s3/aws4_request"
|
||||||
|
to_sign = "\n".join(["AWS4-HMAC-SHA256", amz_date, scope, _sha256(canonical_request.encode())])
|
||||||
|
key = _hmac(_hmac(_hmac(_hmac(("AWS4" + secret_key).encode(), day), region), "s3"), "aws4_request")
|
||||||
|
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
||||||
|
headers["authorization"] = f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed}, Signature={signature}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
class S3Client:
|
||||||
|
def __init__(self, *, endpoint: str, region: str, bucket: str, access_key: str, secret_key: str, path_style: bool = True):
|
||||||
|
parts = urlsplit(endpoint)
|
||||||
|
if parts.scheme not in ("http", "https") or not parts.hostname:
|
||||||
|
raise S3Error("Ungültiger Endpunkt")
|
||||||
|
if parts.scheme == "http" and not settings.allow_insecure_offsite:
|
||||||
|
# Credentials and (encrypted, but still) backups must not travel in the clear.
|
||||||
|
raise S3Error("Der Endpunkt muss https:// sein")
|
||||||
|
# httpx leaves a scheme's default port out of the Host header, so the signed host must not carry it either.
|
||||||
|
port = parts.port
|
||||||
|
default = {"https": 443, "http": 80}[parts.scheme]
|
||||||
|
host = parts.hostname if ":" not in parts.hostname else f"[{parts.hostname}]"
|
||||||
|
self.scheme = parts.scheme
|
||||||
|
self.netloc = host if port in (None, default) else f"{host}:{port}"
|
||||||
|
self.region, self.bucket, self.path_style = region or "us-east-1", bucket, path_style
|
||||||
|
self.access_key, self.secret_key = access_key, secret_key
|
||||||
|
|
||||||
|
def _target(self, key: str) -> tuple[str, str, str]:
|
||||||
|
"""(host header, URL path, URL)"""
|
||||||
|
if self.path_style:
|
||||||
|
host, path = self.netloc, f"/{self.bucket}/{key}"
|
||||||
|
else:
|
||||||
|
host, path = f"{self.bucket}.{self.netloc}", f"/{key}"
|
||||||
|
return host, path, f"{self.scheme}://{host}{_quote(path)}"
|
||||||
|
|
||||||
|
async def _request(self, method: str, key: str, body: bytes = b"") -> httpx.Response:
|
||||||
|
host, path, url = self._target(key)
|
||||||
|
headers = sign(
|
||||||
|
method=method, host=host, path=path, query="", payload_hash=_sha256(body), access_key=self.access_key,
|
||||||
|
secret_key=self.secret_key, region=self.region, now=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
return await client.request(method, url, content=body if method == "PUT" else None, headers=headers)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise S3Error(f"Verbindung fehlgeschlagen ({type(exc).__name__})") from None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check(response: httpx.Response, what: str) -> None:
|
||||||
|
if response.status_code >= 300:
|
||||||
|
raise S3Error(f"{what}: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
async def put(self, key: str, data: bytes) -> None:
|
||||||
|
self._check(await self._request("PUT", key, data), "Hochladen")
|
||||||
|
|
||||||
|
async def get(self, key: str) -> bytes:
|
||||||
|
response = await self._request("GET", key)
|
||||||
|
self._check(response, "Herunterladen")
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
response = await self._request("DELETE", key)
|
||||||
|
if response.status_code not in (200, 204, 404):
|
||||||
|
self._check(response, "Löschen")
|
||||||
|
|
||||||
|
async def test(self, key: str) -> None:
|
||||||
|
"""Write, read back and delete a tiny object: proves credentials, bucket and permissions."""
|
||||||
|
probe = b"opnexus connection test"
|
||||||
|
await self.put(key, probe)
|
||||||
|
if await self.get(key) != probe:
|
||||||
|
raise S3Error("Der Speicher lieferte etwas anderes zurück")
|
||||||
|
await self.delete(key)
|
||||||
@@ -18,6 +18,8 @@ from cryptography import x509
|
|||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography.x509.oid import ExtensionOID, NameOID
|
from cryptography.x509.oid import ExtensionOID, NameOID
|
||||||
|
|
||||||
|
from app.services import overlay_service, proxy_connect
|
||||||
|
|
||||||
MAX_PEM_BYTES = 32 * 1024
|
MAX_PEM_BYTES = 32 * 1024
|
||||||
|
|
||||||
|
|
||||||
@@ -90,6 +92,23 @@ def verify_argument(verify_tls: bool, tls_ca_pem: str | None):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_tls(base_url: str, parsed, ctx: ssl.SSLContext, timeout: float):
|
||||||
|
"""TLS connection to the firewall: directly, or through the overlay hub's proxy for overlay addresses."""
|
||||||
|
proxy = overlay_service.proxy_for(base_url)
|
||||||
|
port = parsed.port or 443
|
||||||
|
if proxy is None:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(parsed.hostname, port, ssl=ctx, server_hostname=parsed.hostname), timeout
|
||||||
|
)
|
||||||
|
reader, writer = await proxy_connect.open_tunnel(proxy, parsed.hostname, port, timeout)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(writer.start_tls(ctx, server_hostname=parsed.hostname), timeout)
|
||||||
|
except Exception:
|
||||||
|
writer.close()
|
||||||
|
raise
|
||||||
|
return reader, writer
|
||||||
|
|
||||||
|
|
||||||
async def probe(base_url: str, timeout: float = 8.0) -> dict:
|
async def probe(base_url: str, timeout: float = 8.0) -> dict:
|
||||||
"""Fetch the certificate a firewall presents, *without* trusting it. The
|
"""Fetch the certificate a firewall presents, *without* trusting it. The
|
||||||
caller must show the fingerprint to a human to compare with the firewall."""
|
caller must show the fingerprint to a human to compare with the firewall."""
|
||||||
@@ -100,10 +119,7 @@ async def probe(base_url: str, timeout: float = 8.0) -> dict:
|
|||||||
ctx.check_hostname = False
|
ctx.check_hostname = False
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
try:
|
try:
|
||||||
_, writer = await asyncio.wait_for(
|
_, writer = await _open_tls(base_url, parsed, ctx, timeout)
|
||||||
asyncio.open_connection(parsed.hostname, parsed.port or 443, ssl=ctx, server_hostname=parsed.hostname),
|
|
||||||
timeout,
|
|
||||||
)
|
|
||||||
except (OSError, ssl.SSLError, asyncio.TimeoutError) as exc:
|
except (OSError, ssl.SSLError, asyncio.TimeoutError) as exc:
|
||||||
raise TlsPinError(f"Verbindung fehlgeschlagen: {exc}") from exc
|
raise TlsPinError(f"Verbindung fehlgeschlagen: {exc}") from exc
|
||||||
try:
|
try:
|
||||||
@@ -122,10 +138,7 @@ async def verify_matches(base_url: str, pem: str, timeout: float = 8.0) -> None:
|
|||||||
presents anything else."""
|
presents anything else."""
|
||||||
parsed = urlparse(base_url)
|
parsed = urlparse(base_url)
|
||||||
try:
|
try:
|
||||||
_, writer = await asyncio.wait_for(
|
_, writer = await _open_tls(base_url, parsed, pinned_context(pem), timeout)
|
||||||
asyncio.open_connection(parsed.hostname, parsed.port or 443, ssl=pinned_context(pem), server_hostname=parsed.hostname),
|
|
||||||
timeout,
|
|
||||||
)
|
|
||||||
writer.close()
|
writer.close()
|
||||||
except ssl.SSLError as exc:
|
except ssl.SSLError as exc:
|
||||||
raise TlsPinError("Das Zertifikat der Firewall passt nicht zum festgesetzten Zertifikat") from exc
|
raise TlsPinError("Das Zertifikat der Firewall passt nicht zum festgesetzten Zertifikat") from exc
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.models.update_check_settings import DEFAULT_FEED_URL, UpdateCheckSettings
|
||||||
from app.version import APP_VERSION
|
from app.version import APP_VERSION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,6 +40,7 @@ class _State:
|
|||||||
latest: str | None = None
|
latest: str | None = None
|
||||||
release_url: str | None = None
|
release_url: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
_state = _State()
|
_state = _State()
|
||||||
@@ -45,11 +48,41 @@ _lock = asyncio.Lock()
|
|||||||
|
|
||||||
|
|
||||||
def reset_cache() -> None:
|
def reset_cache() -> None:
|
||||||
"""Nur für Tests."""
|
"""Verwirft den gemerkten Stand (Tests, geänderter Feed)."""
|
||||||
global _state
|
global _state
|
||||||
_state = _State()
|
_state = _State()
|
||||||
|
|
||||||
|
|
||||||
|
_SINGLETON_ID = 1
|
||||||
|
_URL = re.compile(r"^https?://[^\s<>\"']+$")
|
||||||
|
|
||||||
|
|
||||||
|
def valid_feed_url(value: str) -> bool:
|
||||||
|
return bool(_URL.match(value)) and len(value) <= 500
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings(db: AsyncSession) -> UpdateCheckSettings:
|
||||||
|
row = await db.get(UpdateCheckSettings, _SINGLETON_ID)
|
||||||
|
if row is None:
|
||||||
|
row = UpdateCheckSettings(id=_SINGLETON_ID, enabled=False, feed_url=DEFAULT_FEED_URL)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def managed_by_server() -> bool:
|
||||||
|
"""UPDATE_CHECK_URL in the server's .env wins over the setting in the web UI."""
|
||||||
|
return bool(settings.update_check_url.strip())
|
||||||
|
|
||||||
|
|
||||||
|
async def feed_url(db: AsyncSession) -> str:
|
||||||
|
"""The feed to ask, or "" while the check is off."""
|
||||||
|
if managed_by_server():
|
||||||
|
return settings.update_check_url.strip()
|
||||||
|
row = await get_settings(db)
|
||||||
|
return row.feed_url.strip() if row.enabled else ""
|
||||||
|
|
||||||
|
|
||||||
def _parse(tag: str) -> tuple[int, int, int] | None:
|
def _parse(tag: str) -> tuple[int, int, int] | None:
|
||||||
match = _STABLE_TAG.match(tag.strip())
|
match = _STABLE_TAG.match(tag.strip())
|
||||||
return tuple(int(part) for part in match.groups()) if match else None # type: ignore[return-value]
|
return tuple(int(part) for part in match.groups()) if match else None # type: ignore[return-value]
|
||||||
@@ -62,10 +95,10 @@ def _safe_url(value: object) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _fetch() -> tuple[str, str | None]:
|
async def _fetch(url: str) -> tuple[str, str | None]:
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, follow_redirects=True, max_redirects=3) as http:
|
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, follow_redirects=True, max_redirects=3) as http:
|
||||||
response = await http.get(
|
response = await http.get(
|
||||||
settings.update_check_url,
|
url,
|
||||||
headers={"Accept": "application/json", "User-Agent": f"OPNexus/{APP_VERSION}"},
|
headers={"Accept": "application/json", "User-Agent": f"OPNexus/{APP_VERSION}"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -78,9 +111,10 @@ async def _fetch() -> tuple[str, str | None]:
|
|||||||
return tag, _safe_url(data.get("html_url"))
|
return tag, _safe_url(data.get("html_url"))
|
||||||
|
|
||||||
|
|
||||||
async def check() -> dict:
|
async def check(db: AsyncSession) -> dict:
|
||||||
"""Aktueller Stand für GET /api/system/version-check."""
|
"""Aktueller Stand für GET /api/system/version-check."""
|
||||||
enabled = bool(settings.update_check_url.strip())
|
url = await feed_url(db)
|
||||||
|
enabled = bool(url)
|
||||||
current = _parse(APP_VERSION)
|
current = _parse(APP_VERSION)
|
||||||
result = {
|
result = {
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
@@ -95,6 +129,9 @@ async def check() -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async with _lock:
|
async with _lock:
|
||||||
|
if _state.url != url: # Feed geändert: alter Stand gehört nicht mehr dazu
|
||||||
|
reset_cache()
|
||||||
|
_state.url = url
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
interval = timedelta(hours=max(1, settings.update_check_interval_hours))
|
interval = timedelta(hours=max(1, settings.update_check_interval_hours))
|
||||||
fresh = _state.checked_at is not None and now - _state.checked_at < interval
|
fresh = _state.checked_at is not None and now - _state.checked_at < interval
|
||||||
@@ -102,12 +139,12 @@ async def check() -> dict:
|
|||||||
if not fresh and not backing_off:
|
if not fresh and not backing_off:
|
||||||
_state.attempted_at = now
|
_state.attempted_at = now
|
||||||
try:
|
try:
|
||||||
tag, url = await _fetch()
|
tag, release_url = await _fetch(url)
|
||||||
if _parse(tag) is None:
|
if _parse(tag) is None:
|
||||||
# z. B. eine Vorabversion — kein Fehler, nur kein Update.
|
# z. B. eine Vorabversion — kein Fehler, nur kein Update.
|
||||||
_state.latest, _state.release_url = None, None
|
_state.latest, _state.release_url = None, None
|
||||||
else:
|
else:
|
||||||
_state.latest, _state.release_url = tag.lstrip("v"), url
|
_state.latest, _state.release_url = tag.lstrip("v"), release_url
|
||||||
_state.checked_at, _state.error = now, None
|
_state.checked_at, _state.error = now, None
|
||||||
except Exception as exc: # Netz, HTTP, JSON, Format: alles nur "geht gerade nicht"
|
except Exception as exc: # Netz, HTTP, JSON, Format: alles nur "geht gerade nicht"
|
||||||
logger.warning("Update-Check fehlgeschlagen: %s", exc.__class__.__name__)
|
logger.warning("Update-Check fehlgeschlagen: %s", exc.__class__.__name__)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Site variables: ``{{NAME}}`` placeholders in centrally authored objects, filled per host.
|
||||||
|
|
||||||
|
A rule, alias or NAT entry can say ``{{LAN_NET}}`` instead of a literal network; every host that object is
|
||||||
|
deployed to supplies its own value (Host page > Variables). Resolution happens when the payload for one host is
|
||||||
|
built, so preview, deploy, the change counters and the audit all see the host's real values.
|
||||||
|
|
||||||
|
Fail closed: a placeholder without a value for a target host is never pushed. Preview marks that host
|
||||||
|
``missing_variable``, the deploy is refused before anything is written, and as a last line of defence
|
||||||
|
``opnsense_client`` refuses to send any payload that still contains a placeholder.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
|
||||||
|
NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,39}$")
|
||||||
|
VALUE_RE = re.compile(r"^[\w .:/,@+\-]{1,255}$") # networks, addresses, hostnames, names, lists: no braces, quotes or newlines
|
||||||
|
PLACEHOLDER = re.compile(r"\{\{\s*([A-Z][A-Z0-9_]{0,39})\s*\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
class MissingVariables(Exception):
|
||||||
|
"""One or more hosts lack values for placeholders the object uses. ``missing`` maps host name -> names."""
|
||||||
|
|
||||||
|
def __init__(self, missing: dict[str, list[str]]):
|
||||||
|
self.missing = missing
|
||||||
|
super().__init__("; ".join(f"{host}: {', '.join(names)}" for host, names in missing.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def names_in(value) -> set[str]:
|
||||||
|
return set(PLACEHOLDER.findall(value)) if isinstance(value, str) else set()
|
||||||
|
|
||||||
|
|
||||||
|
def names_in_payload(payload: dict) -> set[str]:
|
||||||
|
found: set[str] = set()
|
||||||
|
for value in payload.values():
|
||||||
|
found |= names_in(value)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def host_values(host: OpnsenseHost) -> dict[str, str]:
|
||||||
|
return {v.name: v.value for v in host.variables}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(payload: dict, host: OpnsenseHost) -> dict:
|
||||||
|
"""The payload with this host's values filled in; raises MissingVariables if one is not defined."""
|
||||||
|
values = host_values(host)
|
||||||
|
missing = sorted(n for n in names_in_payload(payload) if n not in values)
|
||||||
|
if missing:
|
||||||
|
raise MissingVariables({host.name: missing})
|
||||||
|
return {k: PLACEHOLDER.sub(lambda m: values[m.group(1)], v) if isinstance(v, str) else v for k, v in payload.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_resolvable(builder: Callable, entity, hosts: list[OpnsenseHost]) -> None:
|
||||||
|
"""Raise MissingVariables (listing every affected host) before anything is written."""
|
||||||
|
missing: dict[str, list[str]] = {}
|
||||||
|
for host in hosts:
|
||||||
|
try:
|
||||||
|
builder(entity, host)
|
||||||
|
except MissingVariables as exc:
|
||||||
|
missing.update(exc.missing)
|
||||||
|
if missing:
|
||||||
|
raise MissingVariables(missing)
|
||||||
|
|
||||||
|
|
||||||
|
def payload_or_none(builder: Callable, entity, host: OpnsenseHost):
|
||||||
|
try:
|
||||||
|
return builder(entity, host)
|
||||||
|
except MissingVariables:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def preview_row_missing(host: OpnsenseHost, names: list[str], dep) -> dict:
|
||||||
|
return {
|
||||||
|
"host_id": str(host.id), "host_name": host.name, "action": "missing_variable",
|
||||||
|
"status": dep.status if dep else None,
|
||||||
|
"message": "Variable fehlt: " + ", ".join(names), "can_remove": False,
|
||||||
|
"can_retry_rollback": False, "changes": [],
|
||||||
|
}
|
||||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
|||||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "1.18.0"
|
APP_VERSION = "1.23.11"
|
||||||
APP_CODENAME = "Kestrel"
|
APP_CODENAME = "Harrier"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Audit-Export: Syslog und Webhook.
|
||||||
|
|
||||||
|
Revision ID: 0010
|
||||||
|
Revises: 0009
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0010"
|
||||||
|
down_revision = "0009"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if sa.inspect(op.get_bind()).has_table("audit_export_settings"):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"audit_export_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("syslog_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("syslog_host", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("syslog_port", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("syslog_protocol", sa.String(length=3), nullable=False),
|
||||||
|
sa.Column("webhook_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("webhook_url", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("webhook_signing_secret_set", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("cursor_created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cursor_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.String(length=300), nullable=True),
|
||||||
|
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS audit_export_settings")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Geplante Backups mit Aufbewahrung und verschlüsseltem Offsite-Ziel; Benachrichtigung bei Backup-Problemen.
|
||||||
|
|
||||||
|
Revision ID: 0011
|
||||||
|
Revises: 0010
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0011"
|
||||||
|
down_revision = "0010"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS backup_failed_enabled boolean NOT NULL DEFAULT true")
|
||||||
|
if not inspector.has_table("backup_settings"):
|
||||||
|
op.create_table(
|
||||||
|
"backup_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("scheduled_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("interval_hours", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("keep_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("offsite_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("s3_endpoint", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("s3_region", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("s3_bucket", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("s3_prefix", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("s3_access_key", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("s3_path_style", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("s3_secret_set", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("passphrase_set", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_offsite_ok_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_offsite_error", sa.String(length=300), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
if not inspector.has_table("backup_uploads"):
|
||||||
|
op.create_table(
|
||||||
|
"backup_uploads",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("snapshot_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("remote_key", sa.String(length=400), nullable=False),
|
||||||
|
sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("host_id", "snapshot_id", name="uq_backup_upload"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_backup_uploads_host_id", "backup_uploads", ["host_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS backup_uploads")
|
||||||
|
op.execute("DROP TABLE IF EXISTS backup_settings")
|
||||||
|
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS backup_failed_enabled")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Regelnutzung: wann hat eine Regel zuletzt etwas getroffen.
|
||||||
|
|
||||||
|
Revision ID: 0012
|
||||||
|
Revises: 0011
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0012"
|
||||||
|
down_revision = "0011"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if sa.inspect(op.get_bind()).has_table("rule_hits"):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"rule_hits",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("rule_uuid", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_sampled_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_hit_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_packets", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("last_bytes", sa.BigInteger(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("host_id", "rule_uuid", name="uq_rule_hit"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_rule_hits_host_id", "rule_hits", ["host_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS rule_hits")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Single Sign-on (OIDC): Einstellungen, Anmeldeversuche, Herkunft der Konten.
|
||||||
|
|
||||||
|
Revision ID: 0013
|
||||||
|
Revises: 0012
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0013"
|
||||||
|
down_revision = "0012"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
op.execute("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS auth_source varchar(10) NOT NULL DEFAULT 'local'")
|
||||||
|
op.execute("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS oidc_subject varchar(600)")
|
||||||
|
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_admin_users_oidc_subject ON admin_users (oidc_subject)")
|
||||||
|
if not inspector.has_table("oidc_settings"):
|
||||||
|
op.create_table(
|
||||||
|
"oidc_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("issuer", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("client_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("client_secret_set", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("scopes", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("groups_claim", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("admin_group", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("operator_group", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("read_only_group", sa.String(length=255), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
if not inspector.has_table("oidc_login_states"):
|
||||||
|
op.create_table(
|
||||||
|
"oidc_login_states",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("state_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("nonce", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("code_verifier", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_oidc_login_states_state_hash", "oidc_login_states", ["state_hash"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS oidc_login_states")
|
||||||
|
op.execute("DROP TABLE IF EXISTS oidc_settings")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_admin_users_oidc_subject")
|
||||||
|
op.execute("ALTER TABLE admin_users DROP COLUMN IF EXISTS oidc_subject")
|
||||||
|
op.execute("ALTER TABLE admin_users DROP COLUMN IF EXISTS auth_source")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Aufgabentrennung: dürfen Operatoren selbst ausrollen?
|
||||||
|
|
||||||
|
Revision ID: 0014
|
||||||
|
Revises: 0013
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0014"
|
||||||
|
down_revision = "0013"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if sa.inspect(op.get_bind()).has_table("governance_settings"):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"governance_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("operators_may_deploy", sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS governance_settings")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Management-Overlay (WireGuard-Hub) für Firewalls hinter NAT.
|
||||||
|
|
||||||
|
Revision ID: 0015
|
||||||
|
Revises: 0014
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0015"
|
||||||
|
down_revision = "0014"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("overlay_settings"):
|
||||||
|
op.create_table(
|
||||||
|
"overlay_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("endpoint", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("subnet", sa.String(length=18), nullable=False),
|
||||||
|
sa.Column("listen_port", sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
if not inspector.has_table("overlay_peers"):
|
||||||
|
op.create_table(
|
||||||
|
"overlay_peers",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("ip", sa.String(length=15), nullable=False),
|
||||||
|
sa.Column("public_key", sa.String(length=44), nullable=True),
|
||||||
|
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
sa.UniqueConstraint("ip"),
|
||||||
|
sa.UniqueConstraint("public_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS overlay_peers")
|
||||||
|
op.execute("DROP TABLE IF EXISTS overlay_settings")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Standort-Variablen: Werte für {{NAME}}-Platzhalter je Host.
|
||||||
|
|
||||||
|
Revision ID: 0016
|
||||||
|
Revises: 0015
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0016"
|
||||||
|
down_revision = "0015"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if sa.inspect(op.get_bind()).has_table("host_variables"):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"host_variables",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=41), nullable=False),
|
||||||
|
sa.Column("value", sa.String(length=255), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("host_id", "name", name="uq_host_variable"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_host_variables_host_id", "host_variables", ["host_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS host_variables")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Benachrichtigung bei kritischen/hohen Sicherheitsbefunden (standardmäßig aus).
|
||||||
|
|
||||||
|
Revision ID: 0017
|
||||||
|
Revises: 0016
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0017"
|
||||||
|
down_revision = "0016"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS compliance_alerts_enabled boolean NOT NULL DEFAULT false")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS compliance_alerts_enabled")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""OIDC: Standard-Scopes ohne "groups" (Keycloak und Entra ID lehnen den Scope als ungültig ab).
|
||||||
|
|
||||||
|
Revision ID: 0018
|
||||||
|
Revises: 0017
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0018"
|
||||||
|
down_revision = "0017"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Only rows still carrying the old default; a value somebody typed on purpose stays.
|
||||||
|
op.execute("UPDATE oidc_settings SET scopes = 'openid profile email' WHERE scopes = 'openid profile email groups'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Versionsprüfung im Webinterface einstellbar.
|
||||||
|
|
||||||
|
Revision ID: 0019
|
||||||
|
Revises: 0018
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0019"
|
||||||
|
down_revision = "0018"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if sa.inspect(op.get_bind()).has_table("update_check_settings"):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"update_check_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("feed_url", sa.String(length=500), server_default=_DEFAULT_FEED_URL, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS update_check_settings")
|
||||||
@@ -12,3 +12,4 @@ pytest-asyncio==1.4.0
|
|||||||
respx==0.23.1
|
respx==0.23.1
|
||||||
cryptography==50.0.2
|
cryptography==50.0.2
|
||||||
alembic==1.20.0
|
alembic==1.20.0
|
||||||
|
PyJWT==2.10.1
|
||||||
@@ -20,6 +20,7 @@ os.environ["WIREGUARD_KEY_SECRET_DIR"] = "/tmp/opnexus-test-wireguard-secrets"
|
|||||||
os.environ["CERT_KEY_SECRET_DIR"] = "/tmp/opnexus-test-cert-key-secrets"
|
os.environ["CERT_KEY_SECRET_DIR"] = "/tmp/opnexus-test-cert-key-secrets"
|
||||||
os.environ["ACME_SECRET_DIR"] = "/tmp/opnexus-test-acme-secrets"
|
os.environ["ACME_SECRET_DIR"] = "/tmp/opnexus-test-acme-secrets"
|
||||||
os.environ["TOTP_SECRET_DIR"] = "/tmp/opnexus-test-totp-secrets"
|
os.environ["TOTP_SECRET_DIR"] = "/tmp/opnexus-test-totp-secrets"
|
||||||
|
os.environ["OVERLAY_DIR"] = "/tmp/opnexus-test-overlay"
|
||||||
os.environ.setdefault("ADMIN_USERNAME", "admin")
|
os.environ.setdefault("ADMIN_USERNAME", "admin")
|
||||||
# Force-set rather than setdefault: docker-compose's backend service sets
|
# Force-set rather than setdefault: docker-compose's backend service sets
|
||||||
# ADMIN_PASSWORD="" (present-but-empty) by default, which setdefault would
|
# ADMIN_PASSWORD="" (present-but-empty) by default, which setdefault would
|
||||||
|
|||||||
@@ -279,3 +279,18 @@ async def test_openapi_schema_is_served_behind_auth_with_bearer_scheme(client, a
|
|||||||
assert (await api.get("/api/openapi.json")).status_code == 200
|
assert (await api.get("/api/openapi.json")).status_code == 200
|
||||||
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
|
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
|
||||||
assert (await client.get("/docs")).status_code != 200
|
assert (await client.get("/docs")).status_code != 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_changing_your_own_password_revokes_your_api_tokens(client):
|
||||||
|
assert (await client.post("/api/users", json={"username": "pwtok", "password": "first-password-12", "role": "operator"})).status_code == 201
|
||||||
|
user = await _login("pwtok", "first-password-12")
|
||||||
|
try:
|
||||||
|
token = (await user.post("/api/tokens", json={"name": "ci", "scope": "read", "current_password": "first-password-12"})).json()["token"]
|
||||||
|
async with _bearer(token) as api:
|
||||||
|
assert (await api.get("/api/hosts")).status_code == 200
|
||||||
|
resp = await user.post("/api/auth/change-password", json={"current_password": "first-password-12", "new_password": "second-password-34"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert (await api.get("/api/hosts")).status_code == 401
|
||||||
|
finally:
|
||||||
|
await user.aclose()
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Audit export: cursor semantics, wire formats (syslog UDP/TCP, signed webhook), admin-only settings."""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.audit_export import AuditExportSettings
|
||||||
|
from app.services import audit_export_service as svc
|
||||||
|
from app.services import notification_secret_store
|
||||||
|
from tests.test_api_tokens import _login
|
||||||
|
|
||||||
|
|
||||||
|
async def _event(n: int, *, age_s: float = 60, status="accepted", details=None) -> AuditEvent:
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
event = AuditEvent(
|
||||||
|
created_at=datetime.now(timezone.utc) - timedelta(seconds=age_s + (10 - n) * 0.001), correlation_id="c", actor="admin", kind="rule",
|
||||||
|
action="deploy", status=status, entity_name=f"rule-{n}", message=f"m{n}", details=details or {},
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
await db.commit()
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def _configure(**fields):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
row = await svc.get_settings(db)
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(row, key, value)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(lag=0):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
return await svc.run_once(db, lag_seconds=lag)
|
||||||
|
|
||||||
|
|
||||||
|
async def _state():
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
return await svc.get_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
class _UdpReceiver:
|
||||||
|
def __init__(self):
|
||||||
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
self.sock.bind(("127.0.0.1", 0))
|
||||||
|
self.sock.settimeout(2)
|
||||||
|
self.port = self.sock.getsockname()[1]
|
||||||
|
|
||||||
|
def receive(self, n):
|
||||||
|
return [self.sock.recvfrom(65535)[0].decode() for _ in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
class _TcpReceiver:
|
||||||
|
def __init__(self):
|
||||||
|
self.server = socket.socket()
|
||||||
|
self.server.bind(("127.0.0.1", 0))
|
||||||
|
self.server.listen(1)
|
||||||
|
self.port = self.server.getsockname()[1]
|
||||||
|
self.data = b""
|
||||||
|
self.thread = threading.Thread(target=self._serve, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def _serve(self):
|
||||||
|
conn, _ = self.server.accept()
|
||||||
|
while chunk := conn.recv(65535):
|
||||||
|
self.data += chunk
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _reset(tmp_path):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
await db.execute(AuditExportSettings.__table__.delete())
|
||||||
|
await db.commit()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_when_nothing_is_enabled():
|
||||||
|
await _event(1)
|
||||||
|
assert await _run() == 0
|
||||||
|
assert (await _state()).cursor_created_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_first_round_starts_from_now_without_replaying_history():
|
||||||
|
await _event(1, age_s=3600)
|
||||||
|
udp = _UdpReceiver()
|
||||||
|
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port)
|
||||||
|
assert await _run() == 0 # sets the cursor
|
||||||
|
assert (await _state()).cursor_created_at is not None
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await _event(2, age_s=0)
|
||||||
|
assert await _run() == 1
|
||||||
|
(line,) = udp.receive(1)
|
||||||
|
assert '"entity_name": "rule-2"' in line and "rule-1" not in line
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_syslog_udp_line_is_rfc5424_with_severity_by_status_and_no_details():
|
||||||
|
udp = _UdpReceiver()
|
||||||
|
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
await _event(1, status="accepted", details={"after_payload": {"password": "hunter2"}})
|
||||||
|
await _event(2, status="denied")
|
||||||
|
assert await _run() == 2
|
||||||
|
ok, bad = udp.receive(2)
|
||||||
|
assert ok.startswith("<134>1 ") and bad.startswith("<132>1 ") # local0.info / local0.warning
|
||||||
|
assert " opnexus - AUDIT - {" in ok
|
||||||
|
body = json.loads(ok.split(" - AUDIT - ", 1)[1])
|
||||||
|
assert body["actor"] == "admin" and body["kind"] == "rule" and "details" not in body
|
||||||
|
assert "hunter2" not in ok
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_syslog_tcp_uses_octet_counting():
|
||||||
|
tcp = _TcpReceiver()
|
||||||
|
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=tcp.port, syslog_protocol="tcp",
|
||||||
|
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
await _event(1)
|
||||||
|
assert await _run() == 1
|
||||||
|
tcp.thread.join(timeout=3)
|
||||||
|
length, _, rest = tcp.data.partition(b" ")
|
||||||
|
assert int(length) == len(rest) and rest.startswith(b"<134>1 ")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failure_keeps_the_cursor_and_the_next_round_retries(respx_mock):
|
||||||
|
await _configure(webhook_enabled=True, webhook_url="https://hook.test/secret-token",
|
||||||
|
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
await _event(1)
|
||||||
|
route = respx_mock.post("https://hook.test/secret-token").mock(return_value=httpx.Response(500))
|
||||||
|
assert await _run() == 0
|
||||||
|
state = await _state()
|
||||||
|
assert state.cursor_id is None and "HTTP 500" in state.last_error and "secret-token" not in state.last_error
|
||||||
|
route.mock(return_value=httpx.Response(200))
|
||||||
|
assert await _run() == 1
|
||||||
|
state = await _state()
|
||||||
|
assert state.last_error is None and state.last_success_at is not None and state.cursor_id is not None
|
||||||
|
assert await _run() == 0 # nothing new
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_batch_is_signed(respx_mock):
|
||||||
|
notification_secret_store.save_secret(svc.SECRET_NAME, "s3cret")
|
||||||
|
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
|
||||||
|
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
await _event(1)
|
||||||
|
await _event(2)
|
||||||
|
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
|
||||||
|
assert await _run() == 2
|
||||||
|
request = route.calls.last.request
|
||||||
|
assert request.headers["X-OPNexus-Signature"] == "sha256=" + hmac.new(b"s3cret", request.content, hashlib.sha256).hexdigest()
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert [e["entity_name"] for e in payload["events"]] == ["rule-1", "rule-2"] and payload["source"] == "opnexus"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_signing_secret_fails_closed(respx_mock):
|
||||||
|
notification_secret_store.delete_secret(svc.SECRET_NAME)
|
||||||
|
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
|
||||||
|
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
await _event(1)
|
||||||
|
assert await _run() == 0 # respx would fail the test on any request: nothing was sent
|
||||||
|
assert "nicht lesbar" in (await _state()).last_error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batches_are_ordered_and_resume_without_gaps_or_duplicates(respx_mock, monkeypatch):
|
||||||
|
monkeypatch.setattr(svc, "BATCH", 2)
|
||||||
|
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
for n in range(1, 6):
|
||||||
|
await _event(n)
|
||||||
|
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
|
||||||
|
assert [await _run(), await _run(), await _run(), await _run()] == [2, 2, 1, 0]
|
||||||
|
names = [e["entity_name"] for call in route.calls for e in json.loads(call.request.content)["events"]]
|
||||||
|
assert names == [f"rule-{n}" for n in range(1, 6)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_very_fresh_events_wait_for_the_lag():
|
||||||
|
udp = _UdpReceiver()
|
||||||
|
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||||
|
await _event(1, age_s=0.2)
|
||||||
|
assert await _run(lag=3) == 0
|
||||||
|
assert await _run(lag=0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Settings API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_roundtrip_hides_the_secret_and_enabling_starts_from_now(client):
|
||||||
|
got = (await client.get("/api/settings/audit-export")).json()
|
||||||
|
assert got["syslog_enabled"] is False and got["webhook_signing_secret_set"] is False
|
||||||
|
put = await client.put("/api/settings/audit-export", json={
|
||||||
|
"syslog_enabled": True, "syslog_host": "127.0.0.1", "syslog_port": 5514, "syslog_protocol": "tcp",
|
||||||
|
"webhook_signing_secret": "shh"})
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
body = put.json()
|
||||||
|
assert body["syslog_protocol"] == "tcp" and body["webhook_signing_secret_set"] is True and "shh" not in put.text
|
||||||
|
assert (await _state()).cursor_created_at is not None
|
||||||
|
cleared = (await client.put("/api/settings/audit-export", json={"clear_webhook_signing_secret": True, "syslog_enabled": False})).json()
|
||||||
|
assert cleared["webhook_signing_secret_set"] is False
|
||||||
|
assert (await _state()).cursor_created_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_validation(client):
|
||||||
|
for body in ({"syslog_protocol": "carrier-pigeon"}, {"syslog_port": 0}, {"webhook_url": "x" * 501}):
|
||||||
|
assert (await client.put("/api/settings/audit-export", json=body)).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_endpoint_needs_a_channel_and_reports_delivery_failures(client, respx_mock):
|
||||||
|
assert (await client.post("/api/settings/audit-export/test")).status_code == 502
|
||||||
|
await client.put("/api/settings/audit-export", json={"webhook_enabled": True, "webhook_url": "https://hook.test/in"})
|
||||||
|
respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
|
||||||
|
assert (await client.post("/api/settings/audit-export/test")).json() == {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_admins_may_read_or_change_it(client):
|
||||||
|
for role in ("operator", "read_only"):
|
||||||
|
name = f"exp-{role}"
|
||||||
|
assert (await client.post("/api/users", json={"username": name, "password": "export-password-1", "role": role})).status_code == 201
|
||||||
|
user = await _login(name, "export-password-1")
|
||||||
|
try:
|
||||||
|
assert (await user.get("/api/settings/audit-export")).status_code == 403
|
||||||
|
assert (await user.put("/api/settings/audit-export", json={"syslog_enabled": True})).status_code == 403
|
||||||
|
finally:
|
||||||
|
await user.aclose()
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""Scheduled backups, retention, encrypted offsite copy: crypto, S3 signing, schedule logic, API."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.backup_settings import BackupSettings, BackupUpload
|
||||||
|
from app.services import backup_crypto, backup_schedule_service as svc, backup_store, notification_checks, notification_secret_store
|
||||||
|
from app.services import s3_client
|
||||||
|
from tests.test_api_tokens import _login
|
||||||
|
from tests.test_rules import _create_host
|
||||||
|
|
||||||
|
CONFIG = b"<?xml version='1.0'?><opnsense><system><hostname>fw</hostname></system></opnsense>"
|
||||||
|
|
||||||
|
|
||||||
|
# ── encryption ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_encryption_roundtrip_and_every_failure_mode():
|
||||||
|
blob = backup_crypto.encrypt(CONFIG, "correct horse battery")
|
||||||
|
assert blob.startswith(backup_crypto.MAGIC) and CONFIG not in blob
|
||||||
|
assert backup_crypto.decrypt(blob, "correct horse battery") == CONFIG
|
||||||
|
assert backup_crypto.encrypt(CONFIG, "correct horse battery") != blob # fresh salt and nonce
|
||||||
|
with pytest.raises(backup_crypto.DecryptError):
|
||||||
|
backup_crypto.decrypt(blob, "wrong passphrase!")
|
||||||
|
tampered = bytearray(blob)
|
||||||
|
tampered[-1] ^= 1
|
||||||
|
with pytest.raises(backup_crypto.DecryptError):
|
||||||
|
backup_crypto.decrypt(bytes(tampered), "correct horse battery")
|
||||||
|
relabelled = bytearray(blob)
|
||||||
|
relabelled[len(backup_crypto.MAGIC)] ^= 1 # salt is authenticated header data
|
||||||
|
with pytest.raises(backup_crypto.DecryptError):
|
||||||
|
backup_crypto.decrypt(bytes(relabelled), "correct horse battery")
|
||||||
|
for junk in (b"", b"OPXBAK1", b"not a backup at all, really not" * 3):
|
||||||
|
with pytest.raises(backup_crypto.DecryptError):
|
||||||
|
backup_crypto.decrypt(junk, "x" * 12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_cli_reads_stdin_and_the_environment():
|
||||||
|
blob = backup_crypto.encrypt(CONFIG, "cli passphrase 123")
|
||||||
|
ok = subprocess.run([sys.executable, "-m", "app.backup_decrypt"], input=blob, capture_output=True,
|
||||||
|
env={**os.environ, "BACKUP_PASSPHRASE": "cli passphrase 123"})
|
||||||
|
assert ok.returncode == 0 and ok.stdout == CONFIG
|
||||||
|
bad = subprocess.run([sys.executable, "-m", "app.backup_decrypt"], input=blob, capture_output=True,
|
||||||
|
env={**os.environ, "BACKUP_PASSPHRASE": "nope nope nope!"})
|
||||||
|
assert bad.returncode == 1 and bad.stdout == b""
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigV4 (examples from the AWS S3 documentation) ───────────────────────
|
||||||
|
|
||||||
|
_AK, _SK = "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||||
|
_EMPTY = s3_client._sha256(b"")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigv4_matches_the_aws_get_object_example():
|
||||||
|
headers = s3_client.sign(
|
||||||
|
method="GET", host="examplebucket.s3.amazonaws.com", path="/test.txt", query="", payload_hash=_EMPTY,
|
||||||
|
access_key=_AK, secret_key=_SK, region="us-east-1", now=datetime(2013, 5, 24, tzinfo=timezone.utc),
|
||||||
|
extra_headers={"Range": "bytes=0-9"},
|
||||||
|
)
|
||||||
|
assert headers["authorization"].endswith("Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41")
|
||||||
|
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date" in headers["authorization"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigv4_matches_the_aws_put_object_example_with_an_escaped_key():
|
||||||
|
body = b"Welcome to Amazon S3."
|
||||||
|
headers = s3_client.sign(
|
||||||
|
method="PUT", host="examplebucket.s3.amazonaws.com", path="/test$file.text", query="", payload_hash=s3_client._sha256(body),
|
||||||
|
access_key=_AK, secret_key=_SK, region="us-east-1", now=datetime(2013, 5, 24, tzinfo=timezone.utc),
|
||||||
|
extra_headers={"Date": "Fri, 24 May 2013 00:00:00 GMT", "x-amz-storage-class": "REDUCED_REDUNDANCY"},
|
||||||
|
)
|
||||||
|
assert headers["authorization"].endswith("Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd")
|
||||||
|
|
||||||
|
|
||||||
|
def _s3(**kw):
|
||||||
|
return s3_client.S3Client(endpoint="https://s3.test", region="eu-1", bucket="bk", access_key="AK", secret_key="SK", **kw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_s3_client_requests_and_errors(respx_mock):
|
||||||
|
put = respx_mock.put("https://s3.test/bk/a/b%20c.opxbak").mock(return_value=httpx.Response(200))
|
||||||
|
await _s3().put("a/b c.opxbak", b"data")
|
||||||
|
request = put.calls.last.request
|
||||||
|
assert request.headers["authorization"].startswith("AWS4-HMAC-SHA256 Credential=AK/") and "/eu-1/s3/aws4_request" in request.headers["authorization"]
|
||||||
|
assert request.headers["x-amz-content-sha256"] == s3_client._sha256(b"data") and request.content == b"data"
|
||||||
|
respx_mock.put("https://bk.s3.test/x").mock(return_value=httpx.Response(403))
|
||||||
|
with pytest.raises(s3_client.S3Error, match="HTTP 403"):
|
||||||
|
await _s3(path_style=False).put("x", b"1")
|
||||||
|
respx_mock.delete("https://s3.test/bk/gone").mock(return_value=httpx.Response(404))
|
||||||
|
await _s3().delete("gone") # deleting what is not there is fine
|
||||||
|
respx_mock.put("https://s3.test/bk/net").mock(side_effect=httpx.ConnectError("boom"))
|
||||||
|
with pytest.raises(s3_client.S3Error, match="Verbindung"):
|
||||||
|
await _s3().put("net", b"1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_endpoint_must_be_https_unless_the_lab_switch_is_on(monkeypatch):
|
||||||
|
with pytest.raises(s3_client.S3Error):
|
||||||
|
s3_client.S3Client(endpoint="http://minio.lan:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||||
|
for bad in ("ftp://x", "minio", ""):
|
||||||
|
with pytest.raises(s3_client.S3Error):
|
||||||
|
s3_client.S3Client(endpoint=bad, region="r", bucket="b", access_key="a", secret_key="s")
|
||||||
|
monkeypatch.setattr(s3_client.settings, "allow_insecure_offsite", True)
|
||||||
|
s3_client.S3Client(endpoint="http://minio.lan:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_s3_connection_test_proves_write_read_delete(respx_mock):
|
||||||
|
store = {}
|
||||||
|
respx_mock.put(url__regex=r"https://s3.test/bk/probe").mock(side_effect=lambda r: store.update(v=r.content) or httpx.Response(200))
|
||||||
|
respx_mock.get(url__regex=r"https://s3.test/bk/probe").mock(side_effect=lambda r: httpx.Response(200, content=store["v"]))
|
||||||
|
gone = respx_mock.delete(url__regex=r"https://s3.test/bk/probe").mock(return_value=httpx.Response(204))
|
||||||
|
await _s3().test("probe")
|
||||||
|
assert gone.called
|
||||||
|
respx_mock.get(url__regex=r"https://s3.test/bk/probe").mock(return_value=httpx.Response(200, content=b"other"))
|
||||||
|
with pytest.raises(s3_client.S3Error):
|
||||||
|
await _s3().test("probe")
|
||||||
|
|
||||||
|
|
||||||
|
# ── schedule, retention, offsite ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _mock_fw(respx_mock, content=CONFIG):
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
return respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=content))
|
||||||
|
|
||||||
|
|
||||||
|
async def _cfg(**fields):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
row = await svc.get_settings(db)
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(row, k, v)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(force=False):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
return await svc.run_due(db, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
def _age(host_id, snapshot_id, hours):
|
||||||
|
import app.services.backup_store as bs
|
||||||
|
path = bs._host_dir(host_id) / snapshot_id
|
||||||
|
t = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp()
|
||||||
|
os.utime(path, (t, t))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_backup_is_taken_when_due_and_not_before(client, respx_mock):
|
||||||
|
download = _mock_fw(respx_mock)
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
assert (await _run())["taken"] == 1 and len(backup_store.list_snapshots(host["id"])) == 1
|
||||||
|
assert (await _run())["taken"] == 0 # fresh, nothing due
|
||||||
|
first = backup_store.list_snapshots(host["id"])[0]["id"]
|
||||||
|
_age(host["id"], first, 25)
|
||||||
|
assert (await _run())["taken"] == 1 and download.call_count == 2
|
||||||
|
assert (await _run(force=True))["taken"] == 1 # "run now" ignores the interval
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_schedule_takes_nothing_and_disabled_hosts_are_skipped(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await _cfg(scheduled_enabled=False)
|
||||||
|
assert (await _run())["taken"] == 0
|
||||||
|
await _cfg(scheduled_enabled=True)
|
||||||
|
await client.patch(f"/api/hosts/{host['id']}", json={"enabled": False})
|
||||||
|
assert (await _run())["taken"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retention_keeps_only_the_newest_n(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await _cfg(keep_count=3)
|
||||||
|
for _ in range(6):
|
||||||
|
backup_store.save_snapshot(host["id"], CONFIG)
|
||||||
|
result = await _run()
|
||||||
|
kept = backup_store.list_snapshots(host["id"])
|
||||||
|
assert len(kept) == 3 and result["pruned"] >= 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failure_is_counted_a_garbage_answer_is_not_stored_and_others_continue(client, respx_mock):
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
ok = await _create_host(client, "fw-ok", "https://ok.test")
|
||||||
|
bad = await _create_host(client, "fw-bad", "https://bad.test")
|
||||||
|
junk = await _create_host(client, "fw-junk", "https://junk.test")
|
||||||
|
respx_mock.get("https://ok.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=CONFIG))
|
||||||
|
respx_mock.get("https://bad.test/api/core/backup/download/this").mock(return_value=httpx.Response(500))
|
||||||
|
respx_mock.get("https://junk.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=b"<html>login</html>"))
|
||||||
|
result = await _run()
|
||||||
|
assert (result["taken"], result["failed"]) == (1, 2)
|
||||||
|
assert backup_store.list_snapshots(ok["id"]) and not backup_store.list_snapshots(bad["id"]) and not backup_store.list_snapshots(junk["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _offsite_on(passphrase="offsite passphrase 1"):
|
||||||
|
notification_secret_store.save_secret(svc.S3_SECRET, "SK")
|
||||||
|
notification_secret_store.save_secret(svc.PASSPHRASE, passphrase)
|
||||||
|
await _cfg(offsite_enabled=True, s3_endpoint="https://s3.test", s3_bucket="bk", s3_access_key="AK", s3_secret_set=True, passphrase_set=True, s3_prefix="/opnexus/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_offsite_upload_is_encrypted_once_only_and_named_by_host(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
host = await _create_host(client, "fw 1/main", "https://fw1.test")
|
||||||
|
await _offsite_on()
|
||||||
|
puts = respx_mock.put(url__regex=r"https://s3\.test/bk/opnexus/.*").mock(return_value=httpx.Response(200))
|
||||||
|
result = await _run()
|
||||||
|
assert result["uploaded"] == 1 and puts.call_count == 1
|
||||||
|
request = puts.calls.last.request
|
||||||
|
assert re.fullmatch(rf"/bk/opnexus/fw-1-main-{host['id'][:8]}/\d{{8}}T\d+\.opxbak", request.url.path)
|
||||||
|
assert CONFIG not in request.content and backup_crypto.decrypt(request.content, "offsite passphrase 1") == CONFIG
|
||||||
|
assert (await _run())["uploaded"] == 0 and puts.call_count == 1 # ledger: nothing twice
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert len((await db.scalars(select(BackupUpload))).all()) == 1
|
||||||
|
cfg = await svc.get_settings(db)
|
||||||
|
assert cfg.last_offsite_error is None and cfg.last_offsite_ok_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_offsite_failure_is_recorded_and_retried_next_round(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await _offsite_on()
|
||||||
|
route = respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(503))
|
||||||
|
await _run()
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert "HTTP 503" in (await svc.get_settings(db)).last_offsite_error
|
||||||
|
assert (await db.scalars(select(BackupUpload))).all() == []
|
||||||
|
route.mock(return_value=httpx.Response(200))
|
||||||
|
assert (await _run())["uploaded"] == 1
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert (await svc.get_settings(db)).last_offsite_error is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_incomplete_offsite_config_and_lost_passphrase_fail_closed(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await _cfg(offsite_enabled=True)
|
||||||
|
await _run()
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert "unvollständig" in (await svc.get_settings(db)).last_offsite_error
|
||||||
|
await _offsite_on()
|
||||||
|
notification_secret_store.delete_secret(svc.PASSPHRASE) # respx would fail on any upload: nothing leaves unencrypted
|
||||||
|
await _run()
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert "Passphrase" in (await svc.get_settings(db)).last_offsite_error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pruned_snapshots_leave_the_ledger(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await _offsite_on()
|
||||||
|
respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(200))
|
||||||
|
await _cfg(keep_count=3)
|
||||||
|
for _ in range(4):
|
||||||
|
backup_store.save_snapshot(host["id"], CONFIG)
|
||||||
|
await _run()
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
names = {r.snapshot_id for r in (await db.scalars(select(BackupUpload))).all()}
|
||||||
|
assert names <= {s["id"] for s in backup_store.list_snapshots(host["id"])}
|
||||||
|
|
||||||
|
|
||||||
|
# ── notifications ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stale_backup_problem_after_three_missed_intervals(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await notification_checks.check_backup_stale(db) == {} # brand-new host: not yet
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
row = await db.get(OpnsenseHost, __import__("uuid").UUID(host["id"]))
|
||||||
|
row.created_at = datetime.now(timezone.utc) - timedelta(days=5)
|
||||||
|
await db.commit()
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert list(await notification_checks.check_backup_stale(db)) == [host["id"]]
|
||||||
|
name = backup_store.save_snapshot(host["id"], CONFIG)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await notification_checks.check_backup_stale(db) == {}
|
||||||
|
_age(host["id"], name, 80)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert list(await notification_checks.check_backup_stale(db)) == [host["id"]]
|
||||||
|
await _cfg(scheduled_enabled=False)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await notification_checks.check_backup_stale(db) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_offsite_problem_follows_the_last_error():
|
||||||
|
await _cfg(offsite_enabled=True, last_offsite_error="HTTP 503")
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert (await notification_checks.check_backup_offsite(db)) == {"offsite": {"message": "HTTP 503"}}
|
||||||
|
await _cfg(last_offsite_error=None)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await notification_checks.check_backup_offsite(db) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_roundtrip_never_returns_secrets(client):
|
||||||
|
put = await client.put("/api/settings/backups", json={
|
||||||
|
"interval_hours": 12, "keep_count": 10, "offsite_enabled": True, "s3_endpoint": "https://s3.test/", "s3_bucket": "bk",
|
||||||
|
"s3_access_key": "AK", "s3_secret_key": "TOPSECRET", "passphrase": "a long passphrase here"})
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
body = put.json()
|
||||||
|
assert body["s3_endpoint"] == "https://s3.test" and body["s3_secret_set"] and body["passphrase_set"]
|
||||||
|
assert "TOPSECRET" not in put.text and "passphrase here" not in put.text
|
||||||
|
assert notification_secret_store.read_secret(svc.S3_SECRET) == "TOPSECRET"
|
||||||
|
cleared = (await client.put("/api/settings/backups", json={"clear_s3_secret": True})).json()
|
||||||
|
assert cleared["s3_secret_set"] is False and notification_secret_store.read_secret(svc.S3_SECRET) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_validation(client):
|
||||||
|
for body in ({"interval_hours": 0}, {"keep_count": 2}, {"passphrase": "short"}, {"s3_region": ""}):
|
||||||
|
assert (await client.put("/api/settings/backups", json=body)).status_code == 422, body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_is_visible_to_everyone_settings_only_to_admins_run_now_to_operators(client, respx_mock):
|
||||||
|
_mock_fw(respx_mock)
|
||||||
|
await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
for role in ("read_only", "operator"):
|
||||||
|
name = f"bk-{role}"
|
||||||
|
assert (await client.post("/api/users", json={"username": name, "password": "backup-password-1", "role": role})).status_code == 201
|
||||||
|
user = await _login(name, "backup-password-1")
|
||||||
|
try:
|
||||||
|
status = (await user.get("/api/backups/status")).json()
|
||||||
|
assert "s3_endpoint" not in status and status["interval_hours"] == 24
|
||||||
|
assert (await user.get("/api/settings/backups")).status_code == 403
|
||||||
|
assert (await user.put("/api/settings/backups", json={"keep_count": 5})).status_code == 403
|
||||||
|
assert (await user.post("/api/settings/backups/test-offsite")).status_code == 403
|
||||||
|
expected = 403 if role == "read_only" else 200
|
||||||
|
assert (await user.post("/api/backups/run")).status_code == expected
|
||||||
|
finally:
|
||||||
|
await user.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_offsite_reports_the_reason(client, respx_mock):
|
||||||
|
resp = await client.post("/api/settings/backups/test-offsite")
|
||||||
|
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "OFFSITE_TEST_FAILED"
|
||||||
|
await _offsite_on()
|
||||||
|
respx_mock.route(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(403))
|
||||||
|
resp = await client.post("/api/settings/backups/test-offsite")
|
||||||
|
assert resp.status_code == 502 and "403" in resp.json()["detail"]["params"]["reason"]
|
||||||
|
respx_mock.clear()
|
||||||
|
store = {}
|
||||||
|
respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(side_effect=lambda r: store.update(v=r.content) or httpx.Response(200))
|
||||||
|
respx_mock.get(url__regex=r"https://s3\.test/bk/.*").mock(side_effect=lambda r: httpx.Response(200, content=store["v"]))
|
||||||
|
respx_mock.delete(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(204))
|
||||||
|
assert (await client.post("/api/settings/backups/test-offsite")).json() == {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_changes_are_audited_without_secrets(client):
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
await client.put("/api/settings/backups", json={"s3_secret_key": "LEAKME-1234"})
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "settings"))).all()
|
||||||
|
assert rows and "LEAKME" not in json.dumps([r.details for r in rows]) + "".join(r.message or "" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ── config diff ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _xml(*, otp="OTPSEED1", hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||||
|
return (f"<opnsense><revision><time>{revision}</time></revision><system><hostname>{hostname}</hostname>"
|
||||||
|
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey><otp_seed>{otp}</otp_seed><pwd>PW-{password}</pwd></user>"
|
||||||
|
f"<dnsserver>{dns}</dnsserver></system><cert><crt>{cert}</crt><keylength>{keylength}</keylength></cert></opnsense>").encode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_shows_changes_and_hides_every_secret(client):
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
backup_store.save_snapshot(host["id"], _xml())
|
||||||
|
backup_store.save_snapshot(host["id"], _xml(hostname="fw-new", password="SUPERSECRET2", dns="9.9.9.9", cert="B" * 200, revision="r2", keylength="2048"))
|
||||||
|
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||||
|
text = "\n".join(body["lines"])
|
||||||
|
assert not body["identical"] and body["added"] >= 4 and body["removed"] >= 4
|
||||||
|
assert "- <hostname>fw</hostname>" in text and "+ <hostname>fw-new</hostname>" in text
|
||||||
|
assert "9.9.9.9" in text and "<keylength>2048</keylength>" in text # ordinary values stay readable
|
||||||
|
for secret in ("SUPERSECRET2", "KEY-SUPERSECRET2", "p1", "A" * 50, "B" * 50):
|
||||||
|
assert secret not in text, secret
|
||||||
|
assert "‹" in text # a changed secret is still visible as a change
|
||||||
|
assert "r1" not in text and "r2" not in text # the volatile revision block is ignored
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_identical_backups_and_explicit_selection(client):
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
first = backup_store.save_snapshot(host["id"], _xml(revision="r1"))
|
||||||
|
second = backup_store.save_snapshot(host["id"], _xml(revision="r2"))
|
||||||
|
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()["identical"] is True
|
||||||
|
third = backup_store.save_snapshot(host["id"], _xml(dns="8.8.8.8"))
|
||||||
|
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": first, "b": third})).json()
|
||||||
|
assert (body["a"], body["b"]) == (first, third) and not body["identical"]
|
||||||
|
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()["a"] == second # default: previous vs newest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_errors(client):
|
||||||
|
import uuid
|
||||||
|
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/backup-diff")).status_code == 404
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).status_code == 404 # nothing to compare
|
||||||
|
one = backup_store.save_snapshot(host["id"], _xml())
|
||||||
|
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).status_code == 404 # only one
|
||||||
|
bad = backup_store.save_snapshot(host["id"], b"<html>not a config</html>")
|
||||||
|
resp = await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": one, "b": bad})
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "BACKUP_UNLESBAR"
|
||||||
|
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": "../../etc/passwd", "b": one})).status_code == 404
|
||||||
|
xxe = b'<?xml version="1.0"?><!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><opnsense>&e;</opnsense>'
|
||||||
|
evil = backup_store.save_snapshot(host["id"], xxe)
|
||||||
|
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": one, "b": evil})).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_is_capped(client):
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
many = "".join(f"<dns{i}>1.1.1.{i % 250}</dns{i}>" for i in range(5000))
|
||||||
|
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many}</system></opnsense>".encode())
|
||||||
|
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many.replace('1.1.1', '2.2.2')}</system></opnsense>".encode())
|
||||||
|
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||||
|
assert body["truncated"] is True and len(body["lines"]) == 3000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_hides_otp_seeds_and_uses_a_salt_per_request(client):
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
backup_store.save_snapshot(host["id"], _xml())
|
||||||
|
backup_store.save_snapshot(host["id"], _xml(otp="OTPSEED2", password="p9", dns="9.9.9.9"))
|
||||||
|
first = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||||
|
second = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||||
|
text = "\n".join(first["lines"])
|
||||||
|
for secret in ("OTPSEED1", "OTPSEED2", "PW-p9", "PW-p1"):
|
||||||
|
assert secret not in text, secret
|
||||||
|
import hashlib
|
||||||
|
assert hashlib.sha256(b"OTPSEED1").hexdigest()[:12] not in text # no unsalted hash to run a dictionary against
|
||||||
|
assert first["lines"] != second["lines"] # a fresh salt per request: hashes cannot be matched across requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_host_has_no_default_port_but_keeps_a_custom_one():
|
||||||
|
plain = s3_client.S3Client(endpoint="https://s3.test:443", region="r", bucket="b", access_key="a", secret_key="s")
|
||||||
|
assert plain.netloc == "s3.test" and plain._target("k")[0] == "s3.test"
|
||||||
|
custom = s3_client.S3Client(endpoint="https://s3.test:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||||
|
assert custom.netloc == "s3.test:9000"
|
||||||
@@ -69,6 +69,43 @@ async def test_check_carp_health_healthy_reports_nothing(client, respx_mock):
|
|||||||
assert problems == {}
|
assert problems == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _vips(**states: str) -> dict:
|
||||||
|
return {"rows": [{"vhid": v, "status": st} for v, st in states.items()],
|
||||||
|
"carp": {"allow": "1", "maintenancemode": "0", "demotion": "0"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_carp_health_detects_a_vip_that_is_down_on_one_node(client, respx_mock):
|
||||||
|
# 2026-10-20: VHID 130 was DISABLED on the primary and taken over by the secondary; per host it looked fine.
|
||||||
|
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
|
||||||
|
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "DISABLED"})))
|
||||||
|
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
|
||||||
|
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
|
||||||
|
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
|
||||||
|
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
|
||||||
|
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
problems = await notification_checks.check_carp_health(db)
|
||||||
|
|
||||||
|
assert {p["condition"] for p in problems.values()} == {"vip_down"}
|
||||||
|
assert next(iter(problems.values()))["vhids"] == ["130"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_carp_health_detects_a_partial_failover(client, respx_mock):
|
||||||
|
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
|
||||||
|
return_value=httpx.Response(200, json=_vips(**{"110": "MASTER", "130": "BACKUP"})))
|
||||||
|
respx_mock.get("https://fw-b.test/api/diagnostics/interface/getVipStatus").mock(
|
||||||
|
return_value=httpx.Response(200, json=_vips(**{"110": "BACKUP", "130": "BACKUP"})))
|
||||||
|
await _create_host(client, "fw-a", "https://fw-a.test", "ha")
|
||||||
|
await _create_host(client, "fw-b", "https://fw-b.test", "ha")
|
||||||
|
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
problems = await notification_checks.check_carp_health(db)
|
||||||
|
|
||||||
|
assert [p["condition"] for p in problems.values()] == ["role_mismatch"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_carp_health_ignores_single_reachable_host(client, respx_mock):
|
async def test_check_carp_health_ignores_single_reachable_host(client, respx_mock):
|
||||||
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(return_value=httpx.Response(200, json=_vip("MASTER")))
|
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(return_value=httpx.Response(200, json=_vip("MASTER")))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.services import compliance_service as cs
|
from app.services import compliance_service as cs
|
||||||
from tests.test_api_tokens import _bearer, _make
|
from tests.test_api_tokens import _bearer, _make
|
||||||
@@ -158,6 +159,8 @@ def _mock_host(respx_mock, xml, firmware_status="none"):
|
|||||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=xml))
|
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=xml))
|
||||||
respx_mock.get(url__regex=r".*/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": firmware_status}))
|
respx_mock.get(url__regex=r".*/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": firmware_status}))
|
||||||
|
respx_mock.get(url__regex=r".*/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
|
||||||
|
respx_mock.get(url__regex=r".*/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -212,3 +215,167 @@ async def test_metrics_expose_the_score(client, respx_mock):
|
|||||||
text = (await client.get("/api/metrics")).text
|
text = (await client.get("/api/metrics")).text
|
||||||
assert f'opnexus_host_compliance_score{{host="fw1",cluster=""}} {result["score"]}' in text
|
assert f'opnexus_host_compliance_score{{host="fw1",cluster=""}} {result["score"]}' in text
|
||||||
assert 'opnexus_host_compliance_findings{severity="critical",host="fw1",cluster=""} 1' in text
|
assert 'opnexus_host_compliance_findings{severity="critical",host="fw1",cluster=""} 1' in text
|
||||||
|
|
||||||
|
|
||||||
|
# ── rule usage (pf counters) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
from app.database import SessionLocal # noqa: E402
|
||||||
|
from app.models.host import OpnsenseHost # noqa: E402
|
||||||
|
from app.models.rule_hit import RuleHit # noqa: E402
|
||||||
|
from app.services import rule_hit_service as rh # noqa: E402
|
||||||
|
|
||||||
|
U1, U2, U3 = "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"
|
||||||
|
|
||||||
|
|
||||||
|
def _pf(**packets):
|
||||||
|
"""pfStatistics/rules shaped like the real answer: one entry per pf line, label = rule uuid."""
|
||||||
|
lines = {f'@{i} pass in quick on vtnet0 inet all flags S/SA keep state label "{u}"': {"evaluations": 10, "packets": p, "bytes": p * 100}
|
||||||
|
for i, (u, p) in enumerate(packets.items())}
|
||||||
|
lines['@99 pass out log quick on vtnet0 inet6 proto udp from fe80::/10 to any keep state label "2f2c37025cd02b5cd29fd330f8e48954"'] = {"evaluations": 5, "packets": 9, "bytes": 9}
|
||||||
|
return {"rules": {"filter rules": lines}}
|
||||||
|
|
||||||
|
|
||||||
|
def _search(*uuids, disabled=()):
|
||||||
|
rows = [{"uuid": u, "description": f"rule {u[0]}", "enabled": "0" if u in disabled else "1"} for u in uuids]
|
||||||
|
rows.append({"uuid": "autoautoauto", "description": "Default deny", "enabled": "1", "is_automatic": True})
|
||||||
|
return {"rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pf_counters_are_tied_to_rule_uuids_and_summed_over_expanded_lines():
|
||||||
|
stats = _pf(**{U1: 5})
|
||||||
|
stats["rules"]["filter rules"][f'@7 pass in quick on vtnet0 inet6 all label "{U1}"'] = {"evaluations": 2, "packets": 3, "bytes": 30}
|
||||||
|
got = rh.parse_counters(stats)
|
||||||
|
assert got[U1] == {"packets": 8, "bytes": 530, "evaluations": 12}
|
||||||
|
assert "2f2c37025cd02b5cd29fd330f8e48954" not in got # md5 labels are automatic rules, not user rules
|
||||||
|
assert rh.parse_counters({}) == {} and rh.parse_counters({"rules": None}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_rules_skip_automatic_ones():
|
||||||
|
assert set(rh.configured_rules(_search(U1, U2, disabled=(U2,)))) == {U1, U2}
|
||||||
|
assert rh.configured_rules(_search(U1, U2, disabled=(U2,)))[U2]["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def _sample(host_id, stats, search, at):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
host = await db.get(OpnsenseHost, __import__("uuid").UUID(host_id))
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
with patch.object(rh.client, "get_pf_rule_statistics", AsyncMock(return_value=stats)), \
|
||||||
|
patch.object(rh.client, "search_rules", AsyncMock(return_value=search)):
|
||||||
|
return await rh.sample_host(db, host, now=at)
|
||||||
|
|
||||||
|
|
||||||
|
async def _rows(host_id):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
return {r.rule_uuid: r for r in (await db.scalars(select(RuleHit))).all()}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_last_hit_tracking_survives_counter_resets(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")
|
||||||
|
day0 = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
day = lambda n: day0 + timedelta(days=n) # noqa: E731
|
||||||
|
await _sample(host["id"], _pf(**{U1: 0, U2: 7, U3: 0}), _search(U1, U2, U3), day(0))
|
||||||
|
rows = await _rows(host["id"])
|
||||||
|
assert rows[U1].last_hit_at is None and rows[U2].last_hit_at == day(0) # already matching when first seen
|
||||||
|
await _sample(host["id"], _pf(**{U1: 0, U2: 7, U3: 4}), _search(U1, U2, U3), day(1))
|
||||||
|
rows = await _rows(host["id"])
|
||||||
|
assert rows[U2].last_hit_at == day(0) and rows[U3].last_hit_at == day(1) # U2 flat, U3 grew
|
||||||
|
# pf reloaded (counters restart): U2 is lower but above zero -> hit since the reload; U1 at zero tells nothing
|
||||||
|
await _sample(host["id"], _pf(**{U1: 0, U2: 2, U3: 0}), _search(U1, U2, U3), day(2))
|
||||||
|
rows = await _rows(host["id"])
|
||||||
|
assert rows[U2].last_hit_at == day(2) and rows[U3].last_hit_at == day(1) and rows[U1].last_hit_at is None
|
||||||
|
# a rule that disappeared from the firewall is forgotten
|
||||||
|
await _sample(host["id"], _pf(**{U1: 0, U2: 2}), _search(U1, U2), day(3))
|
||||||
|
assert set(await _rows(host["id"])) == {U1, U2}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unused_only_after_the_observation_window(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")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await _sample(host["id"], _pf(**{U1: 0, U2: 5}), _search(U1, U2, U3, disabled=(U3,)), now - timedelta(days=10))
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
states = {r.rule_uuid: rh.classify(r, now) for r in (await db.scalars(select(RuleHit))).all()}
|
||||||
|
summary = await rh.unused_summary(db, __import__("uuid").UUID(host["id"]))
|
||||||
|
assert states == {U1: "collecting", U2: "used", U3: "disabled"}
|
||||||
|
assert summary == {"unused": [], "collecting": 1}
|
||||||
|
await _sample(host["id"], _pf(**{U1: 0, U2: 5}), _search(U1, U2, U3, disabled=(U3,)), now - timedelta(days=1))
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
row = (await db.scalars(select(RuleHit).where(RuleHit.rule_uuid == U1))).one()
|
||||||
|
row.first_seen_at = now - timedelta(days=45) # watched long enough
|
||||||
|
await db.commit()
|
||||||
|
summary = await rh.unused_summary(db, __import__("uuid").UUID(host["id"]))
|
||||||
|
assert summary["unused"] == ["rule 1"]
|
||||||
|
body = (await client.get(f"/api/hosts/{host['id']}/rule-hits")).json()
|
||||||
|
assert body[0]["state"] == "unused" and body[0]["rule_uuid"] == U1 # unused rules first
|
||||||
|
|
||||||
|
|
||||||
|
def test_unused_finding_is_unknown_while_collecting_and_a_low_finding_when_found():
|
||||||
|
xml = _config()
|
||||||
|
ids = lambda f: {x["id"]: x for x in f} # noqa: E731
|
||||||
|
none_yet = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1))
|
||||||
|
assert "rules_unused" not in none_yet
|
||||||
|
collecting = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": [], "collecting": 3}))
|
||||||
|
assert collecting["rules_unused"]["passed"] is None
|
||||||
|
clean = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": [], "collecting": 0}))
|
||||||
|
assert clean["rules_unused"]["passed"] is True
|
||||||
|
found = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": ["old vpn rule"], "collecting": 0}))
|
||||||
|
assert found["rules_unused"]["passed"] is False and found["rules_unused"]["severity"] == "low"
|
||||||
|
assert found["rules_unused"]["params"] == {"count": 1, "examples": ["old vpn rule"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ── notifications ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_critical_and_high_findings_become_problems_that_resolve_when_fixed(client, respx_mock):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import notification_checks
|
||||||
|
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL"),
|
||||||
|
ssh="<ssh><enabled>enabled</enabled><permitrootlogin>1</permitrootlogin><passwordauth>1</passwordauth></ssh>"))
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await notification_checks.check_compliance(db) == {} # nothing checked yet
|
||||||
|
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
problems = await notification_checks.check_compliance(db)
|
||||||
|
assert set(problems) == {f"{host['id']}:wan_any_any", f"{host['id']}:ssh_hardened"}
|
||||||
|
wan = problems[f"{host['id']}:wan_any_any"]
|
||||||
|
assert wan["host_name"] == "fw1" and wan["schweregrad"] == "kritisch" and wan["beispiele"] == "Allow ALL" and wan["befund"].startswith("Keine Allow-all")
|
||||||
|
# The firewall is fixed: next check, the problems are gone (-> "resolved").
|
||||||
|
respx_mock.clear()
|
||||||
|
_mock_host(respx_mock, _config())
|
||||||
|
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await notification_checks.check_compliance(db) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alerts_are_off_by_default_and_unreachable_hosts_raise_none(client, respx_mock):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import notification_checks
|
||||||
|
assert (await client.get("/api/notifications/settings")).json()["compliance_alerts_enabled"] is False
|
||||||
|
assert (await client.put("/api/notifications/settings", json={"compliance_alerts_enabled": True})).json()["compliance_alerts_enabled"] is True
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(500))
|
||||||
|
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await notification_checks.check_compliance(db) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_truncated_config_does_not_stop_the_others(client, respx_mock):
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
bad = await _create_host(client, "a-bad", "https://bad.test")
|
||||||
|
good = await _create_host(client, "b-good", "https://good.test")
|
||||||
|
for base, body in (("bad", b"<opnsense><system><webgui>"), ("good", _config())):
|
||||||
|
respx_mock.get(f"https://{base}.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=body))
|
||||||
|
respx_mock.get(f"https://{base}.test/api/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": "none"}))
|
||||||
|
respx_mock.get(f"https://{base}.test/api/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
|
||||||
|
respx_mock.get(f"https://{base}.test/api/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
resp = await client.post("/api/compliance/check")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
by_name = {h["host_name"]: h for h in resp.json()}
|
||||||
|
assert by_name["a-bad"]["error"] == "invalid_config" and by_name["b-good"]["score"] is not None
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Full OPNexus backup: round trip, integrity, refusal of hostile or newer archives."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import full_backup as fb
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import backup_crypto
|
||||||
|
|
||||||
|
PASS = "full backup passphrase 1"
|
||||||
|
old_mtime = 1_700_000_000 # Nov 2023
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def data(tmp_path, monkeypatch):
|
||||||
|
"""Point every data directory at a scratch tree with recognisable content."""
|
||||||
|
for name, attr in fb._DATA_DIRS.items():
|
||||||
|
path = tmp_path / name
|
||||||
|
path.mkdir()
|
||||||
|
monkeypatch.setattr(settings, attr, str(path))
|
||||||
|
(tmp_path / "opnsense_secrets" / "host-1.secret").write_text("API-SECRET-1")
|
||||||
|
(tmp_path / "totp_secrets").joinpath("u.secret").write_text("TOTP-SECRET")
|
||||||
|
sub = tmp_path / "opnsense_backups" / "host-1"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "20260101T000000.xml").write_text("<opnsense/>")
|
||||||
|
os.utime(sub / "20260101T000000.xml", (old_mtime, old_mtime))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(path: Path) -> None:
|
||||||
|
path.write_bytes(b"PGDMP-fake-database")
|
||||||
|
|
||||||
|
|
||||||
|
def _create(**kw):
|
||||||
|
return fb.create(PASS, dump=_dump, revision="0009", **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_restores_every_secret_and_the_database(data):
|
||||||
|
blob = _create()
|
||||||
|
assert b"API-SECRET-1" not in blob and b"TOTP-SECRET" not in blob and b"PGDMP" not in blob
|
||||||
|
# Wipe the data, then restore.
|
||||||
|
for f in data.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink()
|
||||||
|
seen = {}
|
||||||
|
result = fb.restore(blob, PASS, restore_db=lambda p: seen.update(dump=p.read_bytes()), head="0012")
|
||||||
|
assert seen["dump"] == b"PGDMP-fake-database"
|
||||||
|
assert (data / "opnsense_secrets" / "host-1.secret").read_text() == "API-SECRET-1"
|
||||||
|
assert (data / "totp_secrets" / "u.secret").read_text() == "TOTP-SECRET"
|
||||||
|
assert (data / "opnsense_backups" / "host-1" / "20260101T000000.xml").read_text() == "<opnsense/>"
|
||||||
|
assert result["files_restored"] == 3 and result["db_revision"] == "0009"
|
||||||
|
mode = (data / "opnsense_secrets" / "host-1.secret").stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600
|
||||||
|
# file times survive: stored config backups are aged by their mtime
|
||||||
|
assert abs((data / "opnsense_backups" / "host-1" / "20260101T000000.xml").stat().st_mtime - old_mtime) < 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_shows_only_the_manifest_and_needs_the_passphrase(data):
|
||||||
|
blob = _create()
|
||||||
|
manifest = fb.info(blob, PASS)
|
||||||
|
assert manifest["format"] == 1 and manifest["db_revision"] == "0009" and "opnsense_secrets" in manifest["data"]
|
||||||
|
with pytest.raises(fb.FullBackupError, match="Passphrase"):
|
||||||
|
fb.info(blob, "wrong passphrase 123")
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_passphrase_is_refused(data):
|
||||||
|
with pytest.raises(fb.FullBackupError, match="mindestens"):
|
||||||
|
fb.create("short", dump=_dump, revision="0009")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tampered_or_foreign_files_are_refused(data):
|
||||||
|
blob = bytearray(_create())
|
||||||
|
blob[-5] ^= 0xFF
|
||||||
|
with pytest.raises(fb.FullBackupError):
|
||||||
|
fb.restore(bytes(blob), PASS, restore_db=lambda p: None, head="0012")
|
||||||
|
for junk in (b"", b"not a backup"):
|
||||||
|
with pytest.raises(fb.FullBackupError):
|
||||||
|
fb.restore(junk, PASS, restore_db=lambda p: None, head="0012")
|
||||||
|
# A valid encrypted file that is not a full backup (a single config backup, say).
|
||||||
|
with pytest.raises(fb.FullBackupError, match="Archiv unlesbar"):
|
||||||
|
fb.restore(backup_crypto.encrypt(b"<opnsense/>", PASS), PASS, restore_db=lambda p: None, head="0012")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_backup_from_a_newer_version_is_refused_before_anything_is_touched(data):
|
||||||
|
blob = fb.create(PASS, dump=_dump, revision="0020")
|
||||||
|
touched = []
|
||||||
|
with pytest.raises(fb.FullBackupError, match="neueren"):
|
||||||
|
fb.restore(blob, PASS, restore_db=lambda p: touched.append(p), head="0012")
|
||||||
|
assert touched == []
|
||||||
|
(data / "totp_secrets" / "u.secret").write_text("UNCHANGED")
|
||||||
|
assert (data / "totp_secrets" / "u.secret").read_text() == "UNCHANGED"
|
||||||
|
|
||||||
|
|
||||||
|
def _hostile(member_name: str, kind=tarfile.REGTYPE, link=""):
|
||||||
|
plain = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=plain, mode="w:gz") as tar:
|
||||||
|
manifest = json.dumps({"format": 1, "db_revision": "0001"}).encode()
|
||||||
|
info = tarfile.TarInfo("manifest.json"); info.size = len(manifest)
|
||||||
|
tar.addfile(info, io.BytesIO(manifest))
|
||||||
|
dump = tarfile.TarInfo("database.dump"); dump.size = 1
|
||||||
|
tar.addfile(dump, io.BytesIO(b"x"))
|
||||||
|
evil = tarfile.TarInfo(member_name); evil.type = kind; evil.linkname = link
|
||||||
|
evil.size = 4 if kind == tarfile.REGTYPE else 0
|
||||||
|
tar.addfile(evil, io.BytesIO(b"evil") if kind == tarfile.REGTYPE else None)
|
||||||
|
return backup_crypto.encrypt(plain.getvalue(), PASS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,kind,link", [
|
||||||
|
("../../etc/cron.d/x", tarfile.REGTYPE, ""),
|
||||||
|
("/etc/passwd", tarfile.REGTYPE, ""),
|
||||||
|
("data/opnsense_secrets/../../escape", tarfile.REGTYPE, ""),
|
||||||
|
("data/opnsense_secrets/link", tarfile.SYMTYPE, "/etc/shadow"),
|
||||||
|
("data/opnsense_secrets/dev", tarfile.CHRTYPE, ""),
|
||||||
|
])
|
||||||
|
def test_hostile_archives_are_rejected_before_the_database_is_touched(data, name, kind, link):
|
||||||
|
touched = []
|
||||||
|
with pytest.raises(fb.FullBackupError, match="Unzulässig"):
|
||||||
|
fb.restore(_hostile(name, kind, link), PASS, restore_db=lambda p: touched.append(p), head="0012")
|
||||||
|
assert touched == [] and not (data.parent / "escape").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_directories_in_the_archive_are_ignored(data, tmp_path):
|
||||||
|
blob = _hostile("data/not_a_known_volume/file")
|
||||||
|
fb.restore(blob, PASS, restore_db=lambda p: None, head="0012")
|
||||||
|
assert not (tmp_path / "not_a_known_volume").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_line_reads_the_passphrase_from_the_environment(data, monkeypatch, capsys):
|
||||||
|
blob = _create()
|
||||||
|
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", PASS)
|
||||||
|
class _In: # sys.stdin stand-in with a binary buffer
|
||||||
|
buffer = io.BytesIO(blob)
|
||||||
|
monkeypatch.setattr("sys.stdin", _In)
|
||||||
|
assert fb._main(["full_backup", "info"]) == 0
|
||||||
|
assert '"format": 1' in capsys.readouterr().out
|
||||||
|
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", "")
|
||||||
|
assert fb._main(["full_backup", "create"]) == 1
|
||||||
|
assert fb._main(["full_backup", "bogus"]) == 2
|
||||||
@@ -192,6 +192,7 @@ async def test_vip_password_never_leaves_the_server(client, respx_mock):
|
|||||||
_backup(respx_mock)
|
_backup(respx_mock)
|
||||||
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
|
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
|
||||||
return_value=httpx.Response(200, json={"result": "saved"}))
|
return_value=httpx.Response(200, json={"result": "saved"}))
|
||||||
|
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "10", "status": "BACKUP"}]}))
|
||||||
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
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"}}
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"password": "NEW-SECRET", "advskew": "100"}}
|
||||||
resp = await _preview(client, host_id, **change)
|
resp = await _preview(client, host_id, **change)
|
||||||
@@ -462,6 +463,45 @@ async def test_unlisted_secret_fields_never_reach_the_audit_log(client, respx_mo
|
|||||||
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
|
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vip_description_edit_does_not_reapply_the_vip(client, respx_mock):
|
||||||
|
# Re-applying a CARP VIP for a description edit left it in INIT on both prod nodes (2026-10-20).
|
||||||
|
host_id = await _setup(client, respx_mock, "fw-v2")
|
||||||
|
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
||||||
|
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
||||||
|
"vhid": "12", "advskew": "0", "descr": "old"})
|
||||||
|
_backup(respx_mock)
|
||||||
|
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||||
|
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||||
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"descr": "new"}}
|
||||||
|
plan = (await _preview(client, host_id, **change)).json()
|
||||||
|
done = await _execute(client, host_id, plan["token"], **change)
|
||||||
|
assert done.status_code == 200, done.text
|
||||||
|
assert not reconf.called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vip_edit_reports_a_vip_that_is_not_live(client, respx_mock, monkeypatch):
|
||||||
|
async def no_sleep(_):
|
||||||
|
return None
|
||||||
|
monkeypatch.setattr("app.services.network_change_service.asyncio.sleep", no_sleep)
|
||||||
|
host_id = await _setup(client, respx_mock, "fw-v3")
|
||||||
|
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
||||||
|
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
||||||
|
"vhid": "13", "advskew": "0", "descr": "x"})
|
||||||
|
_backup(respx_mock)
|
||||||
|
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||||
|
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||||
|
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(
|
||||||
|
return_value=httpx.Response(200, json={"rows": [{"vhid": "13", "status": "DISABLED"}]}))
|
||||||
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
||||||
|
plan = (await _preview(client, host_id, **change)).json()
|
||||||
|
done = await _execute(client, host_id, plan["token"], **change)
|
||||||
|
assert done.status_code == 200, done.text
|
||||||
|
assert done.json()["verified"] is False and "carp_vip_inactive" in done.json()["mismatches"]
|
||||||
|
assert reconf.call_count == 2 # one retry
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
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).
|
# OPNsense blanks a VIP's address when an update leaves out "network" (seen live on 26.7.6).
|
||||||
@@ -471,12 +511,15 @@ async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
|||||||
"vhid": "11", "advskew": "0", "descr": "x"})
|
"vhid": "11", "advskew": "0", "descr": "x"})
|
||||||
_backup(respx_mock)
|
_backup(respx_mock)
|
||||||
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
||||||
|
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "11", "status": "BACKUP"}]}))
|
||||||
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
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"}}
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
||||||
plan = (await _preview(client, host_id, **change)).json()
|
plan = (await _preview(client, host_id, **change)).json()
|
||||||
done = await _execute(client, host_id, plan["token"], **change)
|
done = await _execute(client, host_id, plan["token"], **change)
|
||||||
assert done.status_code == 200, done.text
|
assert done.status_code == 200, done.text
|
||||||
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24"}
|
# ...and the interface: OPNsense queues a delete of the live address when the posted interface differs
|
||||||
|
# from the stored one, and the reconfigure then drops the VIP (seen live 2026-10-20).
|
||||||
|
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24", "interface": "lan"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""OIDC single sign-on against a fake identity provider (respx): the happy path and every way a forged or
|
||||||
|
confused login could succeed."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from jwt.algorithms import RSAAlgorithm
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.oidc import OidcLoginState
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.services import notification_secret_store, oidc_service
|
||||||
|
from tests.test_api_tokens import _login
|
||||||
|
|
||||||
|
ISSUER = "https://idp.test/realm"
|
||||||
|
CLIENT = "opnexus-client"
|
||||||
|
|
||||||
|
|
||||||
|
def _keypair(kid):
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
|
||||||
|
jwk = json.loads(RSAAlgorithm.to_jwk(key.public_key()))
|
||||||
|
jwk.update(kid=kid, use="sig", alg="RS256")
|
||||||
|
return pem, jwk
|
||||||
|
|
||||||
|
|
||||||
|
KEY, JWK = _keypair("k1")
|
||||||
|
OTHER_KEY, _ = _keypair("k1") # same kid, different key: a forger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _fresh():
|
||||||
|
oidc_service.clear_caches()
|
||||||
|
notification_secret_store.delete_secret(oidc_service.SECRET_NAME)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
await db.execute(OidcLoginState.__table__.delete())
|
||||||
|
await db.execute(AdminUser.__table__.delete().where(AdminUser.auth_source == "oidc"))
|
||||||
|
await db.commit()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def _configure(client, **extra):
|
||||||
|
body = {"enabled": True, "display_name": "Authentik", "issuer": ISSUER, "client_id": CLIENT, "client_secret": "s3cret",
|
||||||
|
"admin_group": "opnexus-admins", "operator_group": "opnexus-ops", "read_only_group": "opnexus-viewers", **extra}
|
||||||
|
resp = await client.put("/api/settings/oidc", json=body)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_idp(respx_mock, *, userinfo=None):
|
||||||
|
respx_mock.get(f"{ISSUER}/.well-known/openid-configuration").mock(return_value=httpx.Response(200, json={
|
||||||
|
"issuer": ISSUER, "authorization_endpoint": f"{ISSUER}/auth", "token_endpoint": f"{ISSUER}/token",
|
||||||
|
"jwks_uri": f"{ISSUER}/jwks", **({"userinfo_endpoint": f"{ISSUER}/userinfo"} if userinfo else {}),
|
||||||
|
"token_endpoint_auth_methods_supported": ["client_secret_basic"]}))
|
||||||
|
respx_mock.get(f"{ISSUER}/jwks").mock(return_value=httpx.Response(200, json={"keys": [JWK]}))
|
||||||
|
if userinfo is not None:
|
||||||
|
respx_mock.get(f"{ISSUER}/userinfo").mock(return_value=httpx.Response(200, json=userinfo))
|
||||||
|
|
||||||
|
|
||||||
|
def _claims(sent_nonce, **over):
|
||||||
|
now = int(time.time())
|
||||||
|
base = {"iss": ISSUER, "aud": CLIENT, "sub": "user-1", "exp": now + 300, "iat": now, "nonce": sent_nonce,
|
||||||
|
"preferred_username": "alice", "groups": ["opnexus-admins"]}
|
||||||
|
base.update(over)
|
||||||
|
return {k: v for k, v in base.items() if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _id_token(claims, key=KEY, alg="RS256", kid="k1"):
|
||||||
|
return jwt.encode(claims, key, algorithm=alg, headers={"kid": kid})
|
||||||
|
|
||||||
|
|
||||||
|
async def _start(anon, respx_mock, **idp):
|
||||||
|
_mock_idp(respx_mock, **idp)
|
||||||
|
resp = await anon.get("/api/auth/oidc/start", params={"origin": "http://test"})
|
||||||
|
assert resp.status_code == 302, resp.text
|
||||||
|
location = urlsplit(resp.headers["location"])
|
||||||
|
assert f"{location.scheme}://{location.netloc}{location.path}" == f"{ISSUER}/auth"
|
||||||
|
return {k: v[0] for k, v in parse_qs(location.query).items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _callback(anon, respx_mock, query, *, claims=None, token_response=None, **token_kw):
|
||||||
|
"""Drive the callback: the fake token endpoint answers with an ID token for the nonce of this attempt."""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def token(request):
|
||||||
|
seen["form"] = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||||
|
seen["auth"] = request.headers.get("authorization")
|
||||||
|
if token_response is not None:
|
||||||
|
return httpx.Response(200, json=token_response)
|
||||||
|
return httpx.Response(200, json={"id_token": _id_token(claims if claims is not None else _claims(query["nonce"]), **token_kw), "access_token": "at"})
|
||||||
|
|
||||||
|
respx_mock.post(f"{ISSUER}/token").mock(side_effect=token)
|
||||||
|
resp = await anon.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
|
||||||
|
return resp, seen
|
||||||
|
|
||||||
|
|
||||||
|
def _error(resp):
|
||||||
|
assert resp.status_code == 302, resp.text
|
||||||
|
loc = resp.headers["location"]
|
||||||
|
assert loc.startswith("/login?sso_error="), loc
|
||||||
|
return loc.split("=", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
async def _users(prefix):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
return {u.username: u for u in (await db.scalars(select(AdminUser).where(AdminUser.auth_source == "oidc"))).all()}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_creates_the_account_with_the_role_of_the_group_and_a_working_session(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
assert query["code_challenge_method"] == "S256" and query["redirect_uri"] == "http://test/api/auth/oidc/callback"
|
||||||
|
assert query["client_id"] == CLIENT and "openid" in query["scope"]
|
||||||
|
resp, seen = await _callback(anon_client, respx_mock, query)
|
||||||
|
assert resp.status_code == 302 and resp.headers["location"] == "/dashboard"
|
||||||
|
# The provider got the PKCE verifier matching the challenge, the redirect URI and the client secret (HTTP basic).
|
||||||
|
digest = base64.urlsafe_b64encode(hashlib.sha256(seen["form"]["code_verifier"].encode()).digest()).rstrip(b"=").decode()
|
||||||
|
assert digest == query["code_challenge"] and seen["form"]["redirect_uri"] == query["redirect_uri"]
|
||||||
|
assert seen["auth"] == "Basic " + base64.b64encode(f"{CLIENT}:s3cret".encode()).decode()
|
||||||
|
me = (await anon_client.get("/api/auth/me")).json()
|
||||||
|
assert me == {"authenticated": True, "username": "alice", "role": "admin"}
|
||||||
|
user = (await _users("alice"))["alice"]
|
||||||
|
assert user.auth_source == "oidc" and user.oidc_subject == f"{ISSUER}|user-1" and user.last_login_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_role_follows_the_groups_on_every_login_and_the_most_privileged_wins(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
for groups, expected in ((["opnexus-viewers"], "read_only"), (["opnexus-viewers", "opnexus-ops"], "operator"),
|
||||||
|
(["OPNEXUS-ADMINS", "x"], "admin"), (["opnexus-ops"], "operator")):
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=groups))
|
||||||
|
assert resp.headers["location"] == "/dashboard"
|
||||||
|
assert (await _users("alice"))["alice"].role == expected, groups
|
||||||
|
assert len(await _users("alice")) == 1 # same identity, same account
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_without_a_mapped_group_nobody_gets_in_and_no_account_is_created(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
for groups in ([], ["random"], None):
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=groups))
|
||||||
|
assert _error(resp) == "no_role"
|
||||||
|
assert await _users("alice") == {}
|
||||||
|
assert (await anon_client.get("/api/auth/me")).json() == {"authenticated": False}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_groups_can_come_from_userinfo_but_only_for_the_same_subject(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock, userinfo={"sub": "user-1", "groups": ["opnexus-ops"]})
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=None))
|
||||||
|
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "operator"
|
||||||
|
respx_mock.clear()
|
||||||
|
query = await _start(anon_client, respx_mock, userinfo={"sub": "someone-else", "groups": ["opnexus-admins"]})
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=None, sub="user-2"))
|
||||||
|
assert _error(resp) == "no_role"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_username_comes_from_userinfo_if_the_id_token_has_none_and_a_failing_userinfo_does_not_block_the_login(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
# Authelia keeps preferred_username/email out of the ID token; without userinfo the account would be named after the opaque sub.
|
||||||
|
query = await _start(anon_client, respx_mock, userinfo={"sub": "user-1", "preferred_username": "alice", "email": "[email protected]"})
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None))
|
||||||
|
assert resp.headers["location"] == "/dashboard" and list(await _users("alice")) == ["alice"]
|
||||||
|
respx_mock.clear()
|
||||||
|
query = await _start(anon_client, respx_mock, userinfo={"sub": "someone-else", "preferred_username": "mallory"})
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username=None, sub="user-2"))
|
||||||
|
assert resp.headers["location"] == "/dashboard" and "mallory" not in await _users("x") and "user-2" in await _users("x")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers"))
|
||||||
|
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "read_only"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("label,over", [
|
||||||
|
("wrong audience", {"aud": "someone-elses-client"}),
|
||||||
|
("wrong issuer", {"iss": "https://evil.test/realm"}),
|
||||||
|
("expired", {"exp": int(time.time()) - 3600}),
|
||||||
|
("no expiry", {"exp": None}),
|
||||||
|
("wrong nonce", {"nonce": "not-the-one-we-sent"}),
|
||||||
|
("no nonce", {"nonce": None}),
|
||||||
|
("multi audience, wrong azp", {"aud": [CLIENT, "other"], "azp": "other"}),
|
||||||
|
])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forged_or_confused_id_tokens_are_rejected(client, anon_client, respx_mock, label, over):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], **over))
|
||||||
|
assert _error(resp) == "token", label
|
||||||
|
assert await _users("alice") == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signature_attacks_are_rejected(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
public_pem = serialization.load_pem_public_key # noqa: F841 (documenting the confusion attack below)
|
||||||
|
cases = {
|
||||||
|
"signed with another key": dict(key=OTHER_KEY),
|
||||||
|
"unknown key id": dict(kid="nope"),
|
||||||
|
"alg none": None,
|
||||||
|
"hmac with the public key as secret": "hs",
|
||||||
|
}
|
||||||
|
for label, spec in cases.items():
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
claims = _claims(query["nonce"])
|
||||||
|
if spec is None:
|
||||||
|
token = jwt.encode(claims, None, algorithm="none", headers={"kid": "k1"})
|
||||||
|
elif spec == "hs":
|
||||||
|
token = jwt.encode(claims, json.dumps(JWK), algorithm="HS256", headers={"kid": "k1"})
|
||||||
|
else:
|
||||||
|
token = _id_token(claims, **spec)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, token_response={"id_token": token})
|
||||||
|
assert _error(resp) == "token", label
|
||||||
|
oidc_service.clear_caches()
|
||||||
|
respx_mock.clear()
|
||||||
|
assert await _users("alice") == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_state_works_exactly_once_and_unknown_or_expired_ones_fail(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
first, _ = await _callback(anon_client, respx_mock, query)
|
||||||
|
assert first.headers["location"] == "/dashboard"
|
||||||
|
replay = await anon_client.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
|
||||||
|
assert _error(replay) == "state"
|
||||||
|
forged = await anon_client.get("/api/auth/oidc/callback", params={"code": "abc", "state": "made-up"})
|
||||||
|
assert _error(forged) == "state"
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
await db.execute(update(OidcLoginState).values(created_at=datetime.now(timezone.utc) - timedelta(minutes=30)))
|
||||||
|
await db.commit()
|
||||||
|
expired, _ = await _callback(anon_client, respx_mock, query)
|
||||||
|
assert _error(expired) == "state"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_errors_and_missing_parameters(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
denied = await anon_client.get("/api/auth/oidc/callback", params={"error": "access_denied", "state": query["state"]})
|
||||||
|
assert _error(denied) == "provider"
|
||||||
|
assert _error(await anon_client.get("/api/auth/oidc/callback")) == "provider"
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(400, json={"error": "invalid_grant"}))
|
||||||
|
bad = await anon_client.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
|
||||||
|
assert _error(bad) == "token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_sso_identity_never_takes_over_a_local_account(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username="admin", sub="evil-1"))
|
||||||
|
assert resp.headers["location"] == "/dashboard"
|
||||||
|
users = await _users("admin")
|
||||||
|
assert "admin" not in users and any(n.startswith("admin~sso") for n in users)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
local = (await db.scalars(select(AdminUser).where(AdminUser.username == "admin"))).one()
|
||||||
|
assert local.auth_source == "local" and local.oidc_subject is None and local.role == "admin"
|
||||||
|
# A second identity with the same preferred name gets yet another account.
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username="admin", sub="evil-2"))
|
||||||
|
assert len([n for n in await _users("admin") if n.startswith("admin~sso")]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sso_accounts_have_no_usable_password_and_cannot_be_managed_locally(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
await _callback(anon_client, respx_mock, query)
|
||||||
|
for password in ("", "!oidc", "alice", "!"):
|
||||||
|
resp = await anon_client.post("/api/auth/login", json={"username": "alice", "password": password or "x"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
listing = {u["username"]: u for u in (await client.get("/api/users")).json()}
|
||||||
|
uid = listing["alice"]["id"]
|
||||||
|
assert listing["alice"]["auth_source"] == "oidc"
|
||||||
|
for path, body, method in ((f"/api/users/{uid}/role", {"role": "read_only"}, "PATCH"),
|
||||||
|
(f"/api/users/{uid}/reset-password", {"new_password": "a-new-password-1"}, "POST")):
|
||||||
|
resp = await client.request(method, path, json=body)
|
||||||
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "OIDC_MANAGED_USER"
|
||||||
|
assert (await client.delete(f"/api/users/{uid}")).status_code == 204 # deleting is fine; they can sign in again
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_refuses_foreign_origins_and_disabled_sso(client, anon_client, respx_mock):
|
||||||
|
assert _error(await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})) == "disabled"
|
||||||
|
await _configure(client)
|
||||||
|
_mock_idp(respx_mock)
|
||||||
|
for origin in ("http://evil.test", "http://test/path", "http://user@test", "javascript:alert(1)", "http://test?x=1"):
|
||||||
|
assert _error(await anon_client.get("/api/auth/oidc/start", params={"origin": origin})) == "disabled", origin
|
||||||
|
await client.put("/api/settings/oidc", json={"enabled": False})
|
||||||
|
assert _error(await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})) == "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_config_reveals_only_the_button_text(client, anon_client):
|
||||||
|
assert (await anon_client.get("/api/auth/oidc/config")).json() == {"enabled": False, "name": None}
|
||||||
|
await _configure(client)
|
||||||
|
assert (await anon_client.get("/api/auth/oidc/config")).json() == {"enabled": True, "name": "Authentik"}
|
||||||
|
await client.put("/api/settings/oidc", json={"admin_group": "", "operator_group": "", "read_only_group": ""})
|
||||||
|
assert (await anon_client.get("/api/auth/oidc/config")).json()["enabled"] is False # no group mapping = nobody could enter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_are_admin_only_hide_the_secret_and_validate(client):
|
||||||
|
await _configure(client)
|
||||||
|
got = (await client.get("/api/settings/oidc")).json()
|
||||||
|
assert got["client_secret_set"] is True and "s3cret" not in json.dumps(got)
|
||||||
|
assert notification_secret_store.read_secret(oidc_service.SECRET_NAME) == "s3cret"
|
||||||
|
assert (await client.put("/api/settings/oidc", json={"issuer": "http://idp.test"})).status_code == 422
|
||||||
|
assert (await client.put("/api/settings/oidc", json={"clear_client_secret": True})).json()["client_secret_set"] is False
|
||||||
|
for role in ("operator", "read_only"):
|
||||||
|
name = f"sso-{role}"
|
||||||
|
assert (await client.post("/api/users", json={"username": name, "password": "sso-password-123", "role": role})).status_code == 201
|
||||||
|
user = await _login(name, "sso-password-123")
|
||||||
|
try:
|
||||||
|
assert (await user.get("/api/settings/oidc")).status_code == 403
|
||||||
|
assert (await user.put("/api/settings/oidc", json={"enabled": False})).status_code == 403
|
||||||
|
assert (await user.post("/api/settings/oidc/test")).status_code == 403
|
||||||
|
finally:
|
||||||
|
await user.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connection_test(client, respx_mock):
|
||||||
|
assert (await client.post("/api/settings/oidc/test")).status_code == 422
|
||||||
|
await _configure(client)
|
||||||
|
_mock_idp(respx_mock)
|
||||||
|
assert (await client.post("/api/settings/oidc/test")).json()["authorization_endpoint"] == f"{ISSUER}/auth"
|
||||||
|
respx_mock.clear()
|
||||||
|
respx_mock.get(f"{ISSUER}/.well-known/openid-configuration").mock(return_value=httpx.Response(200, json={"issuer": "https://other.test"}))
|
||||||
|
resp = await client.post("/api/settings/oidc/test")
|
||||||
|
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "OIDC_TEST_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_callbacks_are_throttled(client, anon_client, respx_mock):
|
||||||
|
await _configure(client)
|
||||||
|
results = [_error(await anon_client.get("/api/auth/oidc/callback", params={"code": "x", "state": f"s{i}"})) for i in range(12)]
|
||||||
|
assert "throttled" in results
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_string_group_claim_keeps_names_with_spaces_together(client, anon_client, respx_mock):
|
||||||
|
await _configure(client, admin_group="Network Admins", operator_group="Admins", read_only_group="")
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network Admins"))
|
||||||
|
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "admin" # not "Admins" = operator, not split
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="Network, Team"))
|
||||||
|
assert _error(resp) == "no_role"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_callback_only_works_in_the_browser_that_started_the_login(client, anon_client, respx_mock):
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from app.main import app
|
||||||
|
await _configure(client)
|
||||||
|
query = await _start(anon_client, respx_mock)
|
||||||
|
# Somebody else (another browser, no state cookie) opens the callback URL: refused, and the state is not burned for the owner.
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as stranger:
|
||||||
|
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(200, json={"id_token": _id_token(_claims(query["nonce"]))}))
|
||||||
|
resp = await stranger.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
|
||||||
|
assert _error(resp) == "state"
|
||||||
|
assert await _users("alice") == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sso_sessions_are_short_and_the_state_cookie_is_httponly(client, anon_client, respx_mock):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from app.models.user import Session
|
||||||
|
await _configure(client)
|
||||||
|
_mock_idp(respx_mock)
|
||||||
|
start = await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})
|
||||||
|
cookie = start.headers["set-cookie"].lower()
|
||||||
|
assert "opnexus-oidc-state=" in cookie and "httponly" in cookie and "samesite=lax" in cookie and "path=/api/auth/oidc" in cookie
|
||||||
|
query = {k: v[0] for k, v in parse_qs(urlsplit(start.headers["location"]).query).items()}
|
||||||
|
resp, _ = await _callback(anon_client, respx_mock, query)
|
||||||
|
assert resp.headers["location"] == "/dashboard"
|
||||||
|
assert "max-age=43200" in resp.headers["set-cookie"].lower()
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
user = (await _users("alice"))["alice"]
|
||||||
|
session = (await db.scalars(select(Session).where(Session.user_id == user.id))).one()
|
||||||
|
assert session.expires_at - datetime.now(timezone.utc) < timedelta(hours=12, minutes=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_error_text_from_the_provider_cannot_forge_log_lines(client, anon_client, caplog):
|
||||||
|
await _configure(client)
|
||||||
|
import logging
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
await anon_client.get("/api/auth/oidc/callback", params={"error": "x\r\nFAKE LOG LINE\x1b[31m", "state": "s"})
|
||||||
|
text = "\n".join(r.getMessage() for r in caplog.records)
|
||||||
|
assert "FAKE LOG LINE" in text and "\r" not in text and "\x1b" not in text and "\nFAKE" not in text
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""The operator role: rolls out and operates, but cannot change access or destinations."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from tests.test_api_tokens import _bearer, _login
|
||||||
|
|
||||||
|
PW = "operator-password-1"
|
||||||
|
|
||||||
|
|
||||||
|
async def _operator(client, name="oper"):
|
||||||
|
resp = await client.post("/api/users", json={"username": name, "password": PW, "role": "operator"})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["role"] == "operator"
|
||||||
|
return await _login(name, PW)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_operator_can_deploy_but_not_manage_access(client):
|
||||||
|
op = await _operator(client)
|
||||||
|
try:
|
||||||
|
assert (await op.post("/api/groups", json={"name": "ops-group"})).status_code == 201
|
||||||
|
for method, path, body in [
|
||||||
|
("POST", "/api/users", {"username": "x", "password": "x" * 12, "role": "admin"}),
|
||||||
|
("PATCH", "/api/users/00000000-0000-0000-0000-000000000000/role", {"role": "admin"}),
|
||||||
|
("POST", "/api/hosts", {}),
|
||||||
|
("PUT", "/api/notifications/settings", {}),
|
||||||
|
("POST", "/api/system/updater/update", {}),
|
||||||
|
]:
|
||||||
|
resp = await op.request(method, path, json=body)
|
||||||
|
assert resp.status_code == 403, (path, resp.text)
|
||||||
|
assert resp.json()["detail"]["code"] == "ADMIN_REQUIRED"
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_operator_keeps_self_service(client):
|
||||||
|
op = await _operator(client, "selfserve")
|
||||||
|
try:
|
||||||
|
assert (await op.put("/api/settings", json={"theme": "dark"})).status_code == 200
|
||||||
|
resp = await op.post("/api/auth/change-password", json={"current_password": PW, "new_password": "another-password-1"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert (await op.get("/api/users")).status_code == 200
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_operator_tokens_are_capped_at_deploy_and_inherit_the_limits(client):
|
||||||
|
op = await _operator(client, "tokop")
|
||||||
|
try:
|
||||||
|
denied = await op.post("/api/tokens", json={"name": "a", "scope": "admin", "current_password": PW})
|
||||||
|
assert denied.status_code == 403 and denied.json()["detail"]["code"] == "API_TOKEN_SCOPE_TOO_HIGH"
|
||||||
|
made = await op.post("/api/tokens", json={"name": "ci", "scope": "deploy", "current_password": PW})
|
||||||
|
assert made.status_code == 201, made.text
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
async with _bearer(made.json()["token"]) as api:
|
||||||
|
assert (await api.post("/api/groups", json={"name": "from-op-token"})).status_code == 201
|
||||||
|
assert (await api.post("/api/users", json={"username": "y", "password": "y" * 12})).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_operator_cannot_download_raw_backups(client, respx_mock):
|
||||||
|
import httpx
|
||||||
|
from tests.test_rules import _create_host
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
op = await _operator(client, "nobackup")
|
||||||
|
try:
|
||||||
|
resp = await op.get(f"/api/hosts/{host['id']}/backups/anything.xml")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refused_operator_writes_are_audited(client):
|
||||||
|
op = await _operator(client, "audited")
|
||||||
|
try:
|
||||||
|
await op.post("/api/users", json={"username": "z", "password": "z" * 12})
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.actor == "audited", AuditEvent.status == "denied"))).all()
|
||||||
|
assert rows and rows[0].kind == "user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_demoting_an_admin_to_operator_takes_effect_at_once(client):
|
||||||
|
resp = await client.post("/api/users", json={"username": "wasadmin", "password": PW, "role": "admin"})
|
||||||
|
uid = resp.json()["id"]
|
||||||
|
user = await _login("wasadmin", PW)
|
||||||
|
try:
|
||||||
|
assert (await user.post("/api/groups", json={"name": "g1"})).status_code == 201
|
||||||
|
assert (await client.patch(f"/api/users/{uid}/role", json={"role": "operator"})).status_code == 200
|
||||||
|
assert (await user.post("/api/users", json={"username": "w", "password": "w" * 12})).status_code == 403
|
||||||
|
finally:
|
||||||
|
await user.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── separation of duties ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path,rollout", [
|
||||||
|
("POST", "/api/rules/abc/deploy", True),
|
||||||
|
("POST", "/api/aliases/abc/deploy", True),
|
||||||
|
("POST", "/api/port-forward/abc/deploy", True),
|
||||||
|
("POST", "/api/rules/abc/deployments/def/remove", True),
|
||||||
|
("POST", "/api/rules/abc/deployments/def/retry-rollback", True),
|
||||||
|
("POST", "/api/hosts/abc/maintenance/execute", True),
|
||||||
|
("POST", "/api/hosts/abc/maintenance/carp-execute", True),
|
||||||
|
("POST", "/api/hosts/abc/maintenance/schedule", True),
|
||||||
|
("POST", "/api/hosts/abc/network/changes", True),
|
||||||
|
("DELETE", "/api/rules/abc", True),
|
||||||
|
("DELETE", "/api/aliases/abc", True),
|
||||||
|
("DELETE", "/api/port-forward/abc", True),
|
||||||
|
("DELETE", "/api/one-to-one/abc", True),
|
||||||
|
("DELETE", "/api/outbound-nat/abc", True),
|
||||||
|
("DELETE", "/api/policy-hierarchy/rules/abc", True),
|
||||||
|
("DELETE", "/api/ipsec/tunnels/abc", True),
|
||||||
|
("DELETE", "/api/certificates/managed/abc", True),
|
||||||
|
("DELETE", "/api/hosts/abc/snapshots/def", True),
|
||||||
|
("POST", "/api/certificates/managed/abc/push", True),
|
||||||
|
("POST", "/api/acme/accounts/abc/register", True),
|
||||||
|
("POST", "/api/acme/certificates/abc/sign", True),
|
||||||
|
("POST", "/api/acme/certificates/abc/poll", True),
|
||||||
|
("POST", "/api/policy-hierarchy/groups/abc/reassert-ordering", True),
|
||||||
|
("POST", "/api/hosts/abc/services/unbound/restart", True),
|
||||||
|
("DELETE", "/api/groups/abc", False),
|
||||||
|
("DELETE", "/api/rules/abc/extra", False),
|
||||||
|
("POST", "/api/rules/abc/preview", False),
|
||||||
|
("POST", "/api/rules", False),
|
||||||
|
("PUT", "/api/rules/abc", False),
|
||||||
|
("POST", "/api/rules/abc/confirm", False),
|
||||||
|
("GET", "/api/rules/abc/deploy", False),
|
||||||
|
("POST", "/api/hosts/abc/network/changes/preview", False),
|
||||||
|
])
|
||||||
|
def test_what_counts_as_a_rollout(method, path, rollout):
|
||||||
|
from app.services import api_token_service
|
||||||
|
assert api_token_service.is_rollout(method, path) is rollout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_with_separation_of_duties_operators_prepare_but_only_admins_deploy(client):
|
||||||
|
op = await _operator(client, "dutyop")
|
||||||
|
try:
|
||||||
|
assert (await op.get("/api/settings/governance")).json() == {"operators_may_deploy": True}
|
||||||
|
assert (await op.put("/api/settings/governance", json={"operators_may_deploy": False})).status_code == 403
|
||||||
|
assert (await client.put("/api/settings/governance", json={"operators_may_deploy": False})).json() == {"operators_may_deploy": False}
|
||||||
|
# Preparing still works ...
|
||||||
|
assert (await op.post("/api/groups", json={"name": "prepared"})).status_code == 201
|
||||||
|
# ... rolling out does not, whatever the target (the 403 comes before any lookup).
|
||||||
|
for path in ("/api/rules/00000000-0000-0000-0000-000000000000/deploy", "/api/hosts/00000000-0000-0000-0000-000000000000/maintenance/execute"):
|
||||||
|
resp = await op.post(path, json={})
|
||||||
|
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "DEPLOY_NEEDS_ADMIN", path
|
||||||
|
# An admin is not affected (404 = got past the gate and looked for the rule).
|
||||||
|
assert (await client.post("/api/rules/00000000-0000-0000-0000-000000000000/deploy", json={})).status_code in (404, 422)
|
||||||
|
# Switching it back lets the operator through the gate again.
|
||||||
|
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
|
||||||
|
assert (await op.post("/api/rules/00000000-0000-0000-0000-000000000000/deploy", json={})).status_code in (404, 422)
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_refusal_is_audited_and_covers_operator_tokens(client):
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
op = await _operator(client, "dutytok")
|
||||||
|
try:
|
||||||
|
token = (await op.post("/api/tokens", json={"name": "ci", "scope": "deploy", "current_password": PW})).json()["token"]
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
await client.put("/api/settings/governance", json={"operators_may_deploy": False})
|
||||||
|
try:
|
||||||
|
async with _bearer(token) as api:
|
||||||
|
resp = await api.post("/api/rules/00000000-0000-0000-0000-000000000000/deploy", json={})
|
||||||
|
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "DEPLOY_NEEDS_ADMIN"
|
||||||
|
finally:
|
||||||
|
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.status == "denied", AuditEvent.actor.like("dutytok%")))).all()
|
||||||
|
assert rows and rows[0].kind == "rule" and "dutytok (API: ci)" in {r.actor for r in rows}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"""Management overlay: keys, addressing, hub config, the hub's proxy, and routing requests through it."""
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.overlay_hub import Hub, parse_dump, wg_conf
|
||||||
|
from app.services import overlay_service as svc
|
||||||
|
from tests.test_api_tokens import _login
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_overlay():
|
||||||
|
root = Path(settings.overlay_dir)
|
||||||
|
if root.exists():
|
||||||
|
for f in root.iterdir():
|
||||||
|
f.unlink()
|
||||||
|
svc._active_network = None
|
||||||
|
yield
|
||||||
|
svc._active_network = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── keys and addresses ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_keys_are_valid_wireguard_keys_and_the_hub_key_is_stable():
|
||||||
|
private = svc.generate_private_key()
|
||||||
|
public = svc.public_from_private(private)
|
||||||
|
assert svc.valid_public_key(public) and len(public) == 44 and public != private
|
||||||
|
assert svc.hub_public_key() == svc.hub_public_key()
|
||||||
|
assert oct((Path(settings.overlay_dir) / "hub.key").stat().st_mode & 0o777) == "0o600"
|
||||||
|
# RFC 7748 section 6.1 vector (Alice): the standard X25519 public key for a given private key
|
||||||
|
alice_priv = bytes.fromhex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a")
|
||||||
|
import base64
|
||||||
|
assert svc.public_from_private(base64.b64encode(alice_priv).decode()) == base64.b64encode(
|
||||||
|
bytes.fromhex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value,ok", [
|
||||||
|
("A" * 43 + "=", True), ("", False), ("short", False), ("A" * 44, False), ("A" * 42 + "==", False), ("!" * 43 + "=", False),
|
||||||
|
])
|
||||||
|
def test_public_key_validation(value, ok):
|
||||||
|
assert svc.valid_public_key(value) is ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_subnets():
|
||||||
|
assert str(svc.parse_subnet("10.99.0.0/24")) == "10.99.0.0/24"
|
||||||
|
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", "", "127.0.0.0/24", "169.254.1.0/24"):
|
||||||
|
with pytest.raises(svc.OverlayError):
|
||||||
|
svc.parse_subnet(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allocation_skips_the_hub_and_taken_addresses_and_runs_out():
|
||||||
|
net = svc.parse_subnet("10.99.0.0/29") # .1 hub, .2-.6 usable
|
||||||
|
assert str(svc.hub_address(net)) == "10.99.0.1"
|
||||||
|
taken: set[str] = set()
|
||||||
|
for expected in ("10.99.0.2", "10.99.0.3", "10.99.0.4", "10.99.0.5", "10.99.0.6"):
|
||||||
|
ip = svc.next_free_ip(net, taken)
|
||||||
|
assert ip == expected
|
||||||
|
taken.add(ip)
|
||||||
|
with pytest.raises(svc.OverlayError):
|
||||||
|
svc.next_free_ip(net, taken)
|
||||||
|
taken.discard("10.99.0.3")
|
||||||
|
assert svc.next_free_ip(net, taken) == "10.99.0.3" # reuses holes
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_routing_decision():
|
||||||
|
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
|
||||||
|
assert svc.proxy_for("https://10.99.0.2") == settings.overlay_proxy_url
|
||||||
|
assert svc.proxy_for("https://10.99.0.2:8443/x") == settings.overlay_proxy_url
|
||||||
|
for direct in ("https://10.98.0.2", "https://fw.example.com", "https://[fd00::1]", "not a url", "https://"):
|
||||||
|
assert svc.proxy_for(direct) is None
|
||||||
|
svc._active_network = None
|
||||||
|
assert svc.proxy_for("https://10.99.0.2") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _enable(client, **extra):
|
||||||
|
resp = await client.put("/api/overlay/settings", json={"enabled": True, "endpoint": "vpn.example.com", **extra})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_json():
|
||||||
|
return json.loads((Path(settings.overlay_dir) / "hub.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enabling_needs_an_endpoint_and_writes_the_hub_config(client):
|
||||||
|
assert (await client.put("/api/overlay/settings", json={"enabled": True})).status_code == 422
|
||||||
|
body = await _enable(client)
|
||||||
|
assert body["enabled"] and body["hub_public_key"] == svc.hub_public_key() and body["hub_address"] == "10.99.0.1"
|
||||||
|
assert body["hub_state"] == "absent" # no hub container reporting
|
||||||
|
config = _hub_json()
|
||||||
|
assert config["enabled"] and config["address"] == "10.99.0.1/24" and config["listen_port"] == 51820 and config["peers"] == []
|
||||||
|
assert config["private_key"] == svc.hub_private_key()
|
||||||
|
assert svc._active_network == ipaddress.ip_network("10.99.0.0/24")
|
||||||
|
assert oct((Path(settings.overlay_dir) / "hub.json").stat().st_mode & 0o777) == "0o600"
|
||||||
|
await client.put("/api/overlay/settings", json={"enabled": False})
|
||||||
|
off = _hub_json()
|
||||||
|
assert off["enabled"] is False and off["private_key"] is None and off["peers"] == [] and svc._active_network is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_peer_lifecycle_and_hub_config(client):
|
||||||
|
await _enable(client, listen_port=51999)
|
||||||
|
created = (await client.post("/api/overlay/peers", json={"name": "branch-1"})).json()
|
||||||
|
assert created["ip"] == "10.99.0.2"
|
||||||
|
ins = created["instructions"]
|
||||||
|
assert ins["tunnel_address"] == "10.99.0.2/32" and ins["allowed_ips"] == "10.99.0.1/32" and ins["endpoint"] == "vpn.example.com:51999"
|
||||||
|
assert ins["host_url"] == "https://10.99.0.2" and ins["keepalive"] == 25 and svc.valid_public_key(ins["hub_public_key"]) and ins["psk"]
|
||||||
|
again = (await client.get(f"/api/overlay/peers/{created['id']}/instructions")).json()
|
||||||
|
assert again["psk"] == ins["psk"] # stable, so the admin can look it up again
|
||||||
|
second = (await client.post("/api/overlay/peers", json={"name": "branch-2"})).json()
|
||||||
|
assert second["ip"] == "10.99.0.3" and second["instructions"]["psk"] != ins["psk"]
|
||||||
|
assert (await client.post("/api/overlay/peers", json={"name": "branch-1"})).status_code == 409
|
||||||
|
# A peer without a public key is not in the hub config yet.
|
||||||
|
assert _hub_json()["peers"] == []
|
||||||
|
key = svc.public_from_private(svc.generate_private_key())
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{created['id']}", json={"public_key": key})).status_code == 200
|
||||||
|
peers = _hub_json()["peers"]
|
||||||
|
assert peers == [{"public_key": key, "psk": ins["psk"], "ip": "10.99.0.2"}]
|
||||||
|
# Deleting removes it from the config and the PSK from disk.
|
||||||
|
assert (await client.delete(f"/api/overlay/peers/{created['id']}")).status_code == 204
|
||||||
|
assert _hub_json()["peers"] == [] and not (Path(settings.overlay_dir) / f"psk-{created['id']}").exists()
|
||||||
|
assert (await client.delete(f"/api/overlay/peers/{created['id']}")).status_code == 404
|
||||||
|
# The freed address is reused.
|
||||||
|
assert (await client.post("/api/overlay/peers", json={"name": "branch-3"})).json()["ip"] == "10.99.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_peer_key_validation_and_host_link(client, respx_mock):
|
||||||
|
import httpx
|
||||||
|
from tests.test_rules import _create_host
|
||||||
|
await _enable(client)
|
||||||
|
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
|
||||||
|
other = (await client.post("/api/overlay/peers", json={"name": "q"})).json()
|
||||||
|
for bad in ("nonsense", "A" * 44, svc.hub_public_key()):
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": bad})).status_code == 422, bad
|
||||||
|
good = svc.public_from_private(svc.generate_private_key())
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": good})).status_code == 200
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{other['id']}", json={"public_key": good})).status_code == 409 # one key, one peer
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"host_id": host["id"]})).status_code == 200
|
||||||
|
listing = (await client.get("/api/overlay")).json()["peers"]
|
||||||
|
assert next(p for p in listing if p["id"] == peer["id"])["host_name"] == "fw1"
|
||||||
|
import uuid
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"host_id": str(uuid.uuid4())})).status_code == 404
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"clear_host": True})).status_code == 200
|
||||||
|
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": ""})).status_code == 200 # clearing is allowed
|
||||||
|
assert _hub_json()["peers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subnet_changes_only_without_peers(client):
|
||||||
|
await _enable(client)
|
||||||
|
assert (await client.put("/api/overlay/settings", json={"subnet": "172.30.5.0/24"})).json()["hub_address"] == "172.30.5.1"
|
||||||
|
assert (await client.put("/api/overlay/settings", json={"subnet": "8.8.8.0/24"})).status_code == 422
|
||||||
|
peer = (await client.post("/api/overlay/peers", json={"name": "x"})).json()
|
||||||
|
assert peer["ip"] == "172.30.5.2"
|
||||||
|
resp = await client.put("/api/overlay/settings", json={"subnet": "10.50.0.0/24"})
|
||||||
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "OVERLAY_SUBNET_LOCKED"
|
||||||
|
assert (await client.put("/api/overlay/settings", json={"subnet": "172.30.5.0/24"})).status_code == 200 # same value is no change
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_status_from_the_hubs_report(client):
|
||||||
|
await _enable(client)
|
||||||
|
key = svc.public_from_private(svc.generate_private_key())
|
||||||
|
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
|
||||||
|
await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": key})
|
||||||
|
status_file = Path(settings.overlay_dir) / "status.json"
|
||||||
|
now = time.time()
|
||||||
|
status_file.write_text(json.dumps({"updated_at": now, "up": True, "error": None,
|
||||||
|
"peers": [{"public_key": key, "endpoint": "203.0.113.9:4000", "handshake_at": int(now) - 30, "rx": 1000, "tx": 2000}]}))
|
||||||
|
data = (await client.get("/api/overlay")).json()
|
||||||
|
assert data["settings"]["hub_state"] == "running"
|
||||||
|
p = data["peers"][0]
|
||||||
|
assert p["connected"] is True and p["rx_bytes"] == 1000 and p["tx_bytes"] == 2000 and p["remote_endpoint"] == "203.0.113.9:4000"
|
||||||
|
status_file.write_text(json.dumps({"updated_at": now, "up": True, "peers": [{"public_key": key, "handshake_at": int(now) - 900, "rx": 1, "tx": 1}]}))
|
||||||
|
assert (await client.get("/api/overlay")).json()["peers"][0]["connected"] is False # handshake too old
|
||||||
|
status_file.write_text(json.dumps({"updated_at": now - 600, "up": True, "peers": [{"public_key": key, "handshake_at": int(now), "rx": 1, "tx": 1}]}))
|
||||||
|
data = (await client.get("/api/overlay")).json()
|
||||||
|
assert data["settings"]["hub_state"] == "stale" and data["peers"][0]["connected"] is False # hub stopped reporting
|
||||||
|
status_file.write_text("not json")
|
||||||
|
assert (await client.get("/api/overlay")).json()["settings"]["hub_state"] == "absent"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_admins_write_or_read_the_psk(client):
|
||||||
|
await _enable(client)
|
||||||
|
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
|
||||||
|
for role in ("operator", "read_only"):
|
||||||
|
name = f"ov-{role}"
|
||||||
|
assert (await client.post("/api/users", json={"username": name, "password": "overlay-password-1", "role": role})).status_code == 201
|
||||||
|
user = await _login(name, "overlay-password-1")
|
||||||
|
try:
|
||||||
|
assert (await user.get("/api/overlay")).status_code == 200 # status is for everybody ...
|
||||||
|
assert "psk" not in (await user.get("/api/overlay")).text
|
||||||
|
assert (await user.get(f"/api/overlay/peers/{peer['id']}/instructions")).status_code == 403 # ... the pre-shared key is not
|
||||||
|
for method, path, body in (("PUT", "/api/overlay/settings", {"enabled": False}), ("POST", "/api/overlay/peers", {"name": "z"}),
|
||||||
|
("PATCH", f"/api/overlay/peers/{peer['id']}", {"clear_host": True}), ("DELETE", f"/api/overlay/peers/{peer['id']}", None)):
|
||||||
|
assert (await user.request(method, path, json=body)).status_code == 403, (role, path)
|
||||||
|
finally:
|
||||||
|
await user.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── the hub ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_wg_config_and_dump_parsing():
|
||||||
|
conf = wg_conf({"private_key": "PRIV", "listen_port": 51820, "peers": [{"public_key": "PUB1", "psk": "PSK1", "ip": "10.99.0.2"}]})
|
||||||
|
assert conf == "[Interface]\nPrivateKey = PRIV\nListenPort = 51820\n\n[Peer]\nPublicKey = PUB1\nPresharedKey = PSK1\nAllowedIPs = 10.99.0.2/32\n"
|
||||||
|
dump = "hubpriv\thubpub\t51820\toff\n" + "PUB1\tpsk\t198.51.100.7:5000\t10.99.0.2/32\t1700000000\t111\t222\t25\n" + "PUB2\tpsk\t(none)\t10.99.0.3/32\t0\t0\t0\t25\n"
|
||||||
|
assert parse_dump(dump) == [
|
||||||
|
{"public_key": "PUB1", "endpoint": "198.51.100.7:5000", "handshake_at": 1700000000, "rx": 111, "tx": 222},
|
||||||
|
{"public_key": "PUB2", "endpoint": None, "handshake_at": 0, "rx": 0, "tx": 0}]
|
||||||
|
assert parse_dump("only-interface-line") == []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRunner:
|
||||||
|
def __init__(self, existing=False, fail=None):
|
||||||
|
self.calls, self.existing, self.fail = [], existing, fail
|
||||||
|
|
||||||
|
def __call__(self, *cmd, stdin=None):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if cmd[:3] == ("ip", "link", "show"):
|
||||||
|
return (0 if self.existing else 1), ""
|
||||||
|
if cmd[:2] == ("iptables", "-C"):
|
||||||
|
return 1, ""
|
||||||
|
if self.fail and cmd[0] == self.fail:
|
||||||
|
return 1, "boom"
|
||||||
|
if cmd[:2] == ("wg", "show"):
|
||||||
|
return 0, "hubpriv\thubpub\t51820\toff\nPUB1\tpsk\t(none)\t10.99.0.2/32\t0\t0\t0\t25\n"
|
||||||
|
return 0, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _config(**over):
|
||||||
|
return {"enabled": True, "address": "10.99.0.1/24", "subnet": "10.99.0.0/24", "listen_port": 51820, "private_key": "PRIV",
|
||||||
|
"peers": [{"public_key": "PUB1", "psk": "PSK1", "ip": "10.99.0.2"}], **over}
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_applies_the_interface_peers_and_the_inbound_drop_rule(tmp_path):
|
||||||
|
runner = _FakeRunner()
|
||||||
|
hub = Hub(tmp_path, runner)
|
||||||
|
assert hub.apply(_config()) is None
|
||||||
|
cmds = [c[:3] for c in runner.calls]
|
||||||
|
assert ("ip", "link", "add") in cmds and ("ip", "address", "replace") in cmds and ("wg", "syncconf", "wg0") in cmds
|
||||||
|
assert any(c[0] == "iptables" and c[1] == "-I" and "-i" in c and "NEW" in c and c[-1] == "DROP" for c in runner.calls)
|
||||||
|
assert ("iptables", "-I", "FORWARD", "-j", "DROP") in runner.calls # no routing between peers or into the compose network
|
||||||
|
assert not list(tmp_path.glob(".wg0.conf")) # the private key file does not linger
|
||||||
|
assert hub.subnet == ipaddress.ip_network("10.99.0.0/24")
|
||||||
|
# An existing interface is reused, not re-created.
|
||||||
|
runner2 = _FakeRunner(existing=True)
|
||||||
|
Hub(tmp_path, runner2).apply(_config())
|
||||||
|
assert ("ip", "link", "add", "wg0", "type", "wireguard") not in runner2.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_takes_the_interface_down_when_disabled_and_reports_errors(tmp_path):
|
||||||
|
runner = _FakeRunner(existing=True)
|
||||||
|
hub = Hub(tmp_path, runner)
|
||||||
|
assert hub.apply(_config(enabled=False)) is None and ("ip", "link", "del", "wg0") in runner.calls and hub.subnet is None
|
||||||
|
failing = Hub(tmp_path, _FakeRunner(fail="wg"))
|
||||||
|
assert "wg syncconf" in failing.apply(_config())
|
||||||
|
assert "ip link add" in (Hub(tmp_path, _FakeRunner(fail="ip")).apply(_config()) or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_tick_applies_on_change_and_reports_status(tmp_path):
|
||||||
|
runner = _FakeRunner()
|
||||||
|
hub = Hub(tmp_path, runner)
|
||||||
|
assert hub.tick(None) is None # no config yet: still reports
|
||||||
|
status = json.loads((tmp_path / "status.json").read_text())
|
||||||
|
assert status["up"] is True and status["peers"][0]["public_key"] == "PUB1" and abs(status["updated_at"] - time.time()) < 5
|
||||||
|
(tmp_path / "hub.json").write_text(json.dumps(_config()))
|
||||||
|
hub.tick(None)
|
||||||
|
applied = len([c for c in runner.calls if c[:2] == ("wg", "syncconf")])
|
||||||
|
assert applied == 1
|
||||||
|
hub.tick(None)
|
||||||
|
assert len([c for c in runner.calls if c[:2] == ("wg", "syncconf")]) == 1 # unchanged file: no re-apply
|
||||||
|
os.utime(tmp_path / "hub.json", (time.time() + 10, time.time() + 10))
|
||||||
|
hub.tick(None)
|
||||||
|
assert len([c for c in runner.calls if c[:2] == ("wg", "syncconf")]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def _serve_echo(prefix=b"echo:"):
|
||||||
|
async def handle(reader, writer):
|
||||||
|
while data := await reader.read(1024):
|
||||||
|
writer.write(prefix + data)
|
||||||
|
await writer.drain()
|
||||||
|
writer.close()
|
||||||
|
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
||||||
|
return server, server.sockets[0].getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
async def _proxy(subnet="127.0.0.0/8"):
|
||||||
|
hub = Hub(Path(tempfile.mkdtemp()), _FakeRunner())
|
||||||
|
hub.subnet = ipaddress.ip_network(subnet)
|
||||||
|
server = await asyncio.start_server(hub.handle_client, "127.0.0.1", 0)
|
||||||
|
return server, server.sockets[0].getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
async def _connect(proxy_port, target):
|
||||||
|
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
||||||
|
writer.write(f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n".encode())
|
||||||
|
await writer.drain()
|
||||||
|
return reader, writer, await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_relays_connections_into_the_overlay_subnet():
|
||||||
|
echo, echo_port = await _serve_echo()
|
||||||
|
proxy, proxy_port = await _proxy()
|
||||||
|
try:
|
||||||
|
reader, writer, head = await _connect(proxy_port, f"127.0.0.1:{echo_port}")
|
||||||
|
assert head.startswith(b"HTTP/1.1 200")
|
||||||
|
writer.write(b"hello")
|
||||||
|
await writer.drain()
|
||||||
|
assert await asyncio.wait_for(reader.readexactly(10), 5) == b"echo:hello"
|
||||||
|
writer.close()
|
||||||
|
finally:
|
||||||
|
proxy.close(); echo.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_is_not_an_open_proxy():
|
||||||
|
echo, echo_port = await _serve_echo()
|
||||||
|
proxy, proxy_port = await _proxy(subnet="10.99.0.0/24") # loopback is outside this overlay
|
||||||
|
try:
|
||||||
|
for target in (f"127.0.0.1:{echo_port}", "8.8.8.8:443", "example.com:443", "10.99.0.2:0", "10.99.0.2:99999", "10.99.0.2:abc", "nonsense"):
|
||||||
|
_, writer, head = await _connect(proxy_port, target)
|
||||||
|
assert head.startswith(b"HTTP/1.1 403"), target
|
||||||
|
writer.close()
|
||||||
|
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
||||||
|
writer.write(b"GET http://10.99.0.2/ HTTP/1.1\r\nHost: 10.99.0.2\r\n\r\n") # plain proxying is not offered either
|
||||||
|
assert (await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5)).startswith(b"HTTP/1.1 403")
|
||||||
|
writer.close()
|
||||||
|
finally:
|
||||||
|
proxy.close(); echo.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_reports_unreachable_firewalls_and_survives_garbage():
|
||||||
|
proxy, proxy_port = await _proxy()
|
||||||
|
free = socket_free_port()
|
||||||
|
try:
|
||||||
|
_, writer, head = await _connect(proxy_port, f"127.0.0.1:{free}")
|
||||||
|
assert head.startswith(b"HTTP/1.1 502")
|
||||||
|
writer.close()
|
||||||
|
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
||||||
|
writer.write(b"\x00\xff" * 100 + b"\r\n\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
writer.close()
|
||||||
|
_, writer, head = await _connect(proxy_port, f"127.0.0.1:{free}") # still serving
|
||||||
|
assert head.startswith(b"HTTP/1.1 502")
|
||||||
|
writer.close()
|
||||||
|
finally:
|
||||||
|
proxy.close()
|
||||||
|
|
||||||
|
|
||||||
|
def socket_free_port() -> int:
|
||||||
|
import socket
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ── requests to overlay firewalls go through the proxy ───────────────────
|
||||||
|
|
||||||
|
def test_clients_for_overlay_hosts_use_the_proxy_and_others_do_not(monkeypatch):
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.services import opnsense_client, opnsense_secret_store
|
||||||
|
import uuid
|
||||||
|
monkeypatch.setattr(opnsense_secret_store, "read_secret", lambda host_id: "secret")
|
||||||
|
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
|
||||||
|
overlay = OpnsenseHost(id=uuid.uuid4(), name="o", base_url="https://10.99.0.2", api_key="k", verify_tls=False)
|
||||||
|
direct = OpnsenseHost(id=uuid.uuid4(), name="d", base_url="https://10.10.10.2", api_key="k", verify_tls=False)
|
||||||
|
assert opnsense_client._client(overlay)._mounts, "the overlay client must have a proxy mount"
|
||||||
|
assert not opnsense_client._client(direct)._mounts
|
||||||
|
|
||||||
|
|
||||||
|
def _self_signed(cn="fw.test"):
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
|
||||||
|
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1)).not_valid_after(datetime.now(timezone.utc) + timedelta(days=30))
|
||||||
|
.sign(key, hashes.SHA256()))
|
||||||
|
return cert, key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tls_probe_and_pin_check_work_through_the_proxy(tmp_path, monkeypatch):
|
||||||
|
from app.services import tls_pin_service
|
||||||
|
cert, key = _self_signed()
|
||||||
|
(tmp_path / "c.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
(tmp_path / "k.pem").write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(tmp_path / "c.pem", tmp_path / "k.pem")
|
||||||
|
fw = await asyncio.start_server(lambda r, w: w.close(), "127.0.0.1", 0, ssl=ctx)
|
||||||
|
fw_port = fw.sockets[0].getsockname()[1]
|
||||||
|
proxy, proxy_port = await _proxy()
|
||||||
|
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{proxy_port}")
|
||||||
|
svc._active_network = ipaddress.ip_network("127.0.0.0/8")
|
||||||
|
try:
|
||||||
|
info = await tls_pin_service.probe(f"https://127.0.0.1:{fw_port}")
|
||||||
|
assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
|
||||||
|
await tls_pin_service.verify_matches(f"https://127.0.0.1:{fw_port}", info["pem"])
|
||||||
|
other, _ = _self_signed("someone-else")
|
||||||
|
with pytest.raises(tls_pin_service.TlsPinError):
|
||||||
|
await tls_pin_service.verify_matches(f"https://127.0.0.1:{fw_port}", other.public_bytes(serialization.Encoding.PEM).decode())
|
||||||
|
# The proxy refusing (target outside the overlay) surfaces as a connection error, not a hang.
|
||||||
|
svc._active_network = ipaddress.ip_network("127.0.0.0/8")
|
||||||
|
hub_only = await _proxy(subnet="10.99.0.0/24")
|
||||||
|
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{hub_only[1]}")
|
||||||
|
with pytest.raises(tls_pin_service.TlsPinError):
|
||||||
|
await tls_pin_service.probe(f"https://127.0.0.1:{fw_port}")
|
||||||
|
hub_only[0].close()
|
||||||
|
finally:
|
||||||
|
proxy.close(); fw.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_dead_proxy_is_a_clean_error(monkeypatch):
|
||||||
|
from app.services import tls_pin_service
|
||||||
|
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{socket_free_port()}")
|
||||||
|
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
|
||||||
|
with pytest.raises(tls_pin_service.TlsPinError):
|
||||||
|
await tls_pin_service.probe("https://10.99.0.2")
|
||||||
@@ -112,3 +112,75 @@ async def test_unreachable_feed_keeps_last_known_result(client, respx_mock, monk
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_requires_login(anon_client):
|
async def test_requires_login(anon_client):
|
||||||
assert (await anon_client.get("/api/system/version-check")).status_code == 401
|
assert (await anon_client.get("/api/system/version-check")).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── Einstellung im Webinterface (statt .env) ────────────────────────────
|
||||||
|
|
||||||
|
WEB_FEED = "https://mirror.test/latest.json"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _env_unset(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "update_check_url", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def _reset_row(client):
|
||||||
|
await client.put("/api/system/update-check", json={"enabled": False, "feed_url": "https://updates.opnexus.dev/latest.json"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_is_off_by_default_and_can_be_switched_on_in_the_ui(client, respx_mock, _env_unset):
|
||||||
|
await _reset_row(client)
|
||||||
|
body = (await client.get("/api/system/update-check")).json()
|
||||||
|
assert body == {"enabled": False, "feed_url": "https://updates.opnexus.dev/latest.json", "managed_by_server": False}
|
||||||
|
assert (await client.get("/api/system/version-check")).json()["enabled"] is False
|
||||||
|
assert len(respx_mock.calls) == 0
|
||||||
|
|
||||||
|
newer = _bump(APP_VERSION, minor=1)
|
||||||
|
respx_mock.get(WEB_FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{newer}"}))
|
||||||
|
resp = await client.put("/api/system/update-check", json={"enabled": True, "feed_url": f" {WEB_FEED} "})
|
||||||
|
assert resp.status_code == 200 and resp.json()["feed_url"] == WEB_FEED and resp.json()["enabled"] is True
|
||||||
|
check = (await client.get("/api/system/version-check")).json()
|
||||||
|
assert check["enabled"] is True and check["latest"] == newer and check["update_available"] is True
|
||||||
|
await _reset_row(client)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switching_the_check_off_again_stops_requests(client, respx_mock, _env_unset):
|
||||||
|
respx_mock.get(WEB_FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{_bump(APP_VERSION, minor=1)}"}))
|
||||||
|
await client.put("/api/system/update-check", json={"enabled": True, "feed_url": WEB_FEED})
|
||||||
|
assert (await client.get("/api/system/version-check")).json()["latest"]
|
||||||
|
await client.put("/api/system/update-check", json={"enabled": False})
|
||||||
|
after = (await client.get("/api/system/version-check")).json()
|
||||||
|
assert after["enabled"] is False and after["latest"] is None
|
||||||
|
await _reset_row(client)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("url", ["", "ftp://x.test/f", "javascript:alert(1)", "https://a b.test/", "https://x.test/" + "a" * 500])
|
||||||
|
async def test_invalid_feed_urls_are_rejected(client, _env_unset, url):
|
||||||
|
resp = await client.put("/api/system/update-check", json={"feed_url": url})
|
||||||
|
assert resp.status_code in (422, 400)
|
||||||
|
if resp.status_code == 422 and isinstance(resp.json().get("detail"), dict):
|
||||||
|
assert resp.json()["detail"]["code"] == "UPDATE_CHECK_URL_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_env_setting_wins_and_locks_the_ui(client, respx_mock):
|
||||||
|
# the autouse fixture sets UPDATE_CHECK_URL = FEED, i.e. the server decides
|
||||||
|
body = (await client.get("/api/system/update-check")).json()
|
||||||
|
assert body == {"enabled": True, "feed_url": FEED, "managed_by_server": True}
|
||||||
|
resp = await client.put("/api/system/update-check", json={"enabled": False})
|
||||||
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_CHECK_MANAGED_BY_SERVER"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_admins_can_read_or_change_the_setting(client, _env_unset):
|
||||||
|
from tests.test_users import _login_as
|
||||||
|
await client.post("/api/users", json={"username": "viewer-upc", "password": "password-123456", "role": "read_only"})
|
||||||
|
viewer = await _login_as("viewer-upc", "password-123456")
|
||||||
|
try:
|
||||||
|
assert (await viewer.get("/api/system/update-check")).status_code == 403
|
||||||
|
assert (await viewer.put("/api/system/update-check", json={"enabled": True})).status_code == 403
|
||||||
|
finally:
|
||||||
|
await viewer.aclose()
|
||||||
@@ -58,6 +58,17 @@ async def test_status_without_token_is_unavailable_not_an_error(client, respx_mo
|
|||||||
assert body["update_available"] is True # der Hinweis bleibt trotzdem
|
assert body["update_available"] is True # der Hinweis bleibt trotzdem
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("repo,mode", [("", "local"), ("git.example.test/opnexus", "release")])
|
||||||
|
async def test_install_mode_is_known_without_the_updater(client, respx_mock, monkeypatch, tmp_path, repo, mode):
|
||||||
|
# The panel must not tell a source-built install to enable an updater that could not update it anyway.
|
||||||
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
||||||
|
monkeypatch.setattr(settings, "opnexus_image_repo", repo)
|
||||||
|
_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["mode"] == mode
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_status_unreachable_updater(client, respx_mock):
|
async def test_status_unreachable_updater(client, respx_mock):
|
||||||
_feed(respx_mock, _bump(minor=1))
|
_feed(respx_mock, _bump(minor=1))
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Site variables: {{NAME}} placeholders filled per host, failing closed when a value is missing."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.models.host_variable import HostVariable
|
||||||
|
from app.services import variables_service as vs
|
||||||
|
from tests.test_aliases import _alias_payload
|
||||||
|
from tests.test_port_forward import _pf_payload
|
||||||
|
from tests.test_rules import _create_host, _rule_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _host(name="fw", **values):
|
||||||
|
host = OpnsenseHost(name=name, base_url="https://x", api_key="k")
|
||||||
|
host.variables = [HostVariable(name=n, value=v) for n, v in values.items()]
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
# ── unit ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_placeholders_are_found_and_resolved():
|
||||||
|
payload = {"source_net": "{{LAN_NET}}", "destination_net": "any", "description": "ssh to {{ SITE }} ({{LAN_NET}})", "enabled": "1", "port": 22}
|
||||||
|
assert vs.names_in_payload(payload) == {"LAN_NET", "SITE"}
|
||||||
|
resolved = vs.resolve(payload, _host(LAN_NET="192.168.7.0/24", SITE="Nord"))
|
||||||
|
assert resolved == {"source_net": "192.168.7.0/24", "destination_net": "any", "description": "ssh to Nord (192.168.7.0/24)", "enabled": "1", "port": 22}
|
||||||
|
assert payload["source_net"] == "{{LAN_NET}}" # the original is untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_value_raises_naming_host_and_variable():
|
||||||
|
with pytest.raises(vs.MissingVariables) as exc:
|
||||||
|
vs.resolve({"a": "{{A}}", "b": "{{B}}"}, _host("fw-nord", A="1"))
|
||||||
|
assert exc.value.missing == {"fw-nord": ["B"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["lowercase {{lan}}", "{LAN_NET}", "{{ }}", "{{1ABC}}", "$LAN_NET", "{{LAN-NET}}"])
|
||||||
|
def test_things_that_are_not_placeholders_are_left_alone(text):
|
||||||
|
assert vs.names_in(text) == set()
|
||||||
|
assert vs.resolve({"x": text}, _host())["x"] == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_values_are_inserted_literally_not_interpreted():
|
||||||
|
out = vs.resolve({"x": "{{A}}"}, _host(A="\\1 $& {{B}}"))
|
||||||
|
assert out["x"] == "\\1 $& {{B}}" # no regex-group or second-level expansion
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_and_name_validation():
|
||||||
|
assert vs.NAME_RE.fullmatch("LAN_NET2") and not vs.NAME_RE.fullmatch("lan") and not vs.NAME_RE.fullmatch("2LAN") and not vs.NAME_RE.fullmatch("A" * 41)
|
||||||
|
for ok in ("192.168.7.0/24", "fw-nord.example.com", "10.0.0.1,10.0.0.2", "Filiale Nord", "a@b+c"):
|
||||||
|
assert vs.VALUE_RE.fullmatch(ok)
|
||||||
|
for bad in ("", "{{X}}", "a\nb", "ok\n", 'a"b', "a;b", "a" * 256, "<x>"):
|
||||||
|
assert not vs.VALUE_RE.fullmatch(bad)
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _mock_hosts(respx_mock, *bases):
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
# multi-host deploys compare what the interface name means on every target
|
||||||
|
respx_mock.get(url__regex=r".*/firewall/filter/getRule").mock(
|
||||||
|
return_value=httpx.Response(200, json={"rule": {"interface": {"lan": {"value": "LAN", "selected": 0}}}}))
|
||||||
|
respx_mock.get(url__regex=r".*/firewall/alias/searchItem.*").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
|
||||||
|
|
||||||
|
def _bodies(route):
|
||||||
|
return [json.loads(call.request.content) for call in route.calls]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_variables_api_roundtrip_validation_and_usage_discovery(client, respx_mock):
|
||||||
|
await _mock_hosts(respx_mock)
|
||||||
|
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
h2 = await _create_host(client, "fw2", "https://fw2.test")
|
||||||
|
assert (await client.get(f"/api/hosts/{h1['id']}/variables")).json() == {"variables": []}
|
||||||
|
await client.post("/api/rules", json=_rule_payload([h1["id"]], description="ssh {{SITE}}", source_net="{{LAN_NET}}"))
|
||||||
|
await client.post("/api/aliases", json=_alias_payload([h2["id"]], content="{{DNS_IP}}"))
|
||||||
|
# fw1 uses LAN_NET and SITE, nothing yet defined -> both missing; fw2's alias is not fw1's business.
|
||||||
|
got = (await client.get(f"/api/hosts/{h1['id']}/variables")).json()["variables"]
|
||||||
|
assert [(v["name"], v["value"], v["missing"]) for v in got] == [("LAN_NET", None, True), ("SITE", None, True)]
|
||||||
|
assert got[0]["used_by"] == [{"kind": "rule", "id": got[0]["used_by"][0]["id"], "label": "ssh {{SITE}}"}]
|
||||||
|
put = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24", "EXTRA": "x"}})
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
by_name = {v["name"]: v for v in put.json()["variables"]}
|
||||||
|
assert by_name["LAN_NET"]["missing"] is False and by_name["SITE"]["missing"] is True and by_name["EXTRA"]["used_by"] == []
|
||||||
|
# PUT replaces the whole set.
|
||||||
|
put = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"SITE": "Nord"}})
|
||||||
|
assert {v["name"] for v in put.json()["variables"]} == {"LAN_NET", "SITE"} # LAN_NET only listed because the rule still uses it
|
||||||
|
assert next(v for v in put.json()["variables"] if v["name"] == "LAN_NET")["value"] is None
|
||||||
|
for bad in ({"lan": "1"}, {"OK": "bad;value"}, {"OK": ""}, {"OK": "{{X}}"}):
|
||||||
|
resp = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": bad})
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "VARIABLE_INVALID", bad
|
||||||
|
import uuid
|
||||||
|
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/variables")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_each_host_gets_its_own_values_in_preview_and_deploy(client, respx_mock):
|
||||||
|
await _mock_hosts(respx_mock)
|
||||||
|
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
h2 = await _create_host(client, "fw2", "https://fw2.test")
|
||||||
|
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24", "SITE": "Nord"}})
|
||||||
|
await client.put(f"/api/hosts/{h2['id']}/variables", json={"variables": {"LAN_NET": "10.20.0.0/16", "SITE": "Sued"}})
|
||||||
|
rule = (await client.post("/api/rules", json=_rule_payload([h1["id"], h2["id"]], description="ssh {{SITE}}", source_net="{{LAN_NET}}"))).json()
|
||||||
|
preview = (await client.get(f"/api/rules/{rule['id']}/preview")).json()
|
||||||
|
rows = {r["host_name"]: r for r in preview["hosts"]}
|
||||||
|
after = lambda r: {c["field"]: c["after"] for c in r["changes"]} # noqa: E731
|
||||||
|
assert after(rows["fw1"])["source_net"] == "192.168.7.0/24" and after(rows["fw1"])["description"] == "ssh Nord"
|
||||||
|
assert after(rows["fw2"])["source_net"] == "10.20.0.0/16" and after(rows["fw2"])["description"] == "ssh Sued"
|
||||||
|
assert preview["can_deploy"] is True
|
||||||
|
routes = {b: respx_mock.post(f"https://{b}.test/api/firewall/filter/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": f"u-{b}"})) for b in ("fw1", "fw2")}
|
||||||
|
for b in ("fw1", "fw2"):
|
||||||
|
respx_mock.post(f"https://{b}.test/api/firewall/filter/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||||
|
resp = await client.post(f"/api/rules/{rule['id']}/deploy", json={"preview_token": preview["token"]})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
sent = {b: _bodies(r)[0]["rule"] for b, r in routes.items()}
|
||||||
|
assert (sent["fw1"]["source_net"], sent["fw1"]["description"]) == ("192.168.7.0/24", "ssh Nord")
|
||||||
|
assert (sent["fw2"]["source_net"], sent["fw2"]["description"]) == ("10.20.0.0/16", "ssh Sued")
|
||||||
|
assert "{{" not in json.dumps(sent)
|
||||||
|
# The centrally stored rule keeps its placeholders; the deployment stores what was really sent.
|
||||||
|
assert (await client.get("/api/rules")).json()[0]["source_net"] == "{{LAN_NET}}"
|
||||||
|
# Changing a host's value shows up as a change for that host only.
|
||||||
|
await client.put(f"/api/hosts/{h2['id']}/variables", json={"variables": {"LAN_NET": "10.30.0.0/16", "SITE": "Sued"}})
|
||||||
|
for r in routes.values():
|
||||||
|
pass
|
||||||
|
await client.post(f"/api/rules/{rule['id']}/confirm")
|
||||||
|
again = {r["host_name"]: r["action"] for r in (await client.get(f"/api/rules/{rule['id']}/preview")).json()["hosts"]}
|
||||||
|
assert again == {"fw1": "unchanged", "fw2": "update"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_missing_value_blocks_the_whole_deploy_before_anything_is_written(client, respx_mock):
|
||||||
|
await _mock_hosts(respx_mock)
|
||||||
|
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
h2 = await _create_host(client, "fw2", "https://fw2.test")
|
||||||
|
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24"}})
|
||||||
|
rule = (await client.post("/api/rules", json=_rule_payload([h1["id"], h2["id"]], source_net="{{LAN_NET}}"))).json()
|
||||||
|
preview = (await client.get(f"/api/rules/{rule['id']}/preview")).json()
|
||||||
|
rows = {r["host_name"]: r for r in preview["hosts"]}
|
||||||
|
assert rows["fw2"]["action"] == "missing_variable" and "LAN_NET" in rows["fw2"]["message"] and rows["fw1"]["action"] == "create"
|
||||||
|
assert preview["can_deploy"] is False and any("Variable" in w for w in preview["warnings"])
|
||||||
|
# respx would fail on any unmocked firewall call: nothing may be sent, not even to the host that has its value.
|
||||||
|
resp = await client.post(f"/api/rules/{rule['id']}/deploy", json={"preview_token": preview["token"]})
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "VARIABLES_MISSING"
|
||||||
|
assert "fw2: LAN_NET" in resp.json()["detail"]["params"]["details"] and "fw1" not in resp.json()["detail"]["params"]["details"]
|
||||||
|
assert (await client.get("/api/rules")).json()[0]["deployments"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aliases_and_port_forwards_use_variables_too(client, respx_mock):
|
||||||
|
await _mock_hosts(respx_mock)
|
||||||
|
h1 = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"DNS_IP": "10.9.9.9", "WEB": "10.7.0.20"}})
|
||||||
|
alias = (await client.post("/api/aliases", json=_alias_payload([h1["id"]], content="{{DNS_IP}}"))).json()
|
||||||
|
pf = (await client.post("/api/port-forward", json=_pf_payload([h1["id"]], target="{{WEB}}"))).json()
|
||||||
|
a_prev = (await client.get(f"/api/aliases/{alias['id']}/preview")).json()
|
||||||
|
assert {c["field"]: c["after"] for c in a_prev["hosts"][0]["changes"]}["content"] == "10.9.9.9"
|
||||||
|
p_prev = (await client.get(f"/api/port-forward/{pf['id']}/preview")).json()
|
||||||
|
assert {c["field"]: c["after"] for c in p_prev["hosts"][0]["changes"]}["target"] == "10.7.0.20"
|
||||||
|
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"DNS_IP": "10.9.9.9"}})
|
||||||
|
p_prev = (await client.get(f"/api/port-forward/{pf['id']}/preview")).json()
|
||||||
|
assert p_prev["hosts"][0]["action"] == "missing_variable"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workflow_state_does_not_crash_on_missing_values(client, respx_mock):
|
||||||
|
await _mock_hosts(respx_mock)
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await client.post("/api/rules", json=_rule_payload([host["id"]], source_net="{{NOPE}}"))
|
||||||
|
listing = (await client.get("/api/rules")).json()
|
||||||
|
assert listing[0]["workflow_state"] == "draft" and listing[0]["workflow_counts"]["changes"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_client_refuses_any_payload_with_an_unresolved_placeholder(client, respx_mock):
|
||||||
|
from app.models.host import OpnsenseHost as H
|
||||||
|
from app.services import opnsense_client, opnsense_secret_store
|
||||||
|
import uuid
|
||||||
|
await _mock_hosts(respx_mock)
|
||||||
|
created = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
from app.database import SessionLocal
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
host = await db.get(H, uuid.UUID(created["id"]))
|
||||||
|
with pytest.raises(opnsense_client.OpnsenseAPIError, match="Variable"):
|
||||||
|
await opnsense_client.add_rule(host, {"source_net": "{{LAN_NET}}", "action": "pass"})
|
||||||
|
with pytest.raises(opnsense_client.OpnsenseAPIError, match="Variable"):
|
||||||
|
await opnsense_client.add_rule(host, {"nested": "x {{ A_B }} y"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_variables_follow_the_host_and_operators_may_set_them(client, respx_mock):
|
||||||
|
from tests.test_api_tokens import _login
|
||||||
|
await _mock_hosts(respx_mock)
|
||||||
|
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
await client.put(f"/api/hosts/{host['id']}/variables", json={"variables": {"A": "1"}})
|
||||||
|
assert (await client.post("/api/users", json={"username": "varop", "password": "variables-pass-1", "role": "operator"})).status_code == 201
|
||||||
|
op = await _login("varop", "variables-pass-1")
|
||||||
|
try:
|
||||||
|
assert (await op.put(f"/api/hosts/{host['id']}/variables", json={"variables": {"A": "2"}})).status_code == 200
|
||||||
|
finally:
|
||||||
|
await op.aclose()
|
||||||
|
assert (await client.post("/api/users", json={"username": "varro", "password": "variables-pass-1", "role": "read_only"})).status_code == 201
|
||||||
|
ro = await _login("varro", "variables-pass-1")
|
||||||
|
try:
|
||||||
|
assert (await ro.get(f"/api/hosts/{host['id']}/variables")).status_code == 200
|
||||||
|
assert (await ro.put(f"/api/hosts/{host['id']}/variables", json={"variables": {}})).status_code == 403
|
||||||
|
finally:
|
||||||
|
await ro.aclose()
|
||||||
|
await client.delete(f"/api/hosts/{host['id']}") # variables go with the host
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from sqlalchemy import select
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert (await db.scalars(select(HostVariable))).all() == []
|
||||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user