Compare commits

...
2 Commits
Author SHA1 Message Date
Julian Boldt ec20940139 Release v1.29.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Fehlende API-Rechte werden sichtbar:** Darf der API-Schlüssel einer Firewall den Gateway- oder Dienststatus nicht lesen (OPNsense antwortet
  mit 403), fiel das bisher niemandem auf, und der standardmäßig aktive Gateway-Alarm konnte nie auslösen. Jetzt zeigen die Host-Seite
  (Abschnitt „Gateways“) und die Dashboard-Karte „Fleet-/CARP-Gesundheit“ ein Warn-Badge „API-Rechte fehlen: Gateways, Dienste“; der Tooltip nennt die
  OPNsense-Berechtigung, die beim Benutzer oder bei der Gruppe fehlt (System: Gateways, Status: Services, Lobby: Dashboard). Zusätzlich gibt es
  den neuen Alarm **API-Rechte fehlen** (standardmäßig an, abschaltbar unter Einstellungen › Benachrichtigungen, gleiche Entprellung wie die
  anderen Messungs-Alarme) und die Prometheus-Metrik `opnexus_host_api_rights_missing{host,read}`. Nur eine ausdrückliche Ablehnung zählt;
  Zeitüberschreitungen oder Serverfehler bleiben „unbekannt“.
- **Gateway-Alarm pro Gateway abschaltbar:** In der Gateway-Liste der Host-Seite schaltet eine Glocke den Alarm für ein einzelnes Gateway aus
  (z. B. ruhendes Zweit-WAN oder tote IPv6-Gateways); ein „Alarm aus“-Badge zeigt es an. Ein offener Alarm dieses Gateways löst sich dabei
  auf. Nur Administratoren, die Änderung steht im Audit-Log. Das Dashboard zeigt bei mehr als acht Gateways „+ N weitere“.
- **Firewall-Oberfläche (GUI-Zugriff) härter:** Ein frei gewordener Platz wird für eine **andere** Firewall erst 5 Minuten nach dem Ende der letzten
  Sitzung vergeben (Schutz, falls dort noch ein Tab offen ist, der die Cookies des Platzes teilt); dieselbe Firewall bekommt ihn sofort wieder.
  Sind alle Plätze belegt oder noch in der Abkühlzeit, nennt die Meldung den Grund. Ändern sich Adresse, festgesetztes Zertifikat oder Tunnelweg
  einer Firewall, während ihre Oberfläche offen ist, endet die Sitzung spätestens nach 20 Sekunden. Das Limit von 64 gleichzeitigen Sitzungen
  verdrängt keine laufende Sitzung mehr: eine neue wird abgelehnt (mit Meldung), bis eine endet. **Hinweis:** Das GUI-Gateway-Image muss zur
  Backend-Version passen (wie bisher beim Update beider Container).
- **Anmeldung (Cookies) härter:** Das alte Sitzungs-Cookie `opnexus-session` wird bei HTTPS **nicht mehr akzeptiert**. **Alle, die sich seit der
  Umstellung auf 1.27 nicht neu angemeldet haben, müssen sich einmal neu anmelden**; Skripte mit rohem Sitzungs-Cookie brauchen den Namen
  `__Host-opnexus-session` (API-Tokens sind nicht betroffen, ohne HTTPS bleibt `opnexus-session` der normale Name). Beim Abmelden wird ein altes
  Cookie weiterhin gelöscht. Das Cookie, das die SSO-Anmeldung an den Browser bindet, heißt bei HTTPS jetzt `__Host-opnexus-oidc-state`; der
  Rückweg vom Anbieter wird nur noch als Seitenaufruf (Navigation) von einer fremden Seite zugelassen, und ohne dieses Cookie scheitert er immer.
- **Webhook, Benachrichtigungskanäle und SSO-Anbieter: Schutz vor DNS-Rebinding:** Bisher prüfte OPNexus die Adresse, auf die ein Name zeigt, und
  der HTTP-Client löste den Namen danach noch einmal auf; ein Server unter Fremdkontrolle konnte beim zweiten Mal eine interne Adresse liefern. Jetzt
  verbindet OPNexus genau zu der geprüften Adresse (Host-Name, TLS-Zertifikatsprüfung und Anfrage bleiben beim Namen); zeigt eine Antwort auch nur
  teilweise auf Loopback, Link-Local oder einen Cloud-Metadaten-Dienst (auch AWS-IPv6 und Alibaba), kommt keine Verbindung zustande und die Zustellung meldet „Zieladresse nicht
  erlaubt“. Private Netze bleiben erlaubt (eigener ntfy, Gotify, Matrix). Proxy-Umgebungsvariablen werden für diese Aufrufe nicht mehr beachtet.
- **Benachrichtigungen parallel zugestellt:** Webhook, E-Mail und alle Kanäle werden gleichzeitig bedient, jeder mit eigener Zeitgrenze (20 Sekunden),
  und ein Prüfdurchlauf sendet insgesamt höchstens 90 Sekunden. Ein hängender Kanal verzögert weder die anderen noch die nächsten Prüfungen; im Log
  steht dann „timed out“ (ohne Adresse oder Token). Wie bisher wird einmal je Ereignis zugestellt, ohne Wiederholung, **außer** der Durchlauf war so voll, dass
  für ein Ereignis noch gar kein Versuch stattfand: es wird im nächsten Durchlauf zugestellt. Die Zustellung beginnt jetzt nach allen Prüfungen
  eines Durchlaufs statt mitten darin.
- **Rollouts: Fingerabdruck, verantwortliche Person, überlappende Termine:** Der Fingerabdruck eines aktivierten Plans umfasst jetzt auch die
  ausgelassenen und verschobenen Termine seines Fensters: **wer einen Termin auslässt oder verschiebt, muss einen automatischen Plan danach neu
  aktivieren** (ohne solche Ausnahmen bleiben bestehende Aktivierungen gültig; **Pläne, deren Fenster schon Ausnahmen haben, nach dem Update
  einmal neu aktivieren**, sonst meldet der Plan „Plan geändert“ und startet nicht automatisch). Die verantwortliche Person (Aktivierende, Startende, Freigebende)
  wird zusätzlich als Konto-ID gespeichert (Migration 0026, bestehende Einträge werden über den Namen zugeordnet): ein gelöschtes und unter gleichem
  Namen neu angelegtes Konto gilt nicht mehr als dieselbe Person, der Lauf pausiert bzw. der automatische Start unterbleibt mit „Person darf nicht mehr
  ausrollen“. Angezeigt wird weiter der Benutzername. Überlappen sich zwei Termine eines Wartungsfensters, zählt der zuletzt begonnene (bisher der
  früheste), sodass ein älterer, längst abgeschnittener Termin keinen neuen Start mehr blockiert.
- **Kleinigkeiten:** Ressourcen-Alarme zeigen bei fehlendem Messwert keine Zahl mehr („None %“ entfällt). Der Dialog „Von Firewall übernehmen“ zeigt ohne aktive
  Firewall einen Hinweis statt dauerhaft „Lädt…“. Fehlermeldungen beim Übernehmen und Verknüpfen nennen die Gründe in Klartext (Deutsch/Englisch) statt
  roher Codes wie `name_exists`; dasselbe gilt für „GUI-Zugriff nicht eingerichtet“ und blockierte Netzwerkänderungen. Der Test-Knopf der
  Benachrichtigungskanäle ist für Nicht-Admins ausgeblendet (der Server verweigert den Test ohnehin). Der Tooltip der aktiven Plätze nennt keine
  Umgebungsvariable mehr. Eine Gateway-Sitzung, die der Gateway-Container nicht mehr bestätigt (Absturz), zählt für die Abkühlzeit der Plätze als
  beendet.
- **Grafana-Dashboard erweitert:** neue Felder für Gateways (erreichbar, Paketverlust, Latenz), Swap, gestoppte Dienste, fehlende API-Rechte und
  Rollouts nach Status; dazu die Metrik `opnexus_rollout_runs{status}`.
2026-10-22 01:58:06 +02:00
Julian Boldt 8128d14da4 Release v1.28.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Übernehmen: mit vorhandenem zentralem Objekt verknüpfen:** Dieselbe von Hand angelegte Regel, derselbe Alias oder dieselbe NAT-Regel auf mehreren
  Firewalls (z. B. beide HA-Knoten) wird nicht mehr als zweites Objekt übernommen. Im Dialog „Von Firewall übernehmen“ erscheint bei exakter
  Übereinstimmung (nach Einsetzen der Host-Variablen, bei Aliasen inkl. Verweisen) die Aktion **Verknüpfen**: die Firewall wird weiteres Ziel des
  vorhandenen Objekts, die Vorschau zeigt „keine Änderung“, und der nächste Rollout aktualisiert alle Firewalls an einem Objekt. Auf der Firewall wird
  dabei nichts geändert. Unterscheidet sich ein gleichnamiges Objekt, zeigt der Dialog die abweichenden Felder; verknüpft wird dann nie. „Alle
  übernehmbaren“ fragt vorher nach („3 verknüpfen, 2 neu übernehmen, 1 nicht übernehmbar“). Aliase, die bisher wegen „Name existiert“ abgelehnt wurden,
  lassen sich so übernehmen.
  Wartet das vorhandene Objekt gerade auf eine Bestätigung, wird nicht verknüpft; „Alle übernehmbaren“ lässt den Eintrag dann aus. Verknüpfen
  macht ein gerade geöffnetes Bearbeiten-Formular des Objekts ungültig, damit die neue Firewall nicht versehentlich wieder aus den Zielen fällt.

- **Tooltips überarbeitet:** Der deaktivierte Button „Oberfläche öffnen“ nennt jetzt den Grund im Klartext samt nächstem Schritt (statt nur einer
  Variablen wie `GUI_GATEWAY_TOKEN`); dasselbe gilt für ausgeschaltete Funktion, deaktivierte Firewall, Nicht-HTTPS-Adresse und ausgeschaltete
  Zertifikatsprüfung. Texte durchgehend in Du-Form, kürzere VPN-Einleitungen, verständlichere Hinweise bei gesperrten Buttons (Zertifikate, Benutzer,
  Dienste). Icon-Buttons (Bearbeiten, Löschen, Schließen, Reihenfolge) zeigen beim Darüberfahren ihren Namen.
2026-10-21 23:05:16 +02:00
88 changed files with 4316 additions and 322 deletions

No files matched your search

+67
View File
@@ -31,6 +31,73 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.29.0] "Caracara" — 2026-10-22
- **Fehlende API-Rechte werden sichtbar:** Darf der API-Schlüssel einer Firewall den Gateway- oder Dienststatus nicht lesen (OPNsense antwortet
mit 403), fiel das bisher niemandem auf, und der standardmäßig aktive Gateway-Alarm konnte nie auslösen. Jetzt zeigen die Host-Seite
(Abschnitt „Gateways“) und die Dashboard-Karte „Fleet-/CARP-Gesundheit“ ein Warn-Badge „API-Rechte fehlen: Gateways, Dienste“; der Tooltip nennt die
OPNsense-Berechtigung, die beim Benutzer oder bei der Gruppe fehlt (System: Gateways, Status: Services, Lobby: Dashboard). Zusätzlich gibt es
den neuen Alarm **API-Rechte fehlen** (standardmäßig an, abschaltbar unter Einstellungen › Benachrichtigungen, gleiche Entprellung wie die
anderen Messungs-Alarme) und die Prometheus-Metrik `opnexus_host_api_rights_missing{host,read}`. Nur eine ausdrückliche Ablehnung zählt;
Zeitüberschreitungen oder Serverfehler bleiben „unbekannt“.
- **Gateway-Alarm pro Gateway abschaltbar:** In der Gateway-Liste der Host-Seite schaltet eine Glocke den Alarm für ein einzelnes Gateway aus
(z. B. ruhendes Zweit-WAN oder tote IPv6-Gateways); ein „Alarm aus“-Badge zeigt es an. Ein offener Alarm dieses Gateways löst sich dabei
auf. Nur Administratoren, die Änderung steht im Audit-Log. Das Dashboard zeigt bei mehr als acht Gateways „+ N weitere“.
- **Firewall-Oberfläche (GUI-Zugriff) härter:** Ein frei gewordener Platz wird für eine **andere** Firewall erst 5 Minuten nach dem Ende der letzten
Sitzung vergeben (Schutz, falls dort noch ein Tab offen ist, der die Cookies des Platzes teilt); dieselbe Firewall bekommt ihn sofort wieder.
Sind alle Plätze belegt oder noch in der Abkühlzeit, nennt die Meldung den Grund. Ändern sich Adresse, festgesetztes Zertifikat oder Tunnelweg
einer Firewall, während ihre Oberfläche offen ist, endet die Sitzung spätestens nach 20 Sekunden. Das Limit von 64 gleichzeitigen Sitzungen
verdrängt keine laufende Sitzung mehr: eine neue wird abgelehnt (mit Meldung), bis eine endet. **Hinweis:** Das GUI-Gateway-Image muss zur
Backend-Version passen (wie bisher beim Update beider Container).
- **Anmeldung (Cookies) härter:** Das alte Sitzungs-Cookie `opnexus-session` wird bei HTTPS **nicht mehr akzeptiert**. **Alle, die sich seit der
Umstellung auf 1.27 nicht neu angemeldet haben, müssen sich einmal neu anmelden**; Skripte mit rohem Sitzungs-Cookie brauchen den Namen
`__Host-opnexus-session` (API-Tokens sind nicht betroffen, ohne HTTPS bleibt `opnexus-session` der normale Name). Beim Abmelden wird ein altes
Cookie weiterhin gelöscht. Das Cookie, das die SSO-Anmeldung an den Browser bindet, heißt bei HTTPS jetzt `__Host-opnexus-oidc-state`; der
Rückweg vom Anbieter wird nur noch als Seitenaufruf (Navigation) von einer fremden Seite zugelassen, und ohne dieses Cookie scheitert er immer.
- **Webhook, Benachrichtigungskanäle und SSO-Anbieter: Schutz vor DNS-Rebinding:** Bisher prüfte OPNexus die Adresse, auf die ein Name zeigt, und
der HTTP-Client löste den Namen danach noch einmal auf; ein Server unter Fremdkontrolle konnte beim zweiten Mal eine interne Adresse liefern. Jetzt
verbindet OPNexus genau zu der geprüften Adresse (Host-Name, TLS-Zertifikatsprüfung und Anfrage bleiben beim Namen); zeigt eine Antwort auch nur
teilweise auf Loopback, Link-Local oder einen Cloud-Metadaten-Dienst (auch AWS-IPv6 und Alibaba), kommt keine Verbindung zustande und die Zustellung meldet „Zieladresse nicht
erlaubt“. Private Netze bleiben erlaubt (eigener ntfy, Gotify, Matrix). Proxy-Umgebungsvariablen werden für diese Aufrufe nicht mehr beachtet.
- **Benachrichtigungen parallel zugestellt:** Webhook, E-Mail und alle Kanäle werden gleichzeitig bedient, jeder mit eigener Zeitgrenze (20 Sekunden),
und ein Prüfdurchlauf sendet insgesamt höchstens 90 Sekunden. Ein hängender Kanal verzögert weder die anderen noch die nächsten Prüfungen; im Log
steht dann „timed out“ (ohne Adresse oder Token). Wie bisher wird einmal je Ereignis zugestellt, ohne Wiederholung, **außer** der Durchlauf war so voll, dass
für ein Ereignis noch gar kein Versuch stattfand: es wird im nächsten Durchlauf zugestellt. Die Zustellung beginnt jetzt nach allen Prüfungen
eines Durchlaufs statt mitten darin.
- **Rollouts: Fingerabdruck, verantwortliche Person, überlappende Termine:** Der Fingerabdruck eines aktivierten Plans umfasst jetzt auch die
ausgelassenen und verschobenen Termine seines Fensters: **wer einen Termin auslässt oder verschiebt, muss einen automatischen Plan danach neu
aktivieren** (ohne solche Ausnahmen bleiben bestehende Aktivierungen gültig; **Pläne, deren Fenster schon Ausnahmen haben, nach dem Update
einmal neu aktivieren**, sonst meldet der Plan „Plan geändert“ und startet nicht automatisch). Die verantwortliche Person (Aktivierende, Startende, Freigebende)
wird zusätzlich als Konto-ID gespeichert (Migration 0026, bestehende Einträge werden über den Namen zugeordnet): ein gelöschtes und unter gleichem
Namen neu angelegtes Konto gilt nicht mehr als dieselbe Person, der Lauf pausiert bzw. der automatische Start unterbleibt mit „Person darf nicht mehr
ausrollen“. Angezeigt wird weiter der Benutzername. Überlappen sich zwei Termine eines Wartungsfensters, zählt der zuletzt begonnene (bisher der
früheste), sodass ein älterer, längst abgeschnittener Termin keinen neuen Start mehr blockiert.
- **Kleinigkeiten:** Ressourcen-Alarme zeigen bei fehlendem Messwert keine Zahl mehr („None %“ entfällt). Der Dialog „Von Firewall übernehmen“ zeigt ohne aktive
Firewall einen Hinweis statt dauerhaft „Lädt…“. Fehlermeldungen beim Übernehmen und Verknüpfen nennen die Gründe in Klartext (Deutsch/Englisch) statt
roher Codes wie `name_exists`; dasselbe gilt für „GUI-Zugriff nicht eingerichtet“ und blockierte Netzwerkänderungen. Der Test-Knopf der
Benachrichtigungskanäle ist für Nicht-Admins ausgeblendet (der Server verweigert den Test ohnehin). Der Tooltip der aktiven Plätze nennt keine
Umgebungsvariable mehr. Eine Gateway-Sitzung, die der Gateway-Container nicht mehr bestätigt (Absturz), zählt für die Abkühlzeit der Plätze als
beendet.
- **Grafana-Dashboard erweitert:** neue Felder für Gateways (erreichbar, Paketverlust, Latenz), Swap, gestoppte Dienste, fehlende API-Rechte und
Rollouts nach Status; dazu die Metrik `opnexus_rollout_runs{status}`.
## [1.28.0] "Sparrowhawk" — 2026-10-21
- **Übernehmen: mit vorhandenem zentralem Objekt verknüpfen:** Dieselbe von Hand angelegte Regel, derselbe Alias oder dieselbe NAT-Regel auf mehreren
Firewalls (z. B. beide HA-Knoten) wird nicht mehr als zweites Objekt übernommen. Im Dialog „Von Firewall übernehmen“ erscheint bei exakter
Übereinstimmung (nach Einsetzen der Host-Variablen, bei Aliasen inkl. Verweisen) die Aktion **Verknüpfen**: die Firewall wird weiteres Ziel des
vorhandenen Objekts, die Vorschau zeigt „keine Änderung“, und der nächste Rollout aktualisiert alle Firewalls an einem Objekt. Auf der Firewall wird
dabei nichts geändert. Unterscheidet sich ein gleichnamiges Objekt, zeigt der Dialog die abweichenden Felder; verknüpft wird dann nie. „Alle
übernehmbaren“ fragt vorher nach („3 verknüpfen, 2 neu übernehmen, 1 nicht übernehmbar“). Aliase, die bisher wegen „Name existiert“ abgelehnt wurden,
lassen sich so übernehmen.
Wartet das vorhandene Objekt gerade auf eine Bestätigung, wird nicht verknüpft; „Alle übernehmbaren“ lässt den Eintrag dann aus. Verknüpfen
macht ein gerade geöffnetes Bearbeiten-Formular des Objekts ungültig, damit die neue Firewall nicht versehentlich wieder aus den Zielen fällt.
- **Tooltips überarbeitet:** Der deaktivierte Button „Oberfläche öffnen“ nennt jetzt den Grund im Klartext samt nächstem Schritt (statt nur einer
Variablen wie `GUI_GATEWAY_TOKEN`); dasselbe gilt für ausgeschaltete Funktion, deaktivierte Firewall, Nicht-HTTPS-Adresse und ausgeschaltete
Zertifikatsprüfung. Texte durchgehend in Du-Form, kürzere VPN-Einleitungen, verständlichere Hinweise bei gesperrten Buttons (Zertifikate, Benutzer,
Dienste). Icon-Buttons (Bearbeiten, Löschen, Schließen, Reihenfolge) zeigen beim Darüberfahren ihren Namen.
## [1.27.2] "Buzzard" — 2026-10-21
- **`opnexus update` prüft den Proxy vor dem Wechsel:** Nach dem Ziehen der Images validiert das Werkzeug die Caddy-Konfiguration des neuen
+2 -2
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.27.2` 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.29.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -65,7 +65,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.27.2
OPNEXUS_VERSION=1.29.0
```
```bash
+2 -2
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.27.2` 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.29.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -67,7 +67,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.27.2
OPNEXUS_VERSION=1.29.0
```
```bash
+10
View File
@@ -94,6 +94,8 @@ class ErrorCode(str, Enum):
GUI_ACCESS_DISABLED = "GUI_ACCESS_DISABLED"
GUI_HOST_NOT_ELIGIBLE = "GUI_HOST_NOT_ELIGIBLE"
GUI_NO_FREE_SLOT = "GUI_NO_FREE_SLOT"
GUI_SLOT_COOLING_DOWN = "GUI_SLOT_COOLING_DOWN"
GUI_TOO_MANY_SESSIONS = "GUI_TOO_MANY_SESSIONS"
TLS_PROBE_FAILED = "TLS_PROBE_FAILED"
TLS_PEM_INVALID = "TLS_PEM_INVALID"
TLS_PIN_MISMATCH = "TLS_PIN_MISMATCH"
@@ -126,6 +128,10 @@ class ErrorCode(str, Enum):
OBJECT_NOT_ADOPTABLE = "OBJECT_NOT_ADOPTABLE"
ADOPT_OBJECT_NOT_FOUND = "ADOPT_OBJECT_NOT_FOUND"
OUTBOUND_MODE_BLOCKS_ADOPT = "OUTBOUND_MODE_BLOCKS_ADOPT"
ADOPT_LINK_TARGET_NOT_FOUND = "ADOPT_LINK_TARGET_NOT_FOUND"
ADOPT_LINK_TARGET_CHANGED = "ADOPT_LINK_TARGET_CHANGED"
ADOPT_LINK_HOST_HAS_OBJECT = "ADOPT_LINK_HOST_HAS_OBJECT"
ADOPT_LINK_BUSY = "ADOPT_LINK_BUSY"
REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0"
RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN"
ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL"
@@ -141,6 +147,10 @@ class ErrorCode(str, Enum):
UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN = "UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN"
UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT = "UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT"
UPSTREAM_REQUEST_FAILED = "UPSTREAM_REQUEST_FAILED"
API_RIGHTS_MISSING = "API_RIGHTS_MISSING"
GATEWAY_NAME_INVALID = "GATEWAY_NAME_INVALID"
GATEWAY_UNKNOWN = "GATEWAY_UNKNOWN"
GATEWAY_MUTE_LIMIT = "GATEWAY_MUTE_LIMIT"
VERKNUEPFTER_ACME_ACCOUNT_NICHT_GEFUNDEN = "VERKNUEPFTER_ACME_ACCOUNT_NICHT_GEFUNDEN"
VORGANG_KANN_NICHT_MEHR_ABGEBROCHEN_WERDEN = "VORGANG_KANN_NICHT_MEHR_ABGEBROCHEN_WERDEN"
VORSCHAU_ABGELAUFEN_BITTE_ERNEUT_OEFFNEN = "VORSCHAU_ABGELAUFEN_BITTE_ERNEUT_OEFFNEN"
+28 -6
View File
@@ -20,7 +20,10 @@ Security model (see the 1.27 design):
* At most 3 failed GUI logins per firewall in 5 minutes (protects OPNexus' API access from the firewall's
login lockout, which would see all attempts coming from OPNexus).
* Idle and absolute limits; every 20 s the backend re-checks the session (OPNexus session, admin role,
switch, host). Logs carry method, path (no query), status and duration only.
switch, host, and that the firewall's address, pinned certificate and overlay route are still the ones the
session started with).
* At most MAX_SESSIONS sessions at once: expired ones are cleared first, a new session is refused otherwise —
a running session is never evicted by a newer one. Logs carry method, path (no query), status and duration only.
OPNsense behaviour this relies on (opnsense/core, src/www/authgui.inc, master as of 2026-10-21):
* session_auth(): a login is ``isset($_POST['login']) && !empty($_POST['usernamefld']) &&
@@ -131,6 +134,7 @@ class GatewaySession:
idle_timeout: float
absolute_deadline: float
revalidate_every: float
target_fp: str | None = None
last_activity: float = field(default_factory=time.monotonic)
next_check: float = 0.0
jar: dict[str, str] = field(default_factory=dict)
@@ -156,7 +160,8 @@ class BackendClient:
async def revalidate(self, gs: GatewaySession) -> dict:
resp = await self._client.post("/internal/gui/revalidate",
json={"gui_session_id": gs.gui_session_id, "session_hash": gs.session_hash})
json={"gui_session_id": gs.gui_session_id, "session_hash": gs.session_hash,
"target_fp": gs.target_fp})
resp.raise_for_status()
return resp.json()
@@ -250,6 +255,8 @@ _LOGIN_LOCKED = _page("OPNexus", "Zu viele fehlgeschlagene Anmeldungen an dieser
"Too many failed sign-ins at this firewall. Please wait 5 minutes.")
_UNAVAILABLE = _page("OPNexus", "Die Firewall oder OPNexus ist gerade nicht erreichbar.",
"The firewall or OPNexus is not reachable right now.")
_TOO_MANY = _page("OPNexus", "Es laufen schon zu viele Firewall-Oberflächen gleichzeitig. Versuche es später erneut.",
"Too many firewall interfaces are open at the same time. Try again later.")
_REDEEMED = (b"<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"0;url=/\">"
b"<title>OPNexus</title></head><body><a href=\"/\">&rarr;</a></body></html>")
@@ -376,6 +383,17 @@ class GuiGateway:
gs.next_check = self.clock() + gs.revalidate_every
return True
async def _clear_expired(self) -> None:
for key, gs in list(self.sessions.items()):
reason = self._expired(gs)
if reason:
await self._drop(key, reason)
def _has_room(self, browser_hash: str, slot: int) -> bool:
"""Room for one more session; the one this redeem would replace (same browser session and slot) does not count."""
replaced = sum(1 for g in self.sessions.values() if g.slot == slot and g.session_hash == browser_hash)
return len(self.sessions) - replaced < MAX_SESSIONS
# ── request handling ──────────────────────────────────────────────────
async def _handle(self, scope, receive, send) -> int:
method = scope.get("method", "")
@@ -442,6 +460,10 @@ class GuiGateway:
if not ticket or len(ticket) > 128 or not browser_session:
return await self._respond(send, 403, _SESSION_GONE)
client_ip = (headers.get("x-forwarded-for", "").split(",")[0].strip() or None)
# Room first, before the ticket is burned: expired sessions go, running ones are never evicted.
await self._clear_expired()
if not self._has_room(_sha(browser_session), slot):
return await self._respond(send, 503, _TOO_MANY)
try:
result = await self.backend.redeem(_sha(ticket), _sha(browser_session), slot, client_ip and client_ip[:45])
except httpx.HTTPError as exc:
@@ -453,16 +475,16 @@ class GuiGateway:
for old_key, old in list(self.sessions.items()):
if old.slot == slot and old.session_hash == _sha(browser_session):
await self._drop(old_key, "superseded", report=False)
if len(self.sessions) >= MAX_SESSIONS:
oldest = min(self.sessions, key=lambda k: self.sessions[k].last_activity)
await self._drop(oldest, "superseded")
value = secrets.token_urlsafe(32)
gs = GatewaySession(
gui_session_id=result["gui_session_id"], session_hash=_sha(browser_session), slot=slot,
upstream_origin=result["upstream_origin"], ca_pem=result.get("ca_pem"), proxy_url=result.get("proxy_url"),
idle_timeout=float(result["idle_timeout_seconds"]), absolute_deadline=float(result["absolute_deadline"]),
revalidate_every=float(result.get("revalidate_seconds", 20)),
revalidate_every=float(result.get("revalidate_seconds", 20)), target_fp=result.get("target_fp"),
)
if len(self.sessions) >= MAX_SESSIONS: # filled up while the backend was answering
await self.backend.end(gs, "revoked")
return await self._respond(send, 503, _TOO_MANY)
gs.last_activity = self.clock()
gs.next_check = gs.last_activity + gs.revalidate_every
self.sessions[_sha(value)] = gs
+4
View File
@@ -68,6 +68,10 @@ class NotificationSettings(Base):
gateway_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
resource_threshold_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
service_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# An API key that cannot read gateway / service state makes those alerts silently blind (1.29): say so.
api_rights_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
# Gateways whose alert is switched off, "<host_id>:<gateway name>" (1.29), e.g. an idle second WAN.
gateway_alert_muted: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# Rollouts (1.25): a run paused (gate, failure, window...) or an automatic start did not happen.
rollout_problems_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
# Gateway: OPNsense' own verdict (down / loss / delay) decides "offline"; these two add an
+3
View File
@@ -44,6 +44,8 @@ class RolloutPlan(Base):
gate_checks: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
compliance_mode: Mapped[str] = mapped_column(String(8), nullable=False, default="warn", server_default="warn")
activated_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
# Stable id of that account (1.29, no foreign key on purpose: a deleted account stays gone, a new one with the same name is someone else).
activated_by_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
activated_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Why the last automatic start did not happen: {"occurrence", "code", "params", "at"} (notification source).
@@ -91,6 +93,7 @@ class RolloutRun(Base):
pause_reason: Mapped[str | None] = mapped_column(String(40), nullable=True)
pause_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
started_by: Mapped[str] = mapped_column(String(150))
started_by_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True) # see activated_by_id
trigger: Mapped[str] = mapped_column(String(8)) # manual | window
window_occurrence: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Optimistic lock: every state change bumps it; an engine step computed on an older revision is dropped.
+10 -3
View File
@@ -6,6 +6,7 @@ outbound NAT share (services/object_adopt_service.py has the logic and the evide
POST <prefix>/adopt-all take over everything adoptable {host_id}; returns adopted + skipped
Like rule adoption this only creates central records; nothing is written to the firewall."""
import json
import uuid
from typing import Callable
@@ -18,6 +19,7 @@ from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.schemas.adopt import AdoptAllRequest, AdoptAllResult, AdoptObjectRequest, UnmanagedObject
from app.services import object_adopt_service
from app.services.adopt_common import LINK_ERRORS, link_api_error
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
from app.services.policy_service import policy_lock
@@ -44,11 +46,14 @@ def add_adopt_routes(router: APIRouter, *, kind: str, model, load_options: Calla
await policy_lock(db)
host = await _host(db, payload.host_id)
try:
created = await object_adopt_service.adopt(db, kind, host, payload.uuid)
created = await object_adopt_service.adopt(db, kind, host, payload.uuid, link_to=payload.link_to)
except object_adopt_service.NotFoundOnHost as exc:
raise api_error(404, ErrorCode.ADOPT_OBJECT_NOT_FOUND) from exc
except object_adopt_service.NotAdoptable as exc:
raise api_error(409, ErrorCode.OBJECT_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
raise api_error(409, ErrorCode.OBJECT_NOT_ADOPTABLE, reasons=", ".join(exc.reasons),
reason_details=json.dumps(exc.details, ensure_ascii=False)) from exc
except LINK_ERRORS as exc:
raise link_api_error(exc) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
@@ -65,7 +70,9 @@ def add_adopt_routes(router: APIRouter, *, kind: str, model, load_options: Calla
await policy_lock(db)
host = await _host(db, payload.host_id)
try:
result = await object_adopt_service.adopt_all(db, kind, host)
result = await object_adopt_service.adopt_all(db, kind, host, link_existing=payload.link_existing)
except LINK_ERRORS as exc:
raise link_api_error(exc) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
+11 -17
View File
@@ -15,8 +15,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Plain name: used on HTTP-only (lab) deployments and accepted for a while on
# HTTPS deployments so that sessions issued before 1.27 survive the upgrade.
# Plain name: the session cookie of HTTP-only (lab) deployments. On HTTPS deployments it is NOT accepted any
# more (1.29): it is only still deleted (login, logout) so that a pre-1.27 cookie does not linger.
COOKIE_NAME = "opnexus-session"
# HTTPS deployments: the __Host- prefix makes the browser refuse any cookie of
# this name that is not Secure, has a Domain attribute or a Path other than
@@ -31,16 +31,11 @@ def session_cookie_name() -> str:
def session_token(request: Request) -> str:
"""The session token the browser sent. On HTTPS the __Host- cookie always
wins; the plain name is only a migration fallback when the browser has no
__Host- cookie yet (it is replaced on the next /api/auth/me)."""
if settings.cookie_secure:
return request.cookies.get(SECURE_COOKIE_NAME) or request.cookies.get(COOKIE_NAME, "")
return request.cookies.get(COOKIE_NAME, "")
def uses_legacy_cookie(request: Request) -> bool:
return settings.cookie_secure and not request.cookies.get(SECURE_COOKIE_NAME) and bool(request.cookies.get(COOKIE_NAME))
"""The session token the browser sent: the __Host- cookie on HTTPS deployments, the plain one otherwise.
Never the other name (1.29): a plain-name cookie can be planted by any plain-HTTP or sibling-subdomain page and
must not be able to carry a session on an HTTPS deployment (the __Host- prefix does not separate PORTS, see
BACKLOG S8)."""
return request.cookies.get(session_cookie_name(), "")
def has_session_cookie(request: Request) -> bool:
@@ -70,7 +65,7 @@ def _set_session_cookie(response: Response, token: str, max_age: int | None = No
path="/",
)
if settings.cookie_secure:
# Migration: a pre-1.27 plain-name cookie must not linger next to the new one.
# A pre-1.27 plain-name cookie must not linger next to the new one.
response.delete_cookie(key=COOKIE_NAME, path="/", secure=True, httponly=True, samesite="lax")
@@ -298,6 +293,9 @@ async def totp_disable(
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
token = session_token(request)
await auth_service.revoke_session(db, token)
legacy = request.cookies.get(COOKIE_NAME, "")
if settings.cookie_secure and legacy and legacy != token:
await auth_service.revoke_session(db, legacy) # an old plain-name cookie ends with the logout too
_clear_session_cookie(response)
return {"authenticated": False}
@@ -316,10 +314,6 @@ async def me(request: Request, response: Response, db: AsyncSession = Depends(ge
user = await auth_service.resolve_session(db, token)
if user is None:
return {"authenticated": False}
if uses_legacy_cookie(request):
# Soft migration (1.27): the same server-side session moves to the
# __Host- cookie and the plain-name cookie is deleted — nobody is logged out.
_set_session_cookie(response, token)
return {"authenticated": True, "username": user.username, "role": user.role}
+19 -10
View File
@@ -49,7 +49,7 @@ from app.models.wireguard_tunnel import WireguardTunnel
from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment
from app.schemas.dashboard import ActivityItem, DashboardHost, DashboardSummary, PendingAction
from app.models.resource_sample import HostResourceSample
from app.services import gateway_health_service
from app.services import gateway_health_service, notification_settings_service
from app.services.host_status_service import get_statuses
from app.services.monitor_checks import STALE_AFTER
@@ -207,34 +207,43 @@ async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
return pending_actions
async def _gateways_by_host(db: AsyncSession, hosts) -> dict:
"""Gateway state from each host's newest resource sample; hosts without a fresh sample are left out."""
result: dict = {}
async def _sample_readings_by_host(db: AsyncSession, hosts, muted: set[str]) -> tuple[dict, dict]:
"""(gateways, missing API rights) from each host's newest resource sample; hosts without a fresh sample are left out."""
gateways: dict = {}
denied: dict = {}
now = datetime.now(timezone.utc)
for host in hosts:
row = (
await db.execute(
select(HostResourceSample.sampled_at, HostResourceSample.raw["gateways"].as_json())
select(HostResourceSample.sampled_at, HostResourceSample.raw["gateways"].as_json(), HostResourceSample.raw["denied"].as_json())
.where(HostResourceSample.host_id == host.id)
.order_by(HostResourceSample.sampled_at.desc())
.limit(1)
)
).first()
if row is not None and row[1] is not None and now - row[0] <= STALE_AFTER:
result[host.id] = gateway_health_service.from_compact(row[1])
return result
if row is None or now - row[0] > STALE_AFTER:
continue
if row[1] is not None:
gateways[host.id] = [
g.model_copy(update={"alert_enabled": f"{host.id}:{g.name}" not in muted})
for g in gateway_health_service.from_compact(row[1])
]
if isinstance(row[2], list):
denied[host.id] = [str(x) for x in row[2]]
return gateways, denied
@router.get("", response_model=DashboardSummary)
async def summary(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
statuses = await get_statuses(hosts)
gateways = await _gateways_by_host(db, hosts)
muted = set((await notification_settings_service.get_settings(db)).gateway_alert_muted or [])
gateways, denied = await _sample_readings_by_host(db, hosts, muted)
host_summaries = [
DashboardHost(
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
gateways=gateways.get(host.id),
gateways=gateways.get(host.id), api_rights_missing=denied.get(host.id),
)
for host in hosts
]
+78 -4
View File
@@ -1,17 +1,26 @@
import re
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.schemas.gateway import GatewayHealth
from app.services import gateway_health_service
from app.services.opnsense_client import OpnsenseAPIError
from app.models.resource_sample import HostResourceSample
from app.schemas.gateway import ApiRightsState, GatewayAlertState, GatewayAlertUpdate, GatewayHealth
from app.services import audit_service, gateway_health_service, notification_settings_service
from app.services.monitor_checks import STALE_AFTER
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseForbiddenError
router = APIRouter(prefix="/hosts/{host_id}/gateways", tags=["host-gateways"])
# OPNsense gateway names are letters, digits and underscores (WAN_GW, WAN_DHCP6); a little more is allowed.
_GATEWAY_NAME = re.compile(r"[A-Za-z0-9_.-]{1,64}")
MAX_MUTED_GATEWAYS = 200
@router.get("", response_model=list[GatewayHealth])
async def list_gateways(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
@@ -20,6 +29,71 @@ async def list_gateways(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await gateway_health_service.get_gateway_health(host)
gateways = await gateway_health_service.get_gateway_health(host)
except OpnsenseForbiddenError as exc:
# The API key may not read the gateway state: say so instead of a generic upstream failure.
raise api_error(403, ErrorCode.API_RIGHTS_MISSING) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
muted = set((await notification_settings_service.get_settings(db)).gateway_alert_muted or [])
return [g.model_copy(update={"alert_enabled": f"{host.id}:{g.name}" not in muted}) for g in gateways]
@router.get("/api-rights", response_model=ApiRightsState)
async def api_rights(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Side readings the newest FRESH resource sample could not take because the API key lacks the right
(null = no fresh sample, so nothing is known). Stored data only: no call to the firewall."""
if await db.get(OpnsenseHost, host_id) is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
row = (
await db.execute(
select(HostResourceSample.sampled_at, HostResourceSample.raw["denied"].as_json())
.where(HostResourceSample.host_id == host_id)
.order_by(HostResourceSample.sampled_at.desc())
.limit(1)
)
).first()
if row is None or datetime.now(timezone.utc) - row[0] > STALE_AFTER or not isinstance(row[1], list):
return ApiRightsState(missing=None)
return ApiRightsState(missing=[str(x) for x in row[1]])
@router.put("/{name}/alert", response_model=GatewayAlertState)
async def set_gateway_alert(host_id: uuid.UUID, name: str, payload: GatewayAlertUpdate, db: AsyncSession = Depends(get_db)):
"""Switch the gateway-down alert on or off for ONE gateway (admins only, see api_token_service)."""
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
if not _GATEWAY_NAME.fullmatch(name):
raise api_error(422, ErrorCode.GATEWAY_NAME_INVALID)
if not payload.enabled:
# Only a gateway the firewall really reported can be muted ("wan_gw" vs "WAN_GW" would silently do nothing).
# Without a usable sample nothing is known, so the request goes through.
known = (
await db.execute(
select(HostResourceSample.raw["gateways"].as_json())
.where(HostResourceSample.host_id == host_id)
.order_by(HostResourceSample.sampled_at.desc())
.limit(1)
)
).scalar()
if isinstance(known, list) and name not in {g.get("name") for g in known if isinstance(g, dict)}:
raise api_error(404, ErrorCode.GATEWAY_UNKNOWN)
row = await notification_settings_service.get_settings(db)
await db.refresh(row, with_for_update=True)
muted = list(row.gateway_alert_muted or [])
key = f"{host.id}:{name}"
if payload.enabled:
muted = [m for m in muted if m != key]
elif key not in muted:
if len(muted) >= MAX_MUTED_GATEWAYS:
raise api_error(422, ErrorCode.GATEWAY_MUTE_LIMIT, limit=str(MAX_MUTED_GATEWAYS))
muted.append(key)
row.gateway_alert_muted = muted # a new list, so the JSON column notices the change
await db.commit()
# The request middleware logs the call itself; this entry says what it did (only the validated name goes in).
await audit_service.persist(
kind="host", action="gateway_alert_on" if payload.enabled else "gateway_alert_off", entity_id=str(host.id),
host_id=str(host.id), host_name=host.name, status="accepted", message=f"Gateway {name}",
)
return GatewayAlertState(name=name, alert_enabled=payload.enabled)
+7 -2
View File
@@ -159,9 +159,13 @@ class RedeemRequest(BaseModel):
class RevalidateRequest(BaseModel):
gui_session_id: uuid.UUID
session_hash: str = Field(pattern=_HEX64)
# Fingerprint of the target as handed out at redeem time (optional: an older gateway image omits it).
target_fp: str | None = Field(default=None, pattern=_HEX64)
class EndRequest(RevalidateRequest):
class EndRequest(BaseModel):
gui_session_id: uuid.UUID
session_hash: str = Field(pattern=_HEX64)
reason: Literal["idle", "absolute", "logout", "revoked", "superseded", "shutdown"]
@@ -176,7 +180,8 @@ async def internal_redeem(payload: RedeemRequest, db: AsyncSession = Depends(get
@internal_router.post("/revalidate", dependencies=[Depends(_gateway_auth)])
async def internal_revalidate(payload: RevalidateRequest, db: AsyncSession = Depends(get_db)):
return await svc.revalidate(db, gui_session_id=payload.gui_session_id, session_hash=payload.session_hash)
return await svc.revalidate(db, gui_session_id=payload.gui_session_id, session_hash=payload.session_hash,
target_fp=payload.target_fp)
@internal_router.post("/end", dependencies=[Depends(_gateway_auth)])
+6
View File
@@ -226,6 +226,12 @@ async def delete_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
draft_certs = (await db.scalars(select(ManagedCertificate.id).where(ManagedCertificate.host_id == host_id))).all()
draft_accounts = (await db.scalars(select(AcmeAccount.id).where(AcmeAccount.host_id == host_id))).all()
await db.delete(host)
# Gateway alert switches of this host would otherwise stay in the settings forever (and count against the limit).
from app.services import notification_settings_service
settings_row = await notification_settings_service.get_settings(db)
prefix = f"{host_id}:"
if any(str(m).startswith(prefix) for m in settings_row.gateway_alert_muted or []):
settings_row.gateway_alert_muted = [m for m in settings_row.gateway_alert_muted if not str(m).startswith(prefix)]
await db.commit()
opnsense_secret_store.delete_secret(host_id)
for cert_id in draft_certs:
+29 -4
View File
@@ -22,6 +22,28 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["oidc"])
CALLBACK_PATH = "/api/auth/oidc/callback"
STATE_COOKIE = "opnexus-oidc-state"
# HTTPS deployments (1.29): __Host- (Secure, no Domain, Path=/) so that no sibling SUBDOMAIN or plain-HTTP page can
# plant a state cookie of its own (login CSRF with the attacker's own authorisation). Known limit: cookies are not
# separated by PORT, so script in a firewall GUI shown on a gateway slot port of the same host could still set it
# (BACKLOG S8, accepted: needs a compromised firewall GUI and a mapped IdP account; the real fix is one hostname
# per slot). The browser only accepts the prefix with Path=/, so the cookie travels with every request for its
# 10 minutes; plain-HTTP labs keep the narrow path.
SECURE_STATE_COOKIE = "__Host-opnexus-oidc-state"
STATE_COOKIE_PATH = "/api/auth/oidc"
def state_cookie_name() -> str:
return SECURE_STATE_COOKIE if settings.cookie_secure else STATE_COOKIE
def _state_cookie_path() -> str:
return "/" if settings.cookie_secure else STATE_COOKIE_PATH
def _clear_state_cookie(response) -> None:
response.delete_cookie(state_cookie_name(), path=_state_cookie_path(), secure=settings.cookie_secure, httponly=True, samesite="lax")
if settings.cookie_secure: # a plain-name cookie of an earlier version
response.delete_cookie(STATE_COOKIE, path=STATE_COOKIE_PATH, secure=True, httponly=True, samesite="lax")
# 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
@@ -29,7 +51,7 @@ 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")
_clear_state_cookie(response)
return response
@@ -63,7 +85,8 @@ async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db
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")
response.set_cookie(state_cookie_name(), state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure,
path=_state_cookie_path())
return response
@@ -78,7 +101,9 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
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):
# Only the cookie name of this deployment counts (on HTTPS never the plain one); no cookie never matches.
bound = request.cookies.get(state_cookie_name(), "")
if not bound or not secrets.compare_digest(bound.encode(), state.encode()):
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:
@@ -92,7 +117,7 @@ async def callback(request: Request, code: str = "", state: str = "", error: str
)
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")
_clear_state_cookie(response)
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
return response
+7 -5
View File
@@ -106,7 +106,7 @@ async def _apply(db: AsyncSession, plan: RolloutPlan, payload: PlanIn) -> None:
for i, w in enumerate(payload.waves)
]
# Any edit disarms the automatic start: an operator cannot quietly change what an admin activated.
plan.activated_at = plan.activated_by = plan.activated_fingerprint = None
plan.activated_at = plan.activated_by = plan.activated_by_id = plan.activated_fingerprint = None
plan.auto_start_error = None
@@ -180,6 +180,7 @@ async def activate_plan(plan_id: uuid.UUID, payload: TokenIn, request: Request,
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="auto_start")
_resolved, current = await _checked_token(db, plan, payload.preview_token)
plan.activated_by, plan.activated_at, plan.activated_fingerprint = request.state.user.username, datetime.now(timezone.utc), current
plan.activated_by_id = request.state.user.id
plan.auto_start_error = None
await db.commit()
await audit_service.persist(kind="rollout", action="activate", status="completed", entity_id=str(plan.id),
@@ -191,7 +192,7 @@ async def activate_plan(plan_id: uuid.UUID, payload: TokenIn, request: Request,
async def deactivate_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
plan = await _plan_or_404(db, plan_id)
plan.activated_by = plan.activated_at = plan.activated_fingerprint = None
plan.activated_by = plan.activated_by_id = plan.activated_at = plan.activated_fingerprint = None
await db.commit()
await audit_service.persist(kind="rollout", action="deactivate", status="completed", entity_id=str(plan.id), entity_name=plan.name)
return await read_plan(db, await _plan_or_404(db, plan_id))
@@ -306,7 +307,8 @@ async def start_plan(plan_id: uuid.UUID, payload: StartIn, request: Request, db:
if (other := await rollout_engine.active_run(db)) is not None:
# One run at a time: two runs could otherwise touch the same cluster at once.
raise api_error(409, ErrorCode.ROLLOUT_ALREADY_ACTIVE, plan=other.plan_name)
run = await rollout_engine.create_run(db, plan, resolved, started_by=request.state.user.username, trigger="manual")
run = await rollout_engine.create_run(db, plan, resolved, started_by=request.state.user.username,
started_by_id=request.state.user.id, trigger="manual")
await db.commit()
await audit_service.persist(kind="rollout", action="start", status="started", entity_id=str(run.id), entity_name=plan.name,
details={"trigger": "manual", "hosts": sum(len(w["hosts"]) for w in resolved["waves"]),
@@ -363,7 +365,7 @@ async def resume_run(run_id: uuid.UUID, request: Request, db: AsyncSession = Dep
raise api_error(409, ErrorCode.ROLLOUT_HOST_FAILED, hosts=", ".join(failed))
previous = run.started_by
# Whoever resumes takes over the responsibility (and the permission check) from here on.
run.started_by = request.state.user.username
run.started_by, run.started_by_id = request.state.user.username, request.state.user.id
run.status = "resuming"
run.pause_reason = run.pause_detail = None
run.progress = {**(run.progress or {}), "resume_attempts": 0, "resume_recheck_at": None, "paused_at": None}
@@ -378,7 +380,7 @@ async def approve_wave(run_id: uuid.UUID, request: Request, db: AsyncSession = D
raise _not_now(run)
wave = rollout_engine.wave_row(run, run.current_wave)
wave.approved_by, wave.status = request.state.user.username, "preflight"
run.started_by = request.state.user.username
run.started_by, run.started_by_id = request.state.user.username, request.state.user.id
run.status = "preflight"
run.progress = {**(run.progress or {}), "preflight_started": None, "preflight_requested_at": None}
return await _done(db, run, "approve", "approved", request, wave_label=wave.label)
+7 -2
View File
@@ -1,3 +1,4 @@
import json
import uuid
from fastapi import APIRouter, Depends, Body
@@ -18,6 +19,7 @@ from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment
from app.services import rule_adopt_service
from app.services.adopt_common import LINK_ERRORS, link_api_error
from app.schemas.rule import AdoptRequest, UnmanagedRule
router = APIRouter(prefix="/rules", tags=["rules"])
@@ -107,11 +109,14 @@ async def adopt_rule(payload: AdoptRequest, db: AsyncSession = Depends(get_db)):
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid)
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid, link_to=payload.link_to)
except rule_adopt_service.RuleNotFoundOnHost as exc:
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) from exc
except rule_adopt_service.NotAdoptable as exc:
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons),
reason_details=json.dumps(exc.details, ensure_ascii=False)) from exc
except LINK_ERRORS as exc:
raise link_api_error(exc) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
+28
View File
@@ -5,11 +5,31 @@ from pydantic import BaseModel, Field
class AdoptObjectRequest(BaseModel):
host_id: uuid.UUID
# Link to this existing central object instead of creating a new one (services/adopt_link_service.py).
# Declared before `uuid`: that field's name shadows the module inside the class body.
link_to: uuid.UUID | None = None
uuid: str = Field(min_length=1, max_length=64) # the object's UUID on the firewall
class AdoptAllRequest(BaseModel):
host_id: uuid.UUID
# Link entries that have exactly one exact central twin instead of creating a duplicate.
link_existing: bool = False
class LinkDiff(BaseModel):
field: str # model field ("content", "destination_port", ...) or "variables" (a {{VARIABLE}} has no value on this host)
central: str
live: str
class LinkTarget(BaseModel):
"""An existing central object that a live object could be linked to (exact match) or is similar to."""
id: uuid.UUID
name: str
targets: list[str] # names of the hosts it is deployed to
diff: list[LinkDiff] = Field(default_factory=list) # empty for an exact match
class UnmanagedObject(BaseModel):
@@ -28,12 +48,20 @@ class UnmanagedObject(BaseModel):
kept: list[str] = Field(default_factory=list)
# Facts for display, depend on the object type.
info: dict[str, str] = Field(default_factory=dict)
# Central objects whose content, resolved for this host, equals the live object exactly (first = preferred).
matches: list[LinkTarget] = Field(default_factory=list)
# Objects with the same name/description but different content: a hint with the differing fields, never linked.
similar: list[LinkTarget] = Field(default_factory=list)
linkable: bool = False # can be linked to `matches[0]` (or another match) on its own
# What "adopt all" would do: "link" | "new" | "skip" (None only on the adopt-all result).
bulk_action: str | None = None
class AdoptedObject(BaseModel):
uuid: str
title: str
id: uuid.UUID
linked: bool = False # linked to an existing central object instead of newly created
class SkippedObject(BaseModel):
+3
View File
@@ -14,6 +14,9 @@ class DashboardHost(BaseModel):
cluster_name: str | None
# From the last resource sample (no live call); None when there is no sample from the last 3 minutes.
gateways: list[GatewayHealth] | None = None
# Readings the newest fresh sample could not take because the API key lacks the right (gateways, services, ...);
# None = no fresh sample (or one from before this existed), [] = nothing missing.
api_rights_missing: list[str] | None = None
class PendingAction(BaseModel):
+14
View File
@@ -17,3 +17,17 @@ class GatewayHealth(BaseModel):
delay: float | None = None # milliseconds
stddev: float | None = None # milliseconds
monitor: str | None = None # the monitored address
alert_enabled: bool = True # False: the gateway alert is switched off for this gateway (Settings, 1.29)
class GatewayAlertUpdate(BaseModel):
enabled: bool
class GatewayAlertState(BaseModel):
name: str
alert_enabled: bool
class ApiRightsState(BaseModel):
missing: list[str] | None = None # names of the refused readings (gateways, services, swap, cpu); None = unknown
+4 -1
View File
@@ -16,7 +16,7 @@ ProblemType = Literal[
"backup_stale",
"backup_offsite_failed",
"compliance_finding",
"gateway_down", "resource_threshold", "service_down",
"gateway_down", "resource_threshold", "service_down", "api_rights_missing",
"rollout_paused", "rollout_start_failed",
]
ChannelType = Literal["ntfy", "gotify", "telegram", "teams", "matrix"]
@@ -54,6 +54,8 @@ class NotificationSettingsRead(BaseModel):
resource_threshold_enabled: bool = False
service_down_enabled: bool = False
rollout_problems_enabled: bool = True
api_rights_enabled: bool = True
gateway_alert_muted: list[str] = Field(default_factory=list)
gateway_loss_threshold_pct: int = 20
gateway_delay_threshold_ms: int = 500
disk_threshold_pct: int = 90
@@ -111,6 +113,7 @@ class NotificationSettingsUpdate(BaseModel):
resource_threshold_enabled: bool | None = None
service_down_enabled: bool | None = None
rollout_problems_enabled: bool | None = None
api_rights_enabled: bool | None = None
# 0 switches that single threshold off.
gateway_loss_threshold_pct: int | None = Field(default=None, ge=0, le=100)
gateway_delay_threshold_ms: int | None = Field(default=None, ge=0, le=60000)
+8
View File
@@ -4,6 +4,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.adopt import LinkTarget
class RuleCreate(BaseModel):
description: str = Field(min_length=1, max_length=255)
@@ -78,6 +80,8 @@ class DeployApproval(BaseModel):
class AdoptRequest(BaseModel):
host_id: uuid.UUID
# Link to this existing central rule instead of creating a new one (services/adopt_link_service.py).
link_to: uuid.UUID | None = None
rule_uuid: str = Field(min_length=1, max_length=64)
@@ -98,3 +102,7 @@ class UnmanagedRule(BaseModel):
adoptable: bool
# Codes such as "negated_source" or "not_quick"; the UI turns them into text.
reasons: list[str]
# Central rules equal to this one (resolved for the host), same-description rules that differ, link possible.
matches: list[LinkTarget] = Field(default_factory=list)
similar: list[LinkTarget] = Field(default_factory=list)
linkable: bool = False
+13 -1
View File
@@ -48,9 +48,19 @@ def _origin_allowed(origin: str, host_header: str) -> bool:
# The SSO provider sends the browser back here by a cross-site navigation;
# the endpoint authenticates by its own state cookie and creates the session.
# The exemption is only for exactly that: a top-level GET navigation (a script, image or fetch from another site
# is still blocked, whatever it carries) — and the callback fails without the matching state cookie anyway.
_SEC_FETCH_EXEMPT = {"/api/auth/oidc/callback"}
def _is_top_level_navigation(request: Request) -> bool:
if request.method != "GET":
return False
mode = request.headers.get("sec-fetch-mode", "").strip().lower()
dest = request.headers.get("sec-fetch-dest", "").strip().lower()
return mode in ("", "navigate") and dest in ("", "document")
def _explicit_frontend_origin(origin: str) -> bool:
origin = origin.rstrip("/").lower()
if not origin or origin == "null":
@@ -62,7 +72,9 @@ def _explicit_frontend_origin(origin: str) -> bool:
def sec_fetch_blocked(request: Request) -> bool:
path = request.url.path
if not (path == "/api" or path.startswith("/api/")) or path in _SEC_FETCH_EXEMPT:
if not (path == "/api" or path.startswith("/api/")):
return False
if path in _SEC_FETCH_EXEMPT and _is_top_level_navigation(request):
return False
if not has_session_cookie(request):
return False
+36
View File
@@ -25,3 +25,39 @@ def on(value) -> bool:
def clean(value) -> str:
return "" if value is None else str(value).strip()
class LinkTargetNotFound(Exception):
"""The central object chosen to link to was deleted in the meantime."""
class LinkTargetChanged(Exception):
"""The central object no longer equals what is live on the host. `diff` lists the differing fields."""
def __init__(self, diff: list[dict]):
super().__init__(", ".join(d["field"] for d in diff))
self.diff = diff
class HostAlreadyHasObject(Exception):
"""The central object is already deployed to / managed on this host under another UUID."""
class LinkTargetBusy(Exception):
"""The central object has an unconfirmed deployment or a failed rollback; linking waits until it is settled."""
LINK_ERRORS = (LinkTargetNotFound, LinkTargetChanged, HostAlreadyHasObject, LinkTargetBusy)
def link_api_error(exc: Exception):
"""The HTTP error for a failed link (shared by the rule and the object endpoints)."""
from app.errors import ErrorCode, api_error
if isinstance(exc, LinkTargetNotFound):
return api_error(404, ErrorCode.ADOPT_LINK_TARGET_NOT_FOUND)
if isinstance(exc, LinkTargetChanged):
return api_error(409, ErrorCode.ADOPT_LINK_TARGET_CHANGED, fields=", ".join(d["field"] for d in exc.diff))
if isinstance(exc, HostAlreadyHasObject):
return api_error(409, ErrorCode.ADOPT_LINK_HOST_HAS_OBJECT)
return api_error(409, ErrorCode.ADOPT_LINK_BUSY)
+151
View File
@@ -0,0 +1,151 @@
"""Links a hand-made object that was found on a firewall to a central object that already exists, instead
of creating a second central object for it ("Mit vorhandenem zentralem Objekt verknüpfen").
Typical case: the same rule/alias/NAT entry was made by hand on several firewalls (or on both nodes of an HA
pair). The first adoption creates the central object; every further firewall is LINKED to it: the host
becomes another target of that object and gets a deployment row holding the live UUID (remote_uuid) and
the payload OPNexus would send. Like adopting, linking writes NOTHING to the firewall. The live state is
read fresh from the host at link time (the browser only names the central object), so the preview right
afterwards says "unchanged" and a later rollout updates the one object on every host without a duplicate.
A central object is an EXACT match when every field OPNexus manages is equal after the object's
{{VARIABLE}} placeholders are replaced with this host's values (variables_service.resolve). An object with
the same identity (alias name / rule description) but different content is only shown as a hint with the
differing fields and is NEVER linked automatically.
Callers hold policy_lock (same serialisation as adopting), which also makes a repeated link idempotent.
"""
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.host import OpnsenseHost
from app.models.policy import DeviceGroup
from app.services import variables_service
from app.services.adopt_common import HostAlreadyHasObject, LinkTargetBusy, LinkTargetChanged, LinkTargetNotFound
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
from app.services.policy_service import effective_hosts
LINK_MESSAGE = "Mit vorhandenem Objekt verknüpft"
BUSY_STATUSES = ("pending", "revert_failed") # the central object waits for a confirmation / failed to roll back
@dataclass(frozen=True)
class LinkSpec:
model: type
deployment: type
fk: str # deployment column pointing at the central object
uuid_attr: str # deployment column holding the OPNsense UUID (remote_uuid)
identity: str # attribute naming the object: "name" (alias) or "description"
payload: Callable[[Any, OpnsenseHost], dict]
revision: Callable
@dataclass
class Match:
obj: Any
diff: list[dict] # empty = exact match
busy: bool = False # the central object waits for a confirmation: linking is refused until that is settled
def _norm(value) -> Any:
if value is None:
return ""
return value if isinstance(value, bool) else str(value)
def _show(value) -> str:
return ("true" if value else "false") if isinstance(value, bool) else ("" if value is None else str(value))
def diff_fields(obj, values: dict, host: OpnsenseHost) -> list[dict]:
"""Fields in which the central object (resolved for `host`) differs from the live `values`."""
raw = {key: getattr(obj, key) for key in values}
try:
central = variables_service.resolve(raw, host)
except variables_service.MissingVariables as exc:
# Cannot be compared (and could not be deployed to this host either): report, never match.
return [{"field": "variables", "central": ", ".join(exc.missing[host.name]), "live": ""}]
return [{"field": key, "central": _show(central[key]), "live": _show(values[key])}
for key in values if _norm(central[key]) != _norm(values[key])]
def _options(spec: LinkSpec):
return [
selectinload(spec.model.targets),
selectinload(spec.model.groups).selectinload(DeviceGroup.hosts),
selectinload(spec.model.deployments),
]
async def load_central(db: AsyncSession, spec: LinkSpec) -> list:
objs = list((await db.scalars(select(spec.model).options(*_options(spec)))).unique().all())
await hydrate_hierarchy_targets(db, objs)
return objs
def find(objs: list, spec: LinkSpec, host: OpnsenseHost, values: dict) -> tuple[list[Match], list[Match]]:
"""(exact matches, same-identity objects that differ). Objects that already have a deployment on
this host are not candidates: linking would collide with that deployment."""
exact: list[Match] = []
similar: list[Match] = []
for obj in objs:
if any(d.host_id == host.id for d in obj.deployments):
continue
if _norm(getattr(obj, spec.identity)) != _norm(values[spec.identity]):
continue
d = diff_fields(obj, values, host)
busy = any(dep.status in BUSY_STATUSES for dep in obj.deployments)
(similar if d else exact).append(Match(obj, d, busy))
# prefer the object that already reaches the most hosts (the "real" one), then a stable order
exact.sort(key=lambda m: (-len(effective_hosts(m.obj)), str(m.obj.id)))
return exact, similar
def describe(spec: LinkSpec, match: Match) -> dict:
return {
"id": match.obj.id,
"name": getattr(match.obj, spec.identity),
"targets": [h.name for h in effective_hosts(match.obj)],
"diff": match.diff,
}
async def link(db: AsyncSession, spec: LinkSpec, host: OpnsenseHost, object_id, remote_uuid: str, values: dict):
"""Links the live object `remote_uuid` (whose normalised fields are `values`, read fresh by the caller)
to the central object `object_id`. Returns it; a repeated call for the same pair changes nothing."""
obj = (await db.execute(
select(spec.model).where(spec.model.id == object_id).options(*_options(spec))
.execution_options(populate_existing=True)
)).unique().scalar_one_or_none()
if obj is None:
raise LinkTargetNotFound(str(object_id))
await hydrate_hierarchy_targets(db, [obj])
existing = next((d for d in obj.deployments if d.host_id == host.id), None)
if existing is not None:
if getattr(existing, spec.uuid_attr) == remote_uuid:
return obj # already linked: nothing to do
raise HostAlreadyHasObject(str(object_id))
if any(d.status in BUSY_STATUSES for d in obj.deployments):
raise LinkTargetBusy(str(object_id))
changed = diff_fields(obj, values, host)
if changed:
raise LinkTargetChanged(changed)
if host.id not in {h.id for h in effective_hosts(obj)}:
obj.targets.append(host)
# an association-only change does not touch the row; stale edit forms must still be refused (see edit_rule)
obj.updated_at = datetime.now(timezone.utc)
await db.flush()
spec.revision(db, obj, f"Mit {host.name} verknüpft")
db.add(spec.deployment(**{
spec.fk: obj.id, "host_id": host.id, "status": "success", "message": LINK_MESSAGE,
spec.uuid_attr: remote_uuid, "last_payload": spec.payload(obj, host), "previous_payload": None,
}))
await db.flush()
return obj
@@ -50,6 +50,8 @@ _ADMIN_ONLY_WRITES = re.compile(
r"|^/api/logs/settings$"
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
r"|^/api/hosts/[^/]+/gui-ticket$"
# The per-gateway alert switch is a notification setting (same rights as PUT /api/notifications/settings).
r"|^/api/hosts/[^/]+/gateways/[^/]+/alert$"
)
+66 -2
View File
@@ -11,6 +11,12 @@ Flow (see docs in routers/gui_access.py):
A slot stays assigned to one firewall for as long as that browser session has the firewall open; another
firewall gets another slot (another origin), so two firewall GUIs never share an origin at the same time.
After the last gateway session of a slot ended, the slot goes to a DIFFERENT firewall only after SLOT_COOLDOWN
(1.29, S1): a popup of a possibly compromised firewall GUI may still be open and its cookies belong to that
origin. The same firewall may take the slot again at once.
The target the gateway talks to (origin, pinned CA, overlay proxy) is fingerprinted at redeem time; every
re-check compares it with the host's current configuration and ends the session when it changed (S4).
"""
import hashlib
import secrets
@@ -33,7 +39,13 @@ TICKET_TTL = timedelta(seconds=30)
# 20 s: a switch-off, logout or role change must end a gateway session within 30 s of the change, and
# the last re-check can lie up to one interval before it (live test 2026-10-21: 30 s gave 31 s).
REVALIDATE_SECONDS = 20
# A gateway session nobody re-checked for this long is treated as ended (gateway crashed without telling us).
SESSION_STALE_AFTER = timedelta(seconds=REVALIDATE_SECONDS * 3)
MAX_SLOTS = 4
# A slot freed by firewall A goes to firewall B no earlier than this long after A's session ended.
SLOT_COOLDOWN = timedelta(minutes=5)
# Open gateway sessions at once (the gateway container enforces the same number; test_gui_access checks they match).
MAX_GATEWAY_SESSIONS = 64
MIN_GATEWAY_TOKEN_LENGTH = 32
AUDIT_KIND = "gui_access"
@@ -114,6 +126,13 @@ def firewall_origin(base_url: str) -> str:
return f"https://{parts.netloc.rsplit('@', 1)[-1]}"
def target_fingerprint(host: OpnsenseHost) -> str:
"""What a gateway session talks to: origin, pinned CA and overlay route. Changes -> the session must end."""
material = "\n".join([firewall_origin(host.base_url), host.tls_ca_pem or "", overlay_service.proxy_for(host.base_url) or "",
"verify" if host.verify_tls else "noverify"])
return hash_value(material)
# ── audit ─────────────────────────────────────────────────────────────────
async def _audit(actor: str, *, action: str, status: str, host: OpnsenseHost | None = None,
@@ -159,6 +178,30 @@ async def _pick_slot(db: AsyncSession, row: GuiAccessSettings, session_hash: str
free = [s for s in slots if s not in in_use]
if not free:
raise GuiAccessError(409, ErrorCode.GUI_NO_FREE_SLOT, {"slots": str(len(slots))})
# S1: a slot this user's browser may still have a window of ANOTHER firewall on stays closed to this
# firewall — while a gateway session of the user is open there (any browser session) and for SLOT_COOLDOWN
# after the last one ended. The same firewall may take it again at once.
held = (await db.execute(
select(GuiSession.slot, GuiSession.host_id, GuiSession.ended_at, GuiSession.last_checked_at).where(
GuiSession.user_id == user_id,
((GuiSession.ended_at.is_(None)) & ((GuiSession.started_at > now - absolute) | (GuiSession.last_checked_at > now - SLOT_COOLDOWN)))
| (GuiSession.ended_at > now - SLOT_COOLDOWN)))).all()
cooling: dict[int, datetime] = {} # slot -> when it may go to another firewall
for slot, h_id, ended_at, checked_at in held:
if h_id == host_id or slot not in free:
continue
if ended_at is None and checked_at is not None and now - checked_at > SESSION_STALE_AFTER:
ended_at = checked_at # the gateway stopped re-checking it (crash, kill): it ended when it was last seen
release = (ended_at + SLOT_COOLDOWN) if ended_at is not None else now + SLOT_COOLDOWN
if release <= now:
continue # cooled down long ago (a session that stopped answering a while back)
cooling[slot] = max(cooling.get(slot, release), release)
usable = [s for s in free if s not in cooling]
if not usable:
wait = min(cooling.values()) - now
raise GuiAccessError(409, ErrorCode.GUI_SLOT_COOLING_DOWN,
{"minutes": str(max(1, -(-int(wait.total_seconds()) // 60)))})
free = usable
# Prefer the slot this user had this firewall on before, then the slot the user used least recently
# (an old tab of another firewall on a reused origin is the documented residual risk).
history = (await db.execute(
@@ -182,6 +225,13 @@ async def issue_ticket(db: AsyncSession, *, user: AdminUser, session_token: str,
await db.execute(delete(GuiTicket).where(GuiTicket.expires_at < now - timedelta(hours=1)))
session_hash = hash_value(session_token)
slot = await _pick_slot(db, row, session_hash, user.id, host.id)
# Reopening a slot in the same browser session replaces its earlier gateway session, so that one does not count.
open_count = await db.scalar(select(func.count()).select_from(GuiSession).where(
GuiSession.ended_at.is_(None),
GuiSession.started_at > now - timedelta(minutes=row.absolute_timeout_minutes),
~((GuiSession.session_hash == session_hash) & (GuiSession.slot == slot))))
if (open_count or 0) >= MAX_GATEWAY_SESSIONS:
raise GuiAccessError(409, ErrorCode.GUI_TOO_MANY_SESSIONS, {"max": str(MAX_GATEWAY_SESSIONS)})
raw = secrets.token_urlsafe(32)
db.add(GuiTicket(ticket_hash=hash_value(raw), session_hash=session_hash, user_id=user.id, host_id=host.id,
slot=slot, created_at=now, expires_at=now + TICKET_TTL))
@@ -255,6 +305,13 @@ async def redeem(db: AsyncSession, *, ticket_hash: str, session_hash: str, slot:
# The same browser session reopening this slot replaces the earlier gateway session there.
superseded = (await db.scalars(select(GuiSession).where(
GuiSession.session_hash == session_hash, GuiSession.slot == slot, GuiSession.ended_at.is_(None)))).all()
if any(old.host_id != host.id for old in superseded):
# Two quick tickets both saw the slot free: the second must not take over another firewall's origin
# without the cooling-down time (S1).
await db.commit()
await _audit(actor, action="redeem", status="denied", host=host, host_id=ticket.host_id,
message="slot", details={"slot": slot, "client_ip": client_ip, "reason": "slot"})
return {"ok": False, "reason": "slot"}
for old in superseded:
old.ended_at, old.end_reason = now, "superseded"
gs = GuiSession(session_hash=session_hash, user_id=user.id, host_id=host.id, slot=slot,
@@ -268,6 +325,7 @@ async def redeem(db: AsyncSession, *, ticket_hash: str, session_hash: str, slot:
"upstream_origin": firewall_origin(host.base_url),
"ca_pem": host.tls_ca_pem or None, # public certificate (pin); None = system trust store
"proxy_url": overlay_service.proxy_for(host.base_url),
"target_fp": target_fingerprint(host),
**_timing(row, now),
}
@@ -285,7 +343,8 @@ async def _finish(db: AsyncSession, gs: GuiSession, reason: str) -> None:
"duration_seconds": int((gs.ended_at - gs.started_at).total_seconds())})
async def revalidate(db: AsyncSession, *, gui_session_id: uuid.UUID, session_hash: str) -> dict:
async def revalidate(db: AsyncSession, *, gui_session_id: uuid.UUID, session_hash: str,
target_fp: str | None = None) -> dict:
gs = await db.get(GuiSession, gui_session_id)
if gs is None or not secrets.compare_digest(gs.session_hash, session_hash):
return {"ok": False, "reason": "unknown"}
@@ -295,7 +354,12 @@ async def revalidate(db: AsyncSession, *, gui_session_id: uuid.UUID, session_has
if _now() >= gs.started_at + timedelta(minutes=row.absolute_timeout_minutes):
await _finish(db, gs, "absolute")
return {"ok": False, "reason": "absolute"}
reason, _, _ = await _live_check(db, row, gs.session_hash, gs.user_id, gs.host_id, gs.slot)
reason, _, host = await _live_check(db, row, gs.session_hash, gs.user_id, gs.host_id, gs.slot)
# S4: the gateway presents the fingerprint it got at redeem time. Firewall address, pinned certificate or
# overlay route changed since -> the session (and the upstream connection it holds) ends now.
if reason is None and target_fp is not None and host is not None \
and not secrets.compare_digest(target_fp, target_fingerprint(host)):
reason = "target"
if reason is not None:
await _finish(db, gs, reason)
return {"ok": False, "reason": reason}
+8
View File
@@ -21,6 +21,7 @@ from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost
from app.models.notification import DEFAULT_SERVICE_WATCHLIST, NotificationProblem
from app.models.resource_sample import HostResourceSample
from app.models.rollout import RolloutRun
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.routers.dashboard import collect_pending_actions
from app.services import compliance_service, gateway_health_service, notification_settings_service
@@ -105,6 +106,7 @@ async def render(db: AsyncSession) -> str:
out.family("opnexus_host_gateway_delay_seconds", "Round-trip time to the gateway monitor address (last sample).")
out.family("opnexus_host_gateway_stddev_seconds", "Round-trip time deviation to the gateway monitor address (last sample).")
out.family("opnexus_host_service_running", "1 if a watched service (Settings > Notifications) runs, 0 if it is stopped (last sample).")
out.family("opnexus_host_api_rights_missing", "1 if the firewall refused a side reading (read=gateways, services, swap, cpu) because the API key lacks the right (last sample); absent otherwise.")
configured = (await notification_settings_service.get_settings(db)).service_watchlist
watchlist = set(DEFAULT_SERVICE_WATCHLIST if configured is None else configured)
out.family("opnexus_host_carp_master", "1 if the host's last observed CARP role is MASTER, 0 if BACKUP; absent if unknown.")
@@ -136,6 +138,8 @@ async def render(db: AsyncSession) -> str:
):
if value is not None:
out.sample(name, round(value * factor, 6), **gw_labels)
for read in sorted(str(r) for r in raw.get("denied") or []):
out.sample("opnexus_host_api_rights_missing", 1, **labels, read=read)
for key, running in sorted((raw.get("services") or {}).items()):
if key.split("/")[0] in watchlist:
out.sample("opnexus_host_service_running", running, **labels, service=key.split("/")[0], instance=key.split("/", 1)[1] if "/" in key else None)
@@ -176,4 +180,8 @@ async def render(db: AsyncSession) -> str:
pending = await db.scalar(select(func.count()).select_from(ScheduledMaintenance).where(ScheduledMaintenance.status == "pending"))
out.sample("opnexus_scheduled_maintenance_pending", pending or 0)
out.family("opnexus_rollout_runs", "Fleet rollout runs by status (waiting_window, wave_running, paused, succeeded, ...).")
for status, count in sorted((await db.execute(select(RolloutRun.status, func.count()).group_by(RolloutRun.status))).all()):
out.sample("opnexus_rollout_runs", count, status=status)
return out.text()
+58 -1
View File
@@ -97,6 +97,7 @@ async def check_gateway_down(db: AsyncSession) -> dict[str, dict]:
cfg = await notification_settings_service.get_settings(db)
confirm = max(1, cfg.alert_confirm_samples)
loss_limit, delay_limit = cfg.gateway_loss_threshold_pct, cfg.gateway_delay_threshold_ms
muted = set(cfg.gateway_alert_muted or [])
open_keys = await _open_keys(db, "gateway_down")
problems = Problems()
for host in await _enabled_hosts(db):
@@ -110,6 +111,8 @@ async def check_gateway_down(db: AsyncSession) -> dict[str, dict]:
]
names = {name for p in parsed if p for name in p} | _keys_of_host(open_keys, host)
for name in sorted(names):
if f"{host.id}:{name}" in muted:
continue # alert switched off for this gateway: not judged, an open problem of it resolves
flags: list[bool | None] = []
for p in parsed:
if p is None:
@@ -131,6 +134,58 @@ async def check_gateway_down(db: AsyncSession) -> dict[str, dict]:
return problems
# ── API rights ──────────────────────────────────────────────────────────────
# What the sampler reads on the side (resource_monitor_service) -> (ACL entry of the OPNsense user/group that
# grants it, endpoint; the sample's "ok" list proves a reading went through). ACL names: opnsense/core
# src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml (page-system-gateways: api/routes/gateway/status;
# page-status-services: api/core/service/*; page-system-login-logout "Lobby: Dashboard": api/diagnostics/cpu_usage/*
# and api/diagnostics/system/system_swap).
API_READS: dict[str, tuple[str, str]] = {
"gateways": ("System: Gateways", "/api/routes/gateway/status"),
"services": ("Status: Services", "/api/core/service/search"),
"swap": ("Lobby: Dashboard", "/api/diagnostics/system/systemSwap"),
"cpu": ("Lobby: Dashboard", "/api/diagnostics/cpu_usage/getCPUType"),
}
async def check_api_rights_missing(db: AsyncSession) -> dict[str, dict]:
"""The API key of a firewall may be refused (403) on the side readings the alerts rely on (gateway state,
service state): those alerts can then never fire, and nobody would notice. Only a refusal counts; a timeout
or an error stays "unknown"."""
cfg = await notification_settings_service.get_settings(db)
confirm = max(1, cfg.alert_confirm_samples)
open_keys = await _open_keys(db, "api_rights_missing")
problems = Problems()
for host in await _enabled_hosts(db):
samples = await _recent(db, host.id, confirm, "denied", "ok")
if not _fresh(samples):
problems.preserve_prefixes.add(f"{host.id}:")
continue
was_open = _keys_of_host(open_keys, host)
names = {n for s in samples for n in (s["denied"] or []) if n in API_READS} | (was_open & set(API_READS))
for name in sorted(names):
acl, path = API_READS[name]
flags: list[bool | None] = []
for s in samples:
if s["denied"] is None:
flags.append(None) # a sample from before this check existed
elif name in s["denied"]:
flags.append(True)
else:
# Only a reading that went through proves the right is back; a timeout or a sample from
# before "ok" existed stays unknown (the problem neither opens nor closes on it).
flags.append(False if s["ok"] is not None and name in s["ok"] else None)
if verdict(flags, confirm, name in was_open):
problems[f"{host.id}:{name}"] = {
"host_id": str(host.id), "host_name": host.name, "lesezugriff": name, "endpunkt": path,
"fehlendes_recht": acl,
"hinweis": f"Dem API-Benutzer fehlt das Recht, {path} zu lesen (OPNsense-Berechtigung \"{acl}\"). "
"Alarme, die darauf beruhen, können nicht auslösen.",
}
return problems
# ── Resources ───────────────────────────────────────────────────────────────
def disk_groups(devices) -> dict[str, tuple[str, float]]:
@@ -183,7 +238,9 @@ async def check_resource_threshold(db: AsyncSession) -> dict[str, dict]:
def simple(key: str, label: str, flags: list, value, threshold, unit: str) -> None:
if verdict(flags, confirm, key in was_open):
problems[f"{host.id}:{key}"] = {**base, "ressource": label, "wert": f"{value}{unit}", "schwelle": f"{threshold}{unit}"}
# An open alert stays open while the reading is unavailable: then there is no number to show (never "None %").
problems[f"{host.id}:{key}"] = {**base, "ressource": label, "wert": None if value is None else f"{value}{unit}",
"schwelle": f"{threshold}{unit}"}
latest = samples[0]
simple("memory", "Arbeitsspeicher", [over(s["mem_used_pct"], cfg.memory_threshold_pct) for s in samples],
@@ -224,14 +224,14 @@ async def send(channel: NotificationChannel, problem: NotificationProblem, trans
except ValueError as exc:
raise NotificationDeliveryError(f"{label}: {exc}") from None
try:
async with httpx.AsyncClient(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
async with url_guard.guarded_client(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
resp = await client.request(method, url, json=payload, headers=headers)
resp.raise_for_status()
if ok_key and not (resp.json() or {}).get(ok_key):
raise NotificationDeliveryError(f"{label}: Zustellung abgelehnt")
except httpx.HTTPError as exc:
# str(exc) names the full URL, which for Telegram/Teams is the credential itself.
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
reason = url_guard.failure_reason(exc)
raise NotificationDeliveryError(f"{label}: Zustellung fehlgeschlagen ({reason})") from None
except ValueError:
raise NotificationDeliveryError(f"{label}: Antwort nicht lesbar") from None
@@ -40,6 +40,7 @@ _LABELS = {
"gateway_down": "Gateway gestört",
"resource_threshold": "Ressourcen-Schwelle überschritten",
"service_down": "Dienst ausgefallen",
"api_rights_missing": "API-Rechte fehlen",
"rollout_paused": "Rollout angehalten",
"rollout_start_failed": "Rollout nicht automatisch gestartet",
"test": "Testbenachrichtigung",
@@ -96,14 +97,14 @@ async def send_webhook(settings_row: NotificationSettings, problem: Notification
raise NotificationDeliveryError(f"Webhook-Adresse nicht erlaubt: {exc}") from None
try:
# follow_redirects stays off: a redirect must not move the request to an address the check above never saw.
async with httpx.AsyncClient(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
async with url_guard.guarded_client(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
with url_guard.muted():
resp = await client.post(settings_row.webhook_url, content=raw, headers=headers)
resp.raise_for_status()
except httpx.HTTPError as exc:
# str(exc) names the full URL, which often is the credential itself
# (token in path/query) — keep it out of messages and logs.
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
reason = url_guard.failure_reason(exc)
raise NotificationDeliveryError(f"Webhook-Zustellung fehlgeschlagen ({reason})") from None
+85 -22
View File
@@ -3,9 +3,11 @@ against previously open problems, and delivers on transitions. The single
testable entry point — called directly by main.py's background loop AND
directly by tests, same pattern as rule_deploy_service.sweep_expired_deployments.
"""
import asyncio
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import NotificationChannel, NotificationProblem, NotificationSettings
@@ -37,6 +39,7 @@ _CHECKS = [
("gateway_down", "gateway_down_enabled", monitor_checks.check_gateway_down),
("resource_threshold", "resource_threshold_enabled", monitor_checks.check_resource_threshold),
("service_down", "service_down_enabled", monitor_checks.check_service_down),
("api_rights_missing", "api_rights_enabled", monitor_checks.check_api_rights_missing),
("rollout_paused", "rollout_problems_enabled", notification_checks.check_rollout_paused),
("rollout_start_failed", "rollout_problems_enabled", notification_checks.check_rollout_start_failed),
]
@@ -46,44 +49,95 @@ _CHECKS = [
_NO_RESOLVED_NOTICE = {"scheduled_maintenance_failed", "rollout_start_failed"}
# Delivery limits (1.29): a hanging channel must neither delay the others nor hold up the check loop.
DELIVERY_TIMEOUT_SECONDS = 20.0 # one target (webhook, e-mail, one channel) for one message
DELIVERY_CYCLE_BUDGET_SECONDS = 90.0 # everything one check cycle sends, in total
DELIVERY_PARALLEL = 10 # targets served at the same time within a cycle
async def _attempt(label: str, make_call, gate: asyncio.Semaphore | None = None, started: list | None = None) -> None:
"""One delivery target: its own timeout, its own failure — never an exception for the caller. Texts logged
here come from NotificationDeliveryError (names the channel and the HTTP status, never the URL or token)."""
try:
if gate is not None:
await gate.acquire()
if started is not None:
started.append(label) # the target was really tried (a cancelled wait for the gate does not count)
try:
await asyncio.wait_for(make_call(), DELIVERY_TIMEOUT_SECONDS)
finally:
if gate is not None:
gate.release()
except notification_delivery.NotificationDeliveryError:
logger.exception("%s delivery failed", label)
except asyncio.TimeoutError:
logger.warning("%s delivery timed out after %.0f s", label, DELIVERY_TIMEOUT_SECONDS)
except asyncio.CancelledError:
raise
except Exception as exc: # a broken target must not take the others down
logger.warning("%s delivery failed unexpectedly (%s)", label, type(exc).__name__)
async def _deliver(
settings_row: NotificationSettings, problem: NotificationProblem, transition: str, channels: list[NotificationChannel] = (),
gate: asyncio.Semaphore | None = None,
) -> None:
"""Best-effort, not an audit trail: attempted once per transition and
never retried, even on failure — avoids retry-storming a broken webhook
URL or SMTP relay every poll cycle."""
URL or SMTP relay every poll cycle. Webhook, e-mail and every channel are
served in parallel, each with its own timeout (the order has no meaning)."""
if transition == "resolved" and problem.problem_type in _NO_RESOLVED_NOTICE:
problem.resolved_notified_at = _now()
return
where = f"{problem.problem_type}/{problem.dedup_key}"
calls = []
started: list = []
if settings_row.webhook_enabled:
try:
await notification_delivery.send_webhook(settings_row, problem, transition)
except notification_delivery.NotificationDeliveryError:
logger.exception("Webhook delivery failed for %s/%s", problem.problem_type, problem.dedup_key)
calls.append(_attempt(f"Webhook for {where}", lambda: notification_delivery.send_webhook(settings_row, problem, transition), gate, started))
if settings_row.email_enabled:
try:
async def email():
subject, body = notification_delivery.render_message(problem, transition)
await notification_delivery.send_email(settings_row, subject, body)
except notification_delivery.NotificationDeliveryError:
logger.exception("Email delivery failed for %s/%s", problem.problem_type, problem.dedup_key)
calls.append(_attempt(f"Email for {where}", email, gate, started))
for channel in channels:
try:
await notification_channels.send(channel, problem, transition)
except notification_delivery.NotificationDeliveryError:
# The error text names the channel and the HTTP status, never the URL or token.
logger.exception("Channel %s (%s) delivery failed for %s/%s", channel.name, channel.channel_type, problem.problem_type, problem.dedup_key)
calls.append(_attempt(f"Channel {channel.name} ({channel.channel_type}) for {where}",
lambda channel=channel: notification_channels.send(channel, problem, transition), gate, started))
try:
await asyncio.gather(*calls, return_exceptions=True)
finally:
# "Attempted" is what is recorded (also when a target failed or timed out). A message the cycle budget
# cancelled before ANY target was tried stays unrecorded: an opened problem is sent again next cycle.
if started or not calls:
if transition == "resolved":
problem.resolved_notified_at = _now()
else:
problem.last_notified_at = _now()
if transition == "resolved":
problem.resolved_notified_at = _now()
else:
problem.last_notified_at = _now()
async def _deliver_all(settings_row: NotificationSettings, pending: list[tuple[NotificationProblem, str]],
channels: list[NotificationChannel]) -> None:
"""Everything this cycle found, in parallel, within DELIVERY_CYCLE_BUDGET_SECONDS in total."""
if not pending:
return
gate = asyncio.Semaphore(DELIVERY_PARALLEL)
tasks = [asyncio.ensure_future(_deliver(settings_row, problem, transition, channels, gate)) for problem, transition in pending]
done, late = await asyncio.wait(tasks, timeout=DELIVERY_CYCLE_BUDGET_SECONDS)
for task in late:
task.cancel()
if late:
logger.warning("Notification delivery stopped after %.0f s: %d of %d messages were not completed",
DELIVERY_CYCLE_BUDGET_SECONDS, len(late), len(tasks))
await asyncio.gather(*late, return_exceptions=True) # let the cancelled ones record "attempted"
for task in done:
if not task.cancelled() and task.exception() is not None:
logger.warning("Notification delivery failed unexpectedly (%s)", type(task.exception()).__name__)
async def run_notification_checks(db: AsyncSession) -> dict:
settings_row = await notification_settings_service.get_settings(db)
result = {"opened": 0, "resolved": 0}
channels = await notification_channels.enabled_channels(db)
pending: list[tuple[NotificationProblem, str]] = []
for problem_type, enabled_flag, check_fn in _CHECKS:
if not getattr(settings_row, enabled_flag):
@@ -99,12 +153,21 @@ async def run_notification_checks(db: AsyncSession) -> dict:
continue
opened, resolved = await notification_dedup.diff_and_update(db, problem_type, current)
for problem in opened:
await _deliver(settings_row, problem, "opened", channels)
for problem in resolved:
await _deliver(settings_row, problem, "resolved", channels)
pending += [(problem, "opened") for problem in opened] + [(problem, "resolved") for problem in resolved]
result["opened"] += len(opened)
result["resolved"] += len(resolved)
# Open problems that no target was ever tried for (an earlier cycle ran out of time) are sent now.
enabled_types = [t for t, flag, _fn in _CHECKS if getattr(settings_row, flag)]
already = {id(problem) for problem, _transition in pending}
undelivered = (await db.scalars(
select(NotificationProblem).where(
NotificationProblem.status == "open", NotificationProblem.last_notified_at.is_(None),
NotificationProblem.problem_type.in_(enabled_types),
)
)).all() if enabled_types else []
pending += [(problem, "opened") for problem in undelivered if id(problem) not in already]
# Delivered once all checks ran (and in parallel): a slow target no longer holds up the checks after it.
await _deliver_all(settings_row, pending, channels)
await db.commit()
return result
@@ -26,7 +26,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled", "rollout_problems_enabled",
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled", "rollout_problems_enabled", "api_rights_enabled",
"gateway_loss_threshold_pct", "gateway_delay_threshold_ms", "disk_threshold_pct", "memory_threshold_pct",
"swap_threshold_pct", "load_per_cpu_threshold", "alert_confirm_samples", "resource_confirm_samples",
"service_watchlist",
+93 -11
View File
@@ -10,6 +10,10 @@ the deployment's last_payload. So the preview directly afterwards says "unchange
updates the same object via setItem/setRule (no duplicate), and the normal preview → push →
confirm/auto-revert flow applies from the first change on.
An object that already exists centrally for another firewall is LINKED instead of adopted a second time
(adopt_link_service.py: exact match after resolving the host's variables, never automatic for a mere
resemblance).
Only objects OPNexus can describe without distortion are adopted. Anything the model cannot express
is refused with machine-readable reasons (the UI translates them) instead of being imported in a way
that would silently change its meaning on the next edit. Fields OPNexus does not manage (log flag of a
@@ -72,6 +76,7 @@ from app.services import alias_deploy_service, alias_policy_service
from app.services import one_to_one_deploy_service, one_to_one_policy_service
from app.services import opnsense_client, outbound_nat_deploy_service, outbound_nat_policy_service
from app.services import port_forward_deploy_service, port_forward_policy_service
from app.services import adopt_link_service
from app.services.adopt_common import NotAdoptable, NotFoundOnHost, clean, on
from app.services.nat_live_service import split_rows
from app.services.outbound_nat_mode import host_mode
@@ -85,6 +90,7 @@ ALIAS_CONTENT_MAX = 8000 # schemas/alias.py AliasCreate.content
# OPNsense's own aliases that are not user objects (Firewall/Alias.php: bogons & co. are `external`
# rows created by the system, __<if>_network are `internal`).
SYSTEM_ALIAS_NAMES = frozenset({"bogons", "bogonsv6", "sshlockout", "virusprot"})
LINK_TOLERATED = frozenset({"name_exists", "references_unmanaged_alias"})
@dataclass
@@ -93,6 +99,19 @@ class Entry:
reasons: list[str] = field(default_factory=list)
details: dict[str, str] = field(default_factory=dict)
bulk: bool = False # not adoptable alone yet, but adoptable in the same run (its references come first)
matches: list = field(default_factory=list) # central objects equal to the live object (link candidates)
similar: list = field(default_factory=list) # same name/description but different content (hint only)
@property
def linkable(self) -> bool:
"""An exact central twin exists and nothing but "the name is taken" / "its references are not
managed here yet" speaks against it (both are moot when the twin is linked, its content is equal)."""
return any(not m.busy for m in self.matches) and set(self.reasons) <= LINK_TOLERATED
@property
def busy_twin(self) -> bool:
"""The one exact twin waits for a confirmation: \"adopt all\" leaves this entry alone (neither link nor duplicate)."""
return len(self.matches) == 1 and self.matches[0].busy
@property
def uuid(self) -> str:
@@ -117,6 +136,14 @@ class Kind:
context: Callable[[AsyncSession, list[dict]], Awaitable[dict]] | None = None
refs: Callable[[dict], set[str]] | None = None
name: Callable[[dict], str] | None = None
identity: str = "description" # attribute that names the object (the alias name for aliases)
@property
def link_spec(self) -> adopt_link_service.LinkSpec:
return adopt_link_service.LinkSpec(
model=self.model, deployment=self.deployment, fk=self.fk, uuid_attr=self.uuid_attr,
identity=self.identity, payload=self.payload, revision=self.revision,
)
# ── shared helpers ───────────────────────────────────────────────────────
@@ -388,7 +415,7 @@ KINDS: dict[str, Kind] = {
uuid_attr="opnsense_alias_uuid", fetch=_fetch_aliases, check=_alias_check,
title=lambda row: clean(row.get("name")), info=_alias_info, build=_alias_build,
payload=alias_deploy_service._build_payload, revision=alias_policy_service.revision,
context=_alias_context, refs=_alias_refs, name=lambda row: clean(row.get("name")),
context=_alias_context, refs=_alias_refs, name=lambda row: clean(row.get("name")), identity="name",
),
"port_forward": Kind(
key="port_forward", model=PortForwardRule, deployment=PortForwardDeployment, fk="port_forward_id",
@@ -462,12 +489,16 @@ async def _plan(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> tuple[list[
rows = await kind.fetch(host)
managed = await _managed_uuids(db, kind, host)
ctx = await kind.context(db, rows) if kind.context else {}
central = await adopt_link_service.load_central(db, kind.link_spec)
entries = []
for row in rows:
if clean(row.get("uuid")) in managed:
continue
reasons, details = kind.check(row, ctx)
entries.append(Entry(row, list(reasons), dict(details)))
entry = Entry(row, list(reasons), dict(details))
if reasons != ["system"]:
entry.matches, entry.similar = adopt_link_service.find(central, kind.link_spec, host, kind.build(row))
entries.append(entry)
if kind.refs:
order = _resolve_references(kind, entries, rows, managed, ctx)
else:
@@ -475,8 +506,31 @@ async def _plan(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> tuple[list[
return entries, order
def _describe(kind: Kind, entry: Entry) -> dict:
def _bulk_actions(entries: list[Entry], order: list[Entry], link_existing: bool) -> dict[str, tuple[str, Any]]:
"""What "adopt all" does with each entry: ("link", central object) when exactly one central twin exists
(and no other entry of this run took it), ("new", None), or nothing. One function for the dialog's
summary and for the real run, so they cannot disagree. Several twins are never guessed between."""
actions: dict[str, tuple[str, Any]] = {}
taken: set = set()
if link_existing:
for entry in entries:
if entry.linkable and len(entry.matches) == 1 and entry.matches[0].obj.id not in taken:
actions[entry.uuid] = ("link", entry.matches[0].obj)
taken.add(entry.matches[0].obj.id)
for entry in order:
if link_existing and entry.busy_twin:
continue
actions.setdefault(entry.uuid, ("new", None))
return actions
def _describe(kind: Kind, entry: Entry, action: str | None = None) -> dict:
spec = kind.link_spec
return {
"matches": [adopt_link_service.describe(spec, m) for m in entry.matches],
"similar": [adopt_link_service.describe(spec, m) for m in entry.similar],
"linkable": entry.linkable,
"bulk_action": action,
"uuid": entry.uuid,
"title": kind.title(entry.row),
"enabled": on(entry.row.get("enabled")) if "disabled" not in entry.row else not on(entry.row.get("disabled")),
@@ -491,8 +545,9 @@ def _describe(kind: Kind, entry: Entry) -> dict:
async def list_unmanaged(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> list[dict]:
kind = KINDS[kind_key]
entries, _ = await _plan(db, kind, host)
return [_describe(kind, e) for e in entries]
entries, order = await _plan(db, kind, host)
actions = _bulk_actions(entries, order, True)
return [_describe(kind, e, actions.get(e.uuid, ("skip", None))[0]) for e in entries]
def _create(db: AsyncSession, kind: Kind, host: OpnsenseHost, entry: Entry):
@@ -511,8 +566,17 @@ async def _record(db: AsyncSession, kind: Kind, host: OpnsenseHost, obj, remote_
await db.flush()
async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid: str):
async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid: str, link_to=None):
"""Takes over one object as a NEW central object, or - with `link_to` - links it to that existing
central object (adopt_link_service). Either way the live state is read fresh here."""
kind = KINDS[kind_key]
if link_to is not None:
existing = await db.scalar(select(kind.deployment).where(
kind.deployment.host_id == host.id, getattr(kind.deployment, kind.uuid_attr) == remote_uuid))
if existing is not None:
if getattr(existing, kind.fk) == link_to:
return await db.get(kind.model, link_to) # second click: already linked, nothing to do
raise NotAdoptable(["already_managed"])
entries, _ = await _plan(db, kind, host)
entry = next((e for e in entries if e.uuid == remote_uuid), None)
if entry is None:
@@ -520,6 +584,11 @@ async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid
if remote_uuid in await _managed_uuids(db, kind, host):
raise NotAdoptable(["already_managed"])
raise NotFoundOnHost(remote_uuid)
if link_to is not None:
if not set(entry.reasons) <= LINK_TOLERATED:
raise NotAdoptable(entry.reasons, entry.details)
obj = await adopt_link_service.link(db, kind.link_spec, host, link_to, remote_uuid, kind.build(entry.row))
return obj
if entry.reasons:
raise NotAdoptable(entry.reasons, entry.details)
obj = _create(db, kind, host, entry)
@@ -527,19 +596,32 @@ async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid
return obj
async def adopt_all(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> dict:
async def adopt_all(db: AsyncSession, kind_key: str, host: OpnsenseHost, link_existing: bool = False) -> dict:
"""Adopts everything adoptable in one transaction (the caller commits), nested aliases in
dependency order. System objects are not reported; everything else that stays behind is, with reasons."""
dependency order. With `link_existing`, entries with exactly one exact central twin are linked to it
instead (see _bulk_actions). System objects are not reported; everything else that stays behind is,
with reasons."""
kind = KINDS[kind_key]
entries, order = await _plan(db, kind, host)
actions = _bulk_actions(entries, order, link_existing)
adopted = []
done: set[str] = set()
for entry in entries: # linked first: their names are central already, so nested aliases can refer to them
action, target = actions.get(entry.uuid, ("skip", None))
if action == "link":
obj = await adopt_link_service.link(db, kind.link_spec, host, target.id, entry.uuid, kind.build(entry.row))
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id, "linked": True})
done.add(entry.uuid)
for entry in order:
if actions.get(entry.uuid, ("skip", None))[0] != "new":
continue
obj = _create(db, kind, host, entry)
await _record(db, kind, host, obj, entry.uuid)
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id})
done = {e.uuid for e in order}
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id, "linked": False})
done.add(entry.uuid)
skipped = [
{"uuid": e.uuid, "title": kind.title(e.row), "reasons": e.reasons, "reason_details": e.details}
{"uuid": e.uuid, "title": kind.title(e.row),
"reasons": e.reasons + (["twin_busy"] if link_existing and e.busy_twin else []), "reason_details": e.details}
for e in entries if e.uuid not in done and e.reasons != ["system"]
]
return {"adopted": adopted, "skipped": skipped}
+3 -3
View File
@@ -25,7 +25,7 @@ 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
from app.services import notification_secret_store, url_guard
logger = logging.getLogger(__name__)
@@ -77,7 +77,7 @@ 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:
async with url_guard.guarded_client(timeout=10, follow_redirects=False) as client:
response = await client.get(url, **kw)
response.raise_for_status()
data = response.json()
@@ -168,7 +168,7 @@ async def _exchange(cfg: OidcSettings, doc: dict, code: str, state: OidcLoginSta
else:
data["client_secret"] = secret
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
async with url_guard.guarded_client(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}")
+8 -1
View File
@@ -33,6 +33,12 @@ class OpnsenseAmbiguousError(OpnsenseAPIError):
outcome of a write is unknown and must be reconciled, not retried blindly."""
class OpnsenseForbiddenError(OpnsenseAPIError):
"""The firewall answered 403: the API key lacks the privilege for this endpoint. A refusal, not an unknown
outcome; callers that only read can tell "no rights" from "unreachable". 401 (wrong key or secret) stays a
plain OpnsenseAPIError."""
def _client(host: OpnsenseHost) -> httpx.AsyncClient:
# Before the secret is even read: an http:// host must never receive credentials.
reason = firewall_url.check_base_url(host.base_url)
@@ -71,7 +77,8 @@ async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> h
except httpx.HTTPStatusError as exc:
# The body may reflect submitted secrets: keep only a scrubbed, bounded excerpt.
secret = opnsense_secret_store.read_secret(host.id)
error = OpnsenseAmbiguousError if exc.response.status_code >= 500 else OpnsenseAPIError
status = exc.response.status_code
error = OpnsenseAmbiguousError if status >= 500 else OpnsenseForbiddenError if status == 403 else OpnsenseAPIError
raise error(
f"{exc.response.status_code} {path}: {redact_text(exc.response.text, host.api_key, secret)}"
) from exc
@@ -17,7 +17,7 @@ from app.models.host import OpnsenseHost
from app.models.resource_sample import HostResourceSample
from app.services import gateway_health_service
from app.services import opnsense_client as client
from app.services.opnsense_client import OpnsenseAPIError
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseForbiddenError
logger = logging.getLogger(__name__)
@@ -91,10 +91,19 @@ def _services_compact(listing) -> dict[str, int]:
return result
async def _optional(operation):
"""The extra readings must never cost the base sample (older firmware, an endpoint that is missing)."""
async def _optional(operation, name: str, denied: list[str], ok: list[str]):
"""The extra readings must never cost the base sample (older firmware, an endpoint that is missing).
A refusal by the firewall (403: the API key lacks the privilege) is recorded in ``denied`` so the
missing right becomes visible; a reading that went through is recorded in ``ok`` (the proof that a right is
back, whatever the answer contains); any other failure stays "unknown" and is no finding about rights."""
try:
return await operation
result = await operation
ok.append(name)
return result
except OpnsenseForbiddenError:
denied.append(name)
logger.debug("Optionale Messung %s: API-Recht fehlt", name)
return None
except Exception as exc:
logger.debug("Optionale Messung fehlgeschlagen: %s", type(exc).__name__)
return None
@@ -105,11 +114,13 @@ async def sample_host(db: AsyncSession, host: OpnsenseHost) -> None:
system_time = await client.get_system_time(host)
disk = await client.get_system_disk(host)
interfaces = await client.get_interface_statistics(host)
denied: list[str] = []
ok: list[str] = []
swap, cpu_type, gateways, services = await asyncio.gather(
_optional(client.get_system_swap(host)),
_optional(client.get_cpu_type(host)),
_optional(gateway_health_service.get_gateway_health(host)),
_optional(client.search_services(host)),
_optional(client.get_system_swap(host), "swap", denied, ok),
_optional(client.get_cpu_type(host), "cpu", denied, ok),
_optional(gateway_health_service.get_gateway_health(host), "gateways", denied, ok),
_optional(client.search_services(host), "services", denied, ok),
)
db.add(
HostResourceSample(
@@ -125,6 +136,9 @@ async def sample_host(db: AsyncSession, host: OpnsenseHost) -> None:
"cpu_threads": _cpu_threads(cpu_type) if cpu_type is not None else None,
"gateways": gateway_health_service.compact(gateways) if gateways is not None else None,
"services": _services_compact(services) if services is not None else None,
# Readings the firewall refused (403) because the API key lacks the right; always a list.
"denied": sorted(denied),
"ok": sorted(ok),
},
)
)
+14 -8
View File
@@ -139,9 +139,14 @@ def pause(run: RolloutRun, reason: str, **detail) -> None:
_progress(run, paused_at=_iso(_now()))
async def may_roll_out(db: AsyncSession, username: str) -> bool:
"""Does this account (still) have the right to roll out? Same rule as AuthMiddleware's is_rollout."""
user = await db.scalar(select(AdminUser).where(AdminUser.username == username))
async def may_roll_out(db: AsyncSession, username: str, user_id=None) -> bool:
"""Does this account (still) have the right to roll out? Same rule as AuthMiddleware's is_rollout.
With the stable account id (1.29) the id decides: a deleted account that was re-created under the same name is
someone else. Rows from before the id existed (and still without one) are matched by name as before."""
if user_id is not None:
user = await db.get(AdminUser, user_id)
else:
user = await db.scalar(select(AdminUser).where(AdminUser.username == username))
if user is None:
return False
if user.role == "admin":
@@ -184,7 +189,7 @@ async def active_run(db: AsyncSession) -> RolloutRun | None:
async def create_run(db: AsyncSession, plan: RolloutPlan, resolved: dict, *, started_by: str, trigger: str,
occurrence: datetime | None = None) -> RolloutRun:
occurrence: datetime | None = None, started_by_id=None) -> RolloutRun:
"""Freeze the plan and its resolved hosts. The caller holds policy_lock and has checked the token."""
now = _now()
snapshot = {
@@ -194,7 +199,7 @@ async def create_run(db: AsyncSession, plan: RolloutPlan, resolved: dict, *, sta
"waves": [{k: w[k] for k in ("label", "soak_minutes", "carp_handover", "require_approval", "groups")} for w in resolved["waves"]],
}
run = RolloutRun(plan_id=plan.id, plan_name=plan.name, plan_snapshot=snapshot, action=plan.action, status="preflight",
current_wave=0, started_by=started_by, trigger=trigger, window_occurrence=occurrence,
current_wave=0, started_by=started_by, started_by_id=started_by_id, trigger=trigger, window_occurrence=occurrence,
progress={}, created_at=now, step_at=now, revision=0)
run.waves = [RolloutRunWave(index=w["index"], label=w["label"], status="preflight" if w["index"] == 0 else "pending")
for w in resolved["waves"]]
@@ -329,7 +334,7 @@ async def _sync_hosts(db: AsyncSession, run: RolloutRun) -> bool:
# ── handlers: one per run status; each returns an apply-function or None ──────
async def _actor_problem(db: AsyncSession, run: RolloutRun) -> Decision | None:
if await may_roll_out(db, run.started_by):
if await may_roll_out(db, run.started_by, run.started_by_id):
return None
async def apply(db, run):
@@ -769,7 +774,7 @@ async def auto_start(db: AsyncSession) -> int:
problem = _note("auto_start_update_only")
elif plan.activated_fingerprint != rollout_service.fingerprint(plan, resolved):
problem = _note("plan_changed") # someone changed the plan, its groups or its window since activation
elif not await may_roll_out(db, plan.activated_by or ""):
elif not await may_roll_out(db, plan.activated_by or "", plan.activated_by_id):
problem = _note("actor_invalid", user=plan.activated_by)
elif resolved["errors"]:
problem = _note("plan_invalid", reason=resolved["errors"][0]["code"])
@@ -786,7 +791,8 @@ async def auto_start(db: AsyncSession) -> int:
audit_service.context.reset(token)
continue
try:
run = await create_run(db, plan, resolved, started_by=plan.activated_by, trigger="window", occurrence=occ.start)
run = await create_run(db, plan, resolved, started_by=plan.activated_by, started_by_id=plan.activated_by_id,
trigger="window", occurrence=occ.start)
plan.auto_start_error = None
await db.commit()
except IntegrityError:
+42 -18
View File
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment
from app.services import opnsense_client
from app.services import adopt_link_service, opnsense_client
from app.services.adopt_common import NotAdoptable, NotFoundOnHost, clean, on # noqa: F401 (NotAdoptable is re-exported for routers/rules.py)
from app.services.policy_service import revision
from app.services.rule_deploy_service import _build_payload
@@ -64,6 +64,30 @@ def reasons_not_adoptable(row: dict, managed_uuids: set[str]) -> list[str]:
return reasons
def _values(row: dict) -> dict:
"""The fields OPNexus manages, normalised exactly as adopting stores them (also what a link compares)."""
return {
"description": _clean(row.get("description")),
"interface": _clean(row.get("interface")),
"direction": _clean(row.get("direction")),
"ipprotocol": _clean(row.get("ipprotocol")),
"protocol": _clean(row.get("protocol")) or "any",
"action": _clean(row.get("action")),
"source_net": _clean(row.get("source_net")) or "any",
"source_port": _clean(row.get("source_port")) or None,
"destination_net": _clean(row.get("destination_net")) or "any",
"destination_port": _clean(row.get("destination_port")) or None,
"enabled": _on(row.get("enabled")),
"log": _on(row.get("log")),
}
LINK_SPEC = adopt_link_service.LinkSpec(
model=FirewallRule, deployment=RuleDeployment, fk="rule_id", uuid_attr="opnsense_rule_uuid",
identity="description", payload=_build_payload, revision=revision,
)
async def _managed_uuids(db: AsyncSession, host: OpnsenseHost) -> set[str]:
rows = await db.scalars(
select(RuleDeployment.opnsense_rule_uuid).where(
@@ -81,6 +105,7 @@ async def _live_rows(host: OpnsenseHost) -> list[dict]:
async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
managed = await _managed_uuids(db, host)
central = await adopt_link_service.load_central(db, LINK_SPEC)
result = []
for row in await _live_rows(host):
if row.get("is_automatic"):
@@ -88,7 +113,11 @@ async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
reasons = reasons_not_adoptable(row, managed)
if "already_managed" in reasons:
continue
matches, similar = adopt_link_service.find(central, LINK_SPEC, host, _values(row))
result.append({
"matches": [adopt_link_service.describe(LINK_SPEC, m) for m in matches],
"similar": [adopt_link_service.describe(LINK_SPEC, m) for m in similar],
"linkable": any(not m.busy for m in matches) and not reasons,
"uuid": _clean(row.get("uuid")),
"description": _clean(row.get("description")),
"interface": _clean(row.get("interface")),
@@ -108,7 +137,15 @@ async def list_unmanaged(db: AsyncSession, host: OpnsenseHost) -> list[dict]:
return result
async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str) -> FirewallRule:
async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str, link_to=None) -> FirewallRule:
"""Takes the rule over as a new central rule, or - with `link_to` - links it to that existing rule."""
if link_to is not None:
existing = await db.scalar(select(RuleDeployment).where(
RuleDeployment.host_id == host.id, RuleDeployment.opnsense_rule_uuid == rule_uuid))
if existing is not None:
if existing.rule_id == link_to:
return await db.get(FirewallRule, link_to) # second click: already linked
raise NotAdoptable(["already_managed"])
managed = await _managed_uuids(db, host)
row = next((r for r in await _live_rows(host) if _clean(r.get("uuid")) == rule_uuid), None)
if row is None:
@@ -116,23 +153,10 @@ async def adopt(db: AsyncSession, host: OpnsenseHost, rule_uuid: str) -> Firewal
reasons = reasons_not_adoptable(row, managed)
if reasons:
raise NotAdoptable(reasons)
if link_to is not None:
return await adopt_link_service.link(db, LINK_SPEC, host, link_to, rule_uuid, _values(row))
rule = FirewallRule(
description=_clean(row.get("description")),
interface=_clean(row.get("interface")),
direction=_clean(row.get("direction")),
ipprotocol=_clean(row.get("ipprotocol")),
protocol=_clean(row.get("protocol")) or "any",
action=_clean(row.get("action")),
source_net=_clean(row.get("source_net")) or "any",
source_port=_clean(row.get("source_port")) or None,
destination_net=_clean(row.get("destination_net")) or "any",
destination_port=_clean(row.get("destination_port")) or None,
enabled=_on(row.get("enabled")),
log=_on(row.get("log")),
targets=[host],
groups=[],
)
rule = FirewallRule(**_values(row), targets=[host], groups=[])
db.add(rule)
await db.flush()
revision(db, rule, "Von Firewall übernommen")
+113 -8
View File
@@ -7,6 +7,19 @@ or "the cloud metadata service" (loopback, link-local such as 169.254.169.254, u
multicast). Redirects are never followed by the callers, so a public host cannot bounce a request
to such an address. The hostname is resolved at send time and every address is checked (a name that
does not resolve is let through; the request then fails on its own).
DNS rebinding (1.29): a check followed by a second, independent resolution inside the HTTP client could be
answered differently ("first public, then 127.0.0.1"). ``guarded_client`` closes that window: its transport
resolves the name itself when it opens the connection, refuses the connection if ANY answer is forbidden and
connects to exactly the address it checked. Host header, SNI and certificate verification keep using the name.
The up-front ``ensure_resolved_safe`` stays as the early, friendly error.
How: httpcore (the layer under httpx) accepts a pluggable network backend. httpx does not expose it, so
``GuardedTransport`` replaces the backend of the pool its parent class built (``_pool._network_backend``, httpx
0.28 / httpcore 1.0). If those internals change, constructing the transport raises instead of silently
unguarding; tests/test_url_guard.py opens a real socket against it, so an upgrade that breaks the hook fails the
suite. The smaller alternative (rewriting the request URL to the checked IP with an ``sni_hostname`` extension)
was rejected: it changes the URL every caller, log line and mock sees.
"""
import asyncio
import contextlib
@@ -16,6 +29,9 @@ import logging
import socket
from urllib.parse import urlsplit
import httpcore
import httpx
# httpx logs every request line ("HTTP Request: POST https://api.telegram.org/bot<token>/sendMessage"). For
# targets whose URL is the credential that line must not reach the logs: callers wrap the request in muted().
_muted = contextvars.ContextVar("url_guard_muted", default=False)
@@ -39,10 +55,14 @@ def muted():
_muted.reset(token)
# Cloud metadata services outside the link-local range (AWS IPv6 IMDS, Alibaba Cloud).
_METADATA_ADDRESSES = {ipaddress.ip_address("fd00:ec2::254"), ipaddress.ip_address("100.100.100.200")}
def _forbidden(ip: ipaddress._BaseAddress) -> bool:
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
return ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast
return ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast or ip in _METADATA_ADDRESSES
def validate_url(url: str, *, max_length: int = 500) -> str:
@@ -71,6 +91,33 @@ def validate_url(url: str, *, max_length: int = 500) -> str:
return value
async def resolve_host(host: str, port: int | None = None) -> list[str]:
"""Every address the name currently resolves to (a literal answers itself). Raises OSError."""
host = host.strip("[]").split("%")[0]
try:
ipaddress.ip_address(host)
return [host]
except ValueError:
pass
infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM)
seen: list[str] = []
for info in infos:
address = info[4][0].split("%")[0]
if address not in seen:
seen.append(address)
return seen
def _any_forbidden(addresses: list[str]) -> bool:
for address in addresses:
try:
if _forbidden(ipaddress.ip_address(address)):
return True
except ValueError:
continue
return False
async def ensure_resolved_safe(url: str) -> None:
"""Send-time check against DNS answers pointing at forbidden addresses. Raises ValueError."""
host = urlsplit(url).hostname
@@ -82,13 +129,71 @@ async def ensure_resolved_safe(url: str) -> None:
except ValueError:
pass
try:
infos = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
addresses = await resolve_host(host)
except OSError:
return
for info in infos:
if _any_forbidden(addresses):
raise ValueError("Der Name zeigt auf eine nicht erlaubte Adresse (lokal, Link-Local oder Metadaten-Dienst)")
# ── connection-time guard (DNS rebinding) ─────────────────────────────────
BLOCKED_MESSAGE = "Zieladresse nicht erlaubt"
class BlockedAddress(httpcore.ConnectError):
"""The name resolved to a forbidden address when the connection was opened (httpx reports ConnectError)."""
class _GuardedBackend(httpcore.AsyncNetworkBackend):
def __init__(self, inner: httpcore.AsyncNetworkBackend):
self._inner = inner
async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None):
try:
ip = ipaddress.ip_address(info[4][0].split("%")[0])
except ValueError:
continue
if _forbidden(ip):
raise ValueError("Der Name zeigt auf eine nicht erlaubte Adresse (lokal, Link-Local oder Metadaten-Dienst)")
addresses = await asyncio.wait_for(resolve_host(host, port), timeout)
except asyncio.TimeoutError:
raise httpcore.ConnectTimeout("name resolution timed out") from None
except OSError as exc:
raise httpcore.ConnectError(f"name resolution failed ({type(exc).__name__})") from None
if not addresses or _any_forbidden(addresses):
raise BlockedAddress(BLOCKED_MESSAGE)
last: Exception | None = None
for address in addresses: # the checked addresses only, in resolver order
try:
return await self._inner.connect_tcp(address, port, timeout=timeout, local_address=local_address,
socket_options=socket_options)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
last = exc
raise last # type: ignore[misc]
async def connect_unix_socket(self, path, timeout=None, socket_options=None):
raise httpcore.ConnectError("unix sockets are not allowed")
async def sleep(self, seconds):
await self._inner.sleep(seconds)
class GuardedTransport(httpx.AsyncHTTPTransport):
def __init__(self, **kwargs):
super().__init__(**kwargs)
pool = getattr(self, "_pool", None)
backend = getattr(pool, "_network_backend", None)
if not isinstance(backend, httpcore.AsyncNetworkBackend):
raise RuntimeError("httpx/httpcore internals changed: the connection guard cannot be installed")
pool._network_backend = _GuardedBackend(backend)
def guarded_client(**kwargs) -> httpx.AsyncClient:
"""An httpx client whose connections only ever go to addresses that passed the check. Environment proxies are
ignored (a proxy would resolve the name itself, where the check cannot see it)."""
return httpx.AsyncClient(transport=GuardedTransport(), trust_env=False, **kwargs)
def failure_reason(exc: httpx.HTTPError) -> str:
"""Short, URL-free reason for a failed request (the URL often is the credential)."""
if isinstance(exc, httpx.HTTPStatusError):
return f"HTTP {exc.response.status_code}"
if isinstance(exc.__cause__, BlockedAddress):
return BLOCKED_MESSAGE
return type(exc).__name__
+16 -7
View File
@@ -113,13 +113,15 @@ def occurrences(window: MaintenanceWindow, after: datetime, n: int = 10, *, with
def current(window: MaintenanceWindow, now: datetime) -> Occurrence | None:
"""The occurrence open at ``now`` (start <= now < end), if any."""
"""The occurrence open at ``now`` (start <= now < end), if any.
Occurrences can overlap (a moved one reaching into the next regular one, a 24 h window on a 25 h day). Then the
one that started LAST counts: its start cut-off lies latest, so "may a new host action start now" is not
decided by an older occurrence whose cut-off has long passed (1.29; before, the earliest one was taken)."""
if not window.enabled:
return None
for occ in occurrences(window, now, 3):
if occ.start <= now < occ.end:
return occ
return None
open_now = [occ for occ in occurrences(window, now, 5) if occ.start <= now < occ.end]
return max(open_now, key=lambda occ: occ.start) if open_now else None
def startable(window: MaintenanceWindow | None, now: datetime) -> Occurrence | None:
@@ -137,11 +139,18 @@ def is_occurrence_date(window: MaintenanceWindow, day: date) -> bool:
def definition(window: MaintenanceWindow) -> dict:
"""What a rollout plan's fingerprint covers: changing any of this re-requires activation."""
return {
"""What a rollout plan's fingerprint covers: changing any of this re-requires activation. Skipped and moved
occurrences count too (1.29; keyed by the occurrence's local date, the reason text and the actor do not matter).
Without any exception the result is the same as before 1.29, so existing activations stay valid."""
result = {
"id": str(window.id), "timezone": window.timezone, "recurrence": window.recurrence,
"weekdays": sorted(window.weekdays or []), "month_mode": window.month_mode, "month_nth": window.month_nth,
"month_weekday": window.month_weekday, "month_day": window.month_day,
"start_time": window.start_time.strftime("%H:%M"), "duration_minutes": window.duration_minutes,
"start_cutoff_minutes": window.start_cutoff_minutes, "enabled": window.enabled,
}
if window.exceptions:
result["exceptions"] = sorted(
[e.occurrence_date.isoformat(), e.kind, e.moved_start.astimezone(timezone.utc).isoformat() if e.moved_start else None]
for e in window.exceptions)
return result
+2 -2
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung").
"""
APP_VERSION = "1.27.2"
APP_CODENAME = "Buzzard"
APP_VERSION = "1.29.0"
APP_CODENAME = "Caracara"
@@ -0,0 +1,31 @@
"""Alarm bei fehlenden API-Rechten und pro Gateway abschaltbarer Gateway-Alarm.
Revision ID: 0025
Revises: 0024
Create Date: 2026-10-21
"""
from alembic import op
revision = "0025"
down_revision = "0024"
branch_labels = None
depends_on = None
_COLUMNS = [
("api_rights_enabled", "boolean NOT NULL DEFAULT true"),
("gateway_alert_muted", "json NOT NULL DEFAULT '[]'::json"),
]
def upgrade() -> None:
# IF NOT EXISTS: a pre-Alembic install is bridged with create_all() first, which may already have the columns.
for name, ddl in _COLUMNS:
op.execute(f"ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS {name} {ddl}")
# The list default only fills existing rows; the model supplies it for new ones (and Alembic's schema
# comparison cannot compare a JSON server default).
op.execute("ALTER TABLE notification_settings ALTER COLUMN gateway_alert_muted DROP DEFAULT")
def downgrade() -> None:
for name, _ddl in reversed(_COLUMNS):
op.execute(f"ALTER TABLE notification_settings DROP COLUMN IF EXISTS {name}")
@@ -0,0 +1,33 @@
"""Rollouts: die verantwortliche Person zusätzlich als stabile Benutzer-ID (1.29).
Bisher stand nur der Benutzername im Plan (``activated_by``) und im Lauf (``started_by``). Wird ein Konto gelöscht
und ein neues mit gleichem Namen angelegt, hätte der automatische Start unter der Rechten der neuen Person
weitergelaufen. Die ID ändert sich nie. Vorhandene Zeilen werden über den Benutzernamen zugeordnet (soweit das Konto
noch existiert); ohne Treffer bleibt die ID leer und der Name gilt wie bisher.
Revision ID: 0026
Revises: 0025
Create Date: 2026-10-22
"""
from alembic import op
revision = "0026"
down_revision = "0025"
branch_labels = None
depends_on = None
def upgrade() -> None:
# IF NOT EXISTS: a pre-Alembic install is bridged with create_all() first, which may already have the columns.
op.execute("ALTER TABLE rollout_plans ADD COLUMN IF NOT EXISTS activated_by_id uuid")
op.execute("ALTER TABLE rollout_runs ADD COLUMN IF NOT EXISTS started_by_id uuid")
# No foreign key on purpose: a deleted account must stay recognisably gone (SET NULL would fall back to the name).
op.execute("UPDATE rollout_plans p SET activated_by_id = u.id FROM admin_users u "
"WHERE p.activated_by_id IS NULL AND p.activated_by IS NOT NULL AND u.username = p.activated_by")
op.execute("UPDATE rollout_runs r SET started_by_id = u.id FROM admin_users u "
"WHERE r.started_by_id IS NULL AND u.username = r.started_by")
def downgrade() -> None:
op.execute("ALTER TABLE rollout_runs DROP COLUMN IF EXISTS started_by_id")
op.execute("ALTER TABLE rollout_plans DROP COLUMN IF EXISTS activated_by_id")
+303
View File
@@ -0,0 +1,303 @@
[
{
"name": "S1 cooldown ignored",
"file": "app/services/gui_ticket_service.py",
"old": " usable = [s for s in free if s not in cooling]",
"new": " usable = list(free)",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S1 cooldown window zero",
"file": "app/services/gui_ticket_service.py",
"old": " | (GuiSession.ended_at > now - SLOT_COOLDOWN)))).all()",
"new": " | (GuiSession.ended_at > now)))).all()",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S1 same firewall blocked too",
"file": "app/services/gui_ticket_service.py",
"old": " if h_id == host_id or slot not in free:",
"new": " if slot not in free:",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S1 open sessions of other browser ignored",
"file": "app/services/gui_ticket_service.py",
"old": "((GuiSession.ended_at.is_(None)) & (GuiSession.started_at > now - absolute))\n | ",
"new": "(GuiSession.ended_at.is_(None) & (GuiSession.started_at > now))\n | ",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S1 cooldown not per user",
"file": "app/services/gui_ticket_service.py",
"old": " GuiSession.user_id == user_id,\n ((GuiSession",
"new": " ((GuiSession",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 fingerprint not compared",
"file": "app/services/gui_ticket_service.py",
"old": "and not secrets.compare_digest(target_fp, target_fingerprint(host)):",
"new": "and False:",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 fingerprint ignores CA",
"file": "app/services/gui_ticket_service.py",
"old": "host.tls_ca_pem or \"\", overlay",
"new": "\"\", overlay",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 fingerprint ignores overlay",
"file": "app/services/gui_ticket_service.py",
"old": "overlay_service.proxy_for(host.base_url) or \"\",\n",
"new": "\"\",\n",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 fingerprint ignores origin",
"file": "app/services/gui_ticket_service.py",
"old": "firewall_origin(host.base_url), host.tls_ca_pem",
"new": "\"x\", host.tls_ca_pem",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 gateway forgets fingerprint",
"file": "app/gui_gateway.py",
"old": "\"target_fp\": gs.target_fp})",
"new": "\"target_fp\": None})",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 gateway evicts again",
"file": "app/gui_gateway.py",
"old": " return len(self.sessions) - replaced < MAX_SESSIONS",
"new": " return True",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 backend cap off",
"file": "app/services/gui_ticket_service.py",
"old": "if (open_count or 0) >= MAX_GATEWAY_SESSIONS:",
"new": "if (open_count or 0) > 10**9:",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S4 cap counts own replaced session",
"file": "app/services/gui_ticket_service.py",
"old": " ~((GuiSession.session_hash == session_hash) & (GuiSession.slot == slot))))",
"new": " GuiSession.slot > 0))",
"tests": "tests/test_gui_access.py tests/test_gui_gateway.py"
},
{
"name": "S3 plain cookie accepted again on HTTPS",
"file": "app/routers/auth.py",
"old": " return request.cookies.get(session_cookie_name(), \"\")",
"new": " return request.cookies.get(session_cookie_name()) or request.cookies.get(COOKIE_NAME, \"\")",
"tests": "tests/test_session_hardening.py tests/test_gui_access.py"
},
{
"name": "S3 logout leaves legacy session",
"file": "app/routers/auth.py",
"old": " if settings.cookie_secure and legacy and legacy != token:",
"new": " if False:",
"tests": "tests/test_session_hardening.py tests/test_gui_access.py"
},
{
"name": "S3 logout does not delete plain cookie",
"file": "app/routers/auth.py",
"old": " response.delete_cookie(key=COOKIE_NAME, path=\"/\", secure=settings.cookie_secure, httponly=True, samesite=\"lax\")\n if settings.cookie_secure:\n response.delete_cookie(key=SECURE",
"new": " if settings.cookie_secure:\n response.delete_cookie(key=SECURE",
"tests": "tests/test_session_hardening.py tests/test_gui_access.py"
},
{
"name": "S2 plain state cookie name on HTTPS",
"file": "app/routers/oidc.py",
"old": " return SECURE_STATE_COOKIE if settings.cookie_secure else STATE_COOKIE",
"new": " return STATE_COOKIE",
"tests": "tests/test_oidc.py"
},
{
"name": "S2 state cookie path not /",
"file": "app/routers/oidc.py",
"old": " return \"/\" if settings.cookie_secure else STATE_COOKIE_PATH",
"new": " return STATE_COOKIE_PATH",
"tests": "tests/test_oidc.py"
},
{
"name": "S2 callback without cookie accepted",
"file": "app/routers/oidc.py",
"old": " if not bound or not secrets.compare_digest(bound.encode(), state.encode()):",
"new": " if False:",
"tests": "tests/test_oidc.py"
},
{
"name": "S2 sec-fetch exemption for any request",
"file": "app/security_middleware.py",
"old": " if path in _SEC_FETCH_EXEMPT and _is_top_level_navigation(request):",
"new": " if path in _SEC_FETCH_EXEMPT:",
"tests": "tests/test_oidc.py"
},
{
"name": "S5 no guard in transport",
"file": "app/services/url_guard.py",
"old": " pool._network_backend = _GuardedBackend(backend)",
"new": " pass",
"tests": "tests/test_url_guard.py"
},
{
"name": "S5 forbidden check skipped at connect",
"file": "app/services/url_guard.py",
"old": " if not addresses or _any_forbidden(addresses):",
"new": " if not addresses:",
"tests": "tests/test_url_guard.py"
},
{
"name": "S5 only first answer checked",
"file": "app/services/url_guard.py",
"old": "def _any_forbidden(addresses: list[str]) -> bool:\n for address in addresses:",
"new": "def _any_forbidden(addresses: list[str]) -> bool:\n for address in addresses[:1]:",
"tests": "tests/test_url_guard.py"
},
{
"name": "S5 connects by name again",
"file": "app/services/url_guard.py",
"old": " return await self._inner.connect_tcp(address, port,",
"new": " return await self._inner.connect_tcp(host, port,",
"tests": "tests/test_url_guard.py"
},
{
"name": "S5 webhook without guarded client",
"file": "app/services/notification_delivery.py",
"old": "async with url_guard.guarded_client(timeout=settings.opnsense_timeout, follow_redirects=False) as client:\n with url_guard.muted():",
"new": "async with httpx.AsyncClient(timeout=settings.opnsense_timeout, follow_redirects=False) as client:\n with url_guard.muted():",
"tests": "tests/test_url_guard.py"
},
{
"name": "S5 channel without guarded client",
"file": "app/services/notification_channels.py",
"old": "async with url_guard.guarded_client(",
"new": "async with httpx.AsyncClient(",
"tests": "tests/test_url_guard.py"
},
{
"name": "S5 oidc without guarded client",
"file": "app/services/oidc_service.py",
"old": "async with url_guard.guarded_client(timeout=10, follow_redirects=False) as client:",
"new": "async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:",
"tests": "tests/test_url_guard.py"
},
{
"name": "S7 sequential delivery",
"file": "app/services/notification_service.py",
"old": " await asyncio.gather(*calls, return_exceptions=True)",
"new": " for call in calls:\n await call",
"tests": "tests/test_notification_parallel.py"
},
{
"name": "S7 no per-target timeout",
"file": "app/services/notification_service.py",
"old": " await asyncio.wait_for(make_call(), DELIVERY_TIMEOUT_SECONDS)",
"new": " await make_call()",
"tests": "tests/test_notification_parallel.py"
},
{
"name": "S7 no cycle budget",
"file": "app/services/notification_service.py",
"old": " done, late = await asyncio.wait(tasks, timeout=DELIVERY_CYCLE_BUDGET_SECONDS)",
"new": " done, late = await asyncio.wait(tasks)",
"tests": "tests/test_notification_parallel.py"
},
{
"name": "S7 no parallel limit",
"file": "app/services/notification_service.py",
"old": " gate = asyncio.Semaphore(DELIVERY_PARALLEL)",
"new": " gate = asyncio.Semaphore(10**6)",
"tests": "tests/test_notification_parallel.py"
},
{
"name": "S7 attempt not recorded when cancelled",
"file": "app/services/notification_service.py",
"old": " finally:\n # \"Attempted\" is what is recorded",
"new": " except ZeroDivisionError:\n pass\n else:\n # \"Attempted\" is what is recorded",
"tests": "tests/test_notification_parallel.py"
},
{
"name": "S6 exceptions not in definition",
"file": "app/services/window_service.py",
"old": " if window.exceptions:\n result[",
"new": " if False:\n result[",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "S6 moved start not in definition",
"file": "app/services/window_service.py",
"old": "e.moved_start.astimezone(timezone.utc).isoformat() if e.moved_start else None]",
"new": "None]",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "S6 current takes earliest again",
"file": "app/services/window_service.py",
"old": "return max(open_now, key=lambda occ: occ.start) if open_now else None",
"new": "return min(open_now, key=lambda occ: occ.start) if open_now else None",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "S6 may_roll_out ignores id",
"file": "app/services/rollout_engine.py",
"old": " if user_id is not None:\n user = await db.get(AdminUser, user_id)",
"new": " if False:\n user = await db.get(AdminUser, user_id)",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "S6 auto start check ignores id",
"file": "app/services/rollout_engine.py",
"old": "may_roll_out(db, plan.activated_by or \"\", plan.activated_by_id)",
"new": "may_roll_out(db, plan.activated_by or \"\")",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "S6 run does not get id",
"file": "app/services/rollout_engine.py",
"old": "started_by=plan.activated_by, started_by_id=plan.activated_by_id,",
"new": "started_by=plan.activated_by,",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "S6 activation does not store id",
"file": "app/routers/rollouts.py",
"old": " plan.activated_by_id = request.state.user.id\n",
"new": "",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "S6 migration no backfill of plans",
"file": "migrations/versions/0026_rollout_actor_ids.py",
"old": " op.execute(\"UPDATE rollout_plans p SET",
"new": " op.execute(\"SELECT 1 -- UPDATE rollout_plans p SET",
"tests": "tests/test_rollout_engine.py tests/test_maintenance_windows.py tests/test_migrations.py"
},
{
"name": "UI None % shown again",
"file": "app/services/monitor_checks.py",
"old": "\"wert\": None if value is None else f\"{value}{unit}\",",
"new": "\"wert\": f\"{value}{unit}\",",
"tests": "tests/test_monitoring_alerts.py"
},
{
"name": "UI adopt reason_details dropped",
"file": "app/routers/adopt_routes.py",
"old": "reason_details=json.dumps(exc.details, ensure_ascii=False)) from exc",
"new": "reason_details=\"{}\") from exc",
"tests": "tests/test_object_adopt.py"
},
{
"name": "UI channel test open to operators",
"file": "app/services/api_token_service.py",
"old": "r\"^/api/(users|settings|system|notifications|overlay)(/|$)\"",
"new": "r\"^/api/(users|settings|system|overlay)(/|$)\"",
"tests": "tests/test_operator_role.py"
}
]
+486
View File
@@ -0,0 +1,486 @@
"""Linking hand-made objects on a second firewall to the central object that already exists
(services/adopt_link_service.py), instead of creating a duplicate."""
import json
import httpx
import pytest
from sqlalchemy import func, select
from app.database import SessionLocal
from app.models.alias import FirewallAlias
from app.models.audit import AuditEvent
from app.models.port_forward import PortForwardRule
from tests.helpers import deploy_approved
from tests.test_api_tokens import _login
from tests.test_object_adopt import AUTOMATIC_NAT, NAT_KINDS, SYSTEM_ALIASES, alias_row, o2o_row, pf_row, snat_row
from tests.test_object_adopt import _mode
from tests.test_rule_adopt import _row as rule_row
A = "https://fw-link-a.test"
B = "https://fw-link-b.test"
async def _host(client, respx_mock, base, name):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
r = await client.post("/api/hosts", json={"name": name, "base_url": base, "api_key": "k", "api_secret": "s"})
assert r.status_code == 201, r.text
return r.json()["id"]
def _search(respx_mock, base, path, rows):
return respx_mock.get(f"{base}/api/firewall/{path}").mock(return_value=httpx.Response(200, json={"rows": rows, "total": len(rows)}))
def _live_writes(respx_mock):
return [c for c in respx_mock.calls if c.request.method != "GET" and "/firewall/" in str(c.request.url)]
@pytest.fixture
async def two(client, respx_mock):
a = await _host(client, respx_mock, A, "fw-link-a")
b = await _host(client, respx_mock, B, "fw-link-b")
_mode(respx_mock) # outbound NAT mode is read per base url below
for base in (A, B):
respx_mock.get(f"{base}/api/firewall/source_nat/get").mock(return_value=httpx.Response(
200, json={"filter": {"general": {"snat_mode": {m: {"selected": 1 if m == "hybrid" else 0} for m in
("automatic", "hybrid", "advanced", "disabled")}}}}))
return a, b
async def _put_vars(client, host_id, values):
r = await client.put(f"/api/hosts/{host_id}/variables", json={"variables": values})
assert r.status_code == 200, r.text
# ── aliases ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_alias_is_linked_not_duplicated_and_rolls_out_to_both_hosts(client, respx_mock, two):
a, b = two
live_a = alias_row("web_servers", "host", "10.0.0.1\n10.0.0.2", uuid="uuid-a")
live_b = alias_row("web_servers", "host", "10.0.0.1\n10.0.0.2", uuid="uuid-b")
_search(respx_mock, A, "alias/searchItem", SYSTEM_ALIASES + [live_a])
_search(respx_mock, B, "alias/searchItem", SYSTEM_ALIASES + [live_b])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
# host B: the candidate carries the exact twin (name_exists no longer decides alone)
cand = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()
row = next(r for r in cand if r["uuid"] == "uuid-b")
assert row["linkable"] is True and row["reasons"] == ["name_exists"]
assert [(m["id"], m["name"], m["targets"], m["diff"]) for m in row["matches"]] == [(first["id"], "web_servers", ["fw-link-a"], [])]
assert row["similar"] == [] and row["bulk_action"] == "link"
# adopting it as an own object is still refused (the name is taken)
assert (await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b"})).status_code == 409
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": first["id"]})
assert resp.status_code == 201, resp.text
linked = resp.json()
assert linked["id"] == first["id"] and sorted(linked["target_host_ids"]) == sorted([a, b])
assert {d["host_id"]: d["status"] for d in linked["deployments"]} == {a: "success", b: "success"}
assert not _live_writes(respx_mock) # nothing is written to a firewall
async with SessionLocal() as db:
assert await db.scalar(select(func.count()).select_from(FirewallAlias)) == 1
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "alias"))).all()
assert any(e.message == "Mit vorhandenem Objekt verknüpft" and e.details["remote_uuid"] == "uuid-b" for e in events)
revisions = (await client.get(f"/api/aliases/{first['id']}/revisions")).json()
assert any("fw-link-b verknüpft" in r["note"] for r in revisions)
# preview: unchanged on both hosts; it is no longer offered on B
preview = (await client.get(f"/api/aliases/{first['id']}/preview")).json()
assert sorted(h["action"] for h in preview["hosts"]) == ["unchanged", "unchanged"]
assert [r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json() if r["reasons"] != ["system"]] == []
# one edit rolls out to both remote objects, never as a new one
edit = {"name": "web_servers", "type": "host", "content": "10.0.0.1\n10.0.0.3", "description": "web_servers desc", "enabled": True,
"target_host_ids": [a, b], "target_group_ids": [], "expected_updated_at": linked["updated_at"]}
assert (await client.put(f"/api/aliases/{first['id']}", json=edit)).status_code == 200
add = respx_mock.post(url__regex=r".*/alias/addItem").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
set_a = respx_mock.post(f"{A}/api/firewall/alias/setItem/uuid-a").mock(return_value=httpx.Response(200, json={"result": "saved"}))
set_b = respx_mock.post(f"{B}/api/firewall/alias/setItem/uuid-b").mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(url__regex=r".*/alias/reconfigure").mock(return_value=httpx.Response(200, json={"status": "ok"}))
assert (await deploy_approved(client, f"/api/aliases/{first['id']}/deploy")).status_code == 200
assert set_a.called and set_b.called and not add.called
@pytest.mark.asyncio
async def test_link_is_idempotent(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("dup_me", uuid="uuid-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("dup_me", uuid="uuid-b")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
body = {"host_id": b, "uuid": "uuid-b", "link_to": first["id"]}
assert (await client.post("/api/aliases/adopt", json=body)).status_code == 201
again = await client.post("/api/aliases/adopt", json=body)
assert again.status_code == 201, again.text
assert len(again.json()["deployments"]) == 2 and len(again.json()["target_host_ids"]) == 2
# linking the same live object to ANOTHER central object is refused
other = (await client.post("/api/aliases", json={"name": "other", "type": "host", "content": "1.1.1.1"})).json()
assert (await client.post("/api/aliases/adopt", json={**body, "link_to": other["id"]})).status_code == 409
@pytest.mark.asyncio
async def test_different_content_shows_a_diff_and_is_never_linked(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("ntp", "host", "10.0.0.1", uuid="uuid-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("ntp", "host", "10.0.0.1\n10.0.0.9", uuid="uuid-b", description="other text")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
row = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]
assert row["linkable"] is False and row["matches"] == [] and row["bulk_action"] == "skip"
sim = row["similar"][0]
assert sim["id"] == first["id"]
assert {d["field"]: (d["central"], d["live"]) for d in sim["diff"]} == {
"content": ("10.0.0.1", "10.0.0.1\n10.0.0.9"), "description": ("ntp desc", "other text")}
# forcing the link by id is refused too: the live state is compared again on the server
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": first["id"]})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
assert "content" in resp.json()["detail"]["params"]["fields"]
# nothing was linked
alias = next(x for x in (await client.get("/api/aliases")).json() if x["id"] == first["id"])
assert alias["target_host_ids"] == [a] and len(alias["deployments"]) == 1
@pytest.mark.asyncio
async def test_browser_state_is_not_trusted_live_state_decides(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("srv", "host", "10.0.0.1", uuid="uuid-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("srv", "host", "10.0.0.1", uuid="uuid-b")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "uuid-a"})).json()
assert (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]["linkable"] is True
# between listing and clicking somebody changes the alias on host B by hand
_search(respx_mock, B, "alias/searchItem", [alias_row("srv", "host", "10.0.0.1\n10.0.0.77", uuid="uuid-b")])
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": first["id"]})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
@pytest.mark.asyncio
async def test_variables_are_resolved_per_host(client, respx_mock, two):
a, b = two
await _put_vars(client, a, {"LAN_NET": "192.168.1.0/24"})
await _put_vars(client, b, {"LAN_NET": "192.168.2.0/24"})
central = (await client.post("/api/aliases", json={"name": "lan_net", "type": "network", "content": "{{LAN_NET}}", "description": "lan_net desc",
"target_host_ids": [a]})).json()
_search(respx_mock, B, "alias/searchItem", [alias_row("lan_net", "network", "192.168.2.0/24", uuid="uuid-b")])
row = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]
assert row["linkable"] is True and row["matches"][0]["id"] == central["id"]
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "uuid-b", "link_to": central["id"]})
assert resp.status_code == 201, resp.text
dep_b = next(d for d in resp.json()["deployments"] if d["host_id"] == b)
assert dep_b["status"] == "success"
async with SessionLocal() as db:
from app.models.alias_deployment import AliasDeployment
deps = {str(d.host_id): d for d in (await db.scalars(select(AliasDeployment))).all()}
assert deps[b].opnsense_alias_uuid == "uuid-b" and deps[b].last_payload["content"] == "192.168.2.0/24"
preview = (await client.get(f"/api/aliases/{central['id']}/preview")).json()
assert {h["host_name"]: h["action"] for h in preview["hosts"]}["fw-link-b"] == "unchanged" # resolved payload == live
# a host with a DIFFERENT value for the variable does not match; the diff shows the resolved value
_search(respx_mock, B, "alias/searchItem", [alias_row("lan_net", "network", "192.168.9.0/24", uuid="uuid-x")])
# (host B already has the object, so ask for it through a third host)
c = await _host(client, respx_mock, "https://fw-link-c.test", "fw-link-c")
await _put_vars(client, c, {"LAN_NET": "192.168.3.0/24"})
_search(respx_mock, "https://fw-link-c.test", "alias/searchItem", [alias_row("lan_net", "network", "192.168.9.0/24", uuid="uuid-c")])
row = (await client.get(f"/api/aliases/unmanaged?host_id={c}")).json()[0]
assert row["matches"] == [] and row["similar"][0]["diff"] == [{"field": "content", "central": "192.168.3.0/24", "live": "192.168.9.0/24"}]
@pytest.mark.asyncio
async def test_missing_variable_on_the_host_never_matches(client, respx_mock, two):
a, b = two
await _put_vars(client, a, {"LAN_NET": "192.168.1.0/24"})
await client.post("/api/aliases", json={"name": "lan_net", "type": "network", "content": "{{LAN_NET}}", "description": "lan_net desc", "target_host_ids": [a]})
_search(respx_mock, B, "alias/searchItem", [alias_row("lan_net", "network", "192.168.2.0/24", uuid="uuid-b")])
row = (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()[0]
assert row["matches"] == [] and row["similar"][0]["diff"][0]["field"] == "variables"
@pytest.mark.asyncio
async def test_nested_alias_references_are_part_of_the_comparison(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [
alias_row("inner", "network", "10.1.0.0/16", uuid="in-a"),
alias_row("outer", "networkgroup", "inner\n10.2.0.0/16", uuid="out-a")])
result = (await client.post("/api/aliases/adopt-all", json={"host_id": a})).json()
assert len(result["adopted"]) == 2
_search(respx_mock, B, "alias/searchItem", [
alias_row("inner", "network", "10.1.0.0/16", uuid="in-b"),
alias_row("outer", "networkgroup", "inner\n10.2.0.0/16", uuid="out-b")])
rows = {r["title"]: r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()}
assert rows["inner"]["linkable"] and rows["outer"]["linkable"]
# a different reference is a diff, not a match
_search(respx_mock, B, "alias/searchItem", [
alias_row("inner", "network", "10.1.0.0/16", uuid="in-b"),
alias_row("outer", "networkgroup", "other_alias\n10.2.0.0/16", uuid="out-b"),
alias_row("other_alias", "network", "10.5.0.0/16", uuid="oth-b")])
rows = {r["title"]: r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json()}
assert rows["outer"]["matches"] == [] and rows["outer"]["similar"][0]["diff"][0]["field"] == "content"
# "adopt all" with link: both linked, nothing created
_search(respx_mock, B, "alias/searchItem", [
alias_row("inner", "network", "10.1.0.0/16", uuid="in-b"),
alias_row("outer", "networkgroup", "inner\n10.2.0.0/16", uuid="out-b")])
done = (await client.post("/api/aliases/adopt-all", json={"host_id": b, "link_existing": True})).json()
assert sorted((x["title"], x["linked"]) for x in done["adopted"]) == [("inner", True), ("outer", True)] and done["skipped"] == []
async with SessionLocal() as db:
assert await db.scalar(select(func.count()).select_from(FirewallAlias)) == 2
@pytest.mark.asyncio
async def test_adopt_all_without_link_flag_keeps_old_behaviour(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("x1", uuid="u-a")])
await client.post("/api/aliases/adopt-all", json={"host_id": a})
_search(respx_mock, B, "alias/searchItem", [alias_row("x1", uuid="u-b")])
done = (await client.post("/api/aliases/adopt-all", json={"host_id": b})).json()
assert done["adopted"] == [] and done["skipped"][0]["reasons"] == ["name_exists"]
# ── NAT + rules, every kind ─────────────────────────────────────────────
KINDS = {
"port-forward": ("d_nat/searchRule", pf_row, {"descr": "Web"}, {"destination.port": "9999"}, "local_port"),
"one-to-one": ("one_to_one/searchRule", o2o_row, {"description": "Mail 1:1"}, {"external": "203.0.113.77"}, "external"),
"outbound-nat": ("source_nat/searchRule", snat_row, {"description": "LAN out"}, {"source_net": "192.168.9.0/24"}, "source_net"),
}
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", list(KINDS))
async def test_nat_kinds_link_diff_and_bulk(client, respx_mock, two, prefix):
a, b = two
path, factory, _, change, _ = KINDS[prefix]
_search(respx_mock, A, path, [factory(uuid="a-1"), *AUTOMATIC_NAT])
first = (await client.post(f"/api/{prefix}/adopt", json={"host_id": a, "uuid": "a-1"})).json()
# different on B -> hint + diff, no link possible
_search(respx_mock, B, path, [factory(uuid="b-1", **change)])
row = (await client.get(f"/api/{prefix}/unmanaged?host_id={b}")).json()[0]
assert row["adoptable"] is True and row["linkable"] is False and row["matches"] == []
assert row["similar"][0]["id"] == first["id"] and row["similar"][0]["diff"]
resp = await client.post(f"/api/{prefix}/adopt", json={"host_id": b, "uuid": "b-1", "link_to": first["id"]})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
adopt_all = (await client.post(f"/api/{prefix}/adopt-all", json={"host_id": b, "link_existing": True})).json()
assert adopt_all["adopted"][0]["linked"] is False # a different object is adopted as its own, not linked
# fresh start for the exact case on a third host
c = await _host(client, respx_mock, "https://fw-link-c.test", "fw-link-c")
respx_mock.get("https://fw-link-c.test/api/firewall/source_nat/get").mock(return_value=httpx.Response(
200, json={"filter": {"general": {"snat_mode": {m: {"selected": 1 if m == "hybrid" else 0} for m in
("automatic", "hybrid", "advanced", "disabled")}}}}))
_search(respx_mock, "https://fw-link-c.test", path, [factory(uuid="c-1")])
row = (await client.get(f"/api/{prefix}/unmanaged?host_id={c}")).json()[0]
ids = [m["id"] for m in row["matches"]]
assert ids == [first["id"]] and row["bulk_action"] == "link" and row["linkable"]
resp = await client.post(f"/api/{prefix}/adopt", json={"host_id": c, "uuid": "c-1", "link_to": first["id"]})
assert resp.status_code == 201, resp.text
assert resp.json()["id"] == first["id"] and c in resp.json()["target_host_ids"]
preview = (await client.get(f"/api/{prefix}/{first['id']}/preview")).json()
assert {h["host_name"]: h["action"] for h in preview["hosts"]}["fw-link-c"] == "unchanged"
assert not _live_writes(respx_mock)
listed = (await client.get(f"/api/{prefix}")).json()
assert len(listed) == 2 # first (A + C) and the different one from B
@pytest.mark.asyncio
async def test_port_forward_link_updates_remote_object_without_duplicate(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "d_nat/searchRule", [pf_row(uuid="pf-a")])
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="pf-b")])
first = (await client.post("/api/port-forward/adopt", json={"host_id": a, "uuid": "pf-a"})).json()
linked = (await client.post("/api/port-forward/adopt", json={"host_id": b, "uuid": "pf-b", "link_to": first["id"]})).json()
edit = {k: linked[k] for k in ("description", "interface", "ipprotocol", "protocol", "source_net", "source_port",
"destination_net", "destination_port", "target", "local_port", "natreflection", "enabled")}
for base in (A, B): # alias lookups and the interface consistency check of a multi-host rollout
_search(respx_mock, base, "alias/searchItem", [])
for base in (A, B): # the interface consistency check of a multi-host rollout
respx_mock.get(f"{base}/api/firewall/filter/getRule").mock(return_value=httpx.Response(
200, json={"rule": {"interface": {"wan": {"value": "WAN", "selected": 1}}}}))
edit.update(target="192.168.1.99", target_host_ids=[a, b], target_group_ids=[], expected_updated_at=linked["updated_at"])
assert (await client.put(f"/api/port-forward/{first['id']}", json=edit)).status_code == 200
add = respx_mock.post(url__regex=r".*/d_nat/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
sa = respx_mock.post(f"{A}/api/firewall/d_nat/setRule/pf-a").mock(return_value=httpx.Response(200, json={"result": "saved"}))
sb = respx_mock.post(f"{B}/api/firewall/d_nat/setRule/pf-b").mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(url__regex=r".*/d_nat/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
assert (await deploy_approved(client, f"/api/port-forward/{first['id']}/deploy")).status_code == 200
assert sa.called and sb.called and not add.called
assert json.loads(sb.calls.last.request.content)["rule"]["target"] == "192.168.1.99"
@pytest.mark.asyncio
async def test_rule_link_diff_and_errors(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "filter/searchRule", [rule_row("r-a")])
_search(respx_mock, B, "filter/searchRule", [rule_row("r-b")])
first = (await client.post("/api/rules/adopt", json={"host_id": a, "rule_uuid": "r-a"})).json()
row = (await client.get(f"/api/rules/unmanaged?host_id={b}")).json()[0]
assert row["linkable"] is True and [m["id"] for m in row["matches"]] == [first["id"]]
resp = await client.post("/api/rules/adopt", json={"host_id": b, "rule_uuid": "r-b", "link_to": first["id"]})
assert resp.status_code == 201, resp.text
assert resp.json()["id"] == first["id"] and sorted(resp.json()["target_host_ids"]) == sorted([a, b])
again = await client.post("/api/rules/adopt", json={"host_id": b, "rule_uuid": "r-b", "link_to": first["id"]})
assert again.status_code == 201 and len(again.json()["deployments"]) == 2
preview = (await client.get(f"/api/rules/{first['id']}/preview")).json()
assert sorted(h["action"] for h in preview["hosts"]) == ["unchanged", "unchanged"]
assert not _live_writes(respx_mock)
# different port -> diff only
c = await _host(client, respx_mock, "https://fw-link-c.test", "fw-link-c")
_search(respx_mock, "https://fw-link-c.test", "filter/searchRule", [rule_row("r-c", destination_port="2222")])
row = (await client.get(f"/api/rules/unmanaged?host_id={c}")).json()[0]
assert row["linkable"] is False and row["similar"][0]["diff"] == [{"field": "destination_port", "central": "22", "live": "2222"}]
refused = await client.post("/api/rules/adopt", json={"host_id": c, "rule_uuid": "r-c", "link_to": first["id"]})
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "ADOPT_LINK_TARGET_CHANGED"
# ── error cases ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_deleted_target_and_host_already_has_object_and_busy(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("gone", uuid="u-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("gone", uuid="u-b")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
# busy: an unconfirmed deployment on the central object
respx_mock.post(f"{A}/api/firewall/alias/setItem/u-a").mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(url__regex=r".*/alias/reconfigure").mock(return_value=httpx.Response(200, json={"status": "ok"}))
edit = {"name": "gone", "type": "host", "content": "10.9.9.1\n10.9.9.2", "description": "gone desc", "enabled": False,
"target_host_ids": [a], "target_group_ids": [], "expected_updated_at": first["updated_at"]}
assert (await client.put(f"/api/aliases/{first['id']}", json=edit)).status_code == 200
assert (await deploy_approved(client, f"/api/aliases/{first['id']}/deploy")).status_code == 200 # pending now
busy = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
assert busy.status_code == 409 # content differs now AND it is pending; either way nothing is linked
await client.post(f"/api/aliases/{first['id']}/confirm")
# deleted in the meantime
respx_mock.post(url__regex=r".*/alias/delItem/.*").mock(return_value=httpx.Response(200, json={"result": "deleted"}))
assert (await client.delete(f"/api/aliases/{first['id']}")).status_code == 204
gone = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
assert gone.status_code == 404 and gone.json()["detail"]["code"] == "ADOPT_LINK_TARGET_NOT_FOUND"
@pytest.mark.asyncio
async def test_pending_deployment_blocks_the_link(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("busy1", uuid="u-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("busy1", uuid="u-b")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
async with SessionLocal() as db:
from app.models.alias_deployment import AliasDeployment
dep = (await db.scalars(select(AliasDeployment))).one()
dep.status = "pending"
await db.commit()
resp = await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_BUSY"
@pytest.mark.asyncio
async def test_host_that_already_has_the_object_is_not_a_candidate_and_cannot_link(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("twice", uuid="u-a")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
# a second, differently-UUID'd copy on host A itself: A already has the object
_search(respx_mock, A, "alias/searchItem", [alias_row("twice", uuid="u-a"), alias_row("twice", uuid="u-a2")])
rows = (await client.get(f"/api/aliases/unmanaged?host_id={a}")).json()
assert [r["uuid"] for r in rows] == ["u-a2"] and rows[0]["matches"] == [] and rows[0]["linkable"] is False
resp = await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a2", "link_to": first["id"]})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "ADOPT_LINK_HOST_HAS_OBJECT"
@pytest.mark.asyncio
async def test_bulk_never_guesses_between_two_twins_and_links_each_object_once(client, respx_mock, two):
a, b = two
# two identical central objects exist from earlier separate adoptions
for _ in range(2):
await client.post("/api/port-forward", json={
"description": "Web", "interface": "wan", "ipprotocol": "inet", "protocol": "tcp", "source_net": "any",
"destination_net": "wanip", "destination_port": "8443", "target": "192.168.1.50", "local_port": "443",
"natreflection": "", "enabled": True, "target_host_ids": [a], "target_group_ids": []})
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="pf-b")])
row = (await client.get(f"/api/port-forward/unmanaged?host_id={b}")).json()[0]
assert len(row["matches"]) == 2 and row["bulk_action"] == "new" # ask the user, do not guess
done = (await client.post("/api/port-forward/adopt-all", json={"host_id": b, "link_existing": True})).json()
assert [x["linked"] for x in done["adopted"]] == [False]
@pytest.mark.asyncio
async def test_bulk_links_each_central_object_only_once(client, respx_mock, two):
a, b = two
await client.post("/api/port-forward", json={
"description": "Web", "interface": "wan", "ipprotocol": "inet", "protocol": "tcp", "source_net": "any",
"destination_net": "wanip", "destination_port": "8443", "target": "192.168.1.50", "local_port": "443",
"natreflection": "", "enabled": True, "target_host_ids": [a], "target_group_ids": []})
# two identical live rows on B, ONE twin: the first links, the second becomes its own object
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="b-1"), pf_row(uuid="b-2")])
rows = (await client.get(f"/api/port-forward/unmanaged?host_id={b}")).json()
assert sorted(r["bulk_action"] for r in rows) == ["link", "new"] # the dialog's summary equals what happens
done = (await client.post("/api/port-forward/adopt-all", json={"host_id": b, "link_existing": True})).json()
assert sorted(x["linked"] for x in done["adopted"]) == [False, True]
assert len((await client.get("/api/port-forward")).json()) == 2
@pytest.mark.asyncio
async def test_roles_for_linking(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("roles1", uuid="u-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("roles1", uuid="u-b")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
for role, expected in (("read_only", 403), ("operator", 201)):
user = await client.post("/api/users", json={"username": f"lnk-{role}", "password": "adopt-test-password-1", "role": role})
assert user.status_code == 201, user.text
session = await _login(f"lnk-{role}", "adopt-test-password-1")
try:
resp = await session.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})
assert resp.status_code == expected, (role, resp.text)
finally:
await session.aclose()
@pytest.mark.asyncio
async def test_bulk_leaves_a_twin_alone_that_waits_for_a_confirmation(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("busy2", uuid="u-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("busy2", uuid="u-b")])
await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})
async with SessionLocal() as db:
from app.models.alias_deployment import AliasDeployment
(await db.scalars(select(AliasDeployment))).one().status = "pending"
await db.commit()
row = next(r for r in (await client.get(f"/api/aliases/unmanaged?host_id={b}")).json() if r["uuid"] == "u-b")
assert row["linkable"] is False and row["bulk_action"] == "skip"
resp = await client.post("/api/aliases/adopt-all", json={"host_id": b, "link_existing": True})
assert resp.status_code == 200, resp.text
assert resp.json()["adopted"] == []
assert "twin_busy" in resp.json()["skipped"][0]["reasons"]
async with SessionLocal() as db:
assert await db.scalar(select(func.count()).select_from(FirewallAlias)) == 1
@pytest.mark.asyncio
async def test_linking_invalidates_an_open_edit_form(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "alias/searchItem", [alias_row("stale1", uuid="u-a")])
_search(respx_mock, B, "alias/searchItem", [alias_row("stale1", uuid="u-b")])
first = (await client.post("/api/aliases/adopt", json={"host_id": a, "uuid": "u-a"})).json()
linked = (await client.post("/api/aliases/adopt", json={"host_id": b, "uuid": "u-b", "link_to": first["id"]})).json()
assert linked["updated_at"] != first["updated_at"]
stale = {"name": "stale1", "type": "host", "content": linked["content"], "description": linked["description"],
"enabled": linked["enabled"], "target_host_ids": [a], "target_group_ids": [],
"expected_updated_at": first["updated_at"]}
assert (await client.put(f"/api/aliases/{first['id']}", json=stale)).status_code == 409
@pytest.mark.asyncio
async def test_bulk_neither_links_nor_duplicates_a_busy_nat_twin(client, respx_mock, two):
a, b = two
_search(respx_mock, A, "d_nat/searchRule", [pf_row(uuid="pf-a")])
_search(respx_mock, B, "d_nat/searchRule", [pf_row(uuid="pf-b")])
await client.post("/api/port-forward/adopt", json={"host_id": a, "uuid": "pf-a"})
async with SessionLocal() as db:
from app.models.port_forward_deployment import PortForwardDeployment
(await db.scalars(select(PortForwardDeployment))).one().status = "pending"
await db.commit()
resp = await client.post("/api/port-forward/adopt-all", json={"host_id": b, "link_existing": True})
assert resp.status_code == 200, resp.text
assert resp.json()["adopted"] == [] and "twin_busy" in resp.json()["skipped"][0]["reasons"]
async with SessionLocal() as db:
assert await db.scalar(select(func.count()).select_from(PortForwardRule)) == 1
@@ -0,0 +1,417 @@
"""1.29: missing API rights become visible (OpnsenseForbiddenError, `denied` in the sample, api_rights_missing alert,
dashboard, metrics) and the gateway alert can be switched off per gateway."""
import uuid
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from app.models.host import OpnsenseHost
from app.models.notification import NotificationProblem
from app.models.resource_sample import HostResourceSample
from app.services import monitor_checks, notification_delivery, opnsense_client, resource_monitor_service
from app.services.notification_service import run_notification_checks
from tests.test_monitoring_alerts import (
STATUS_PAYLOAD, _add_samples, _gw, _gateway_problems, _only, _open_count, _set,
)
from tests.test_notifications import _create_host
from tests.test_resource_monitor import _mock_resource_endpoints, _mock_vip
async def _host_row(host_id: str) -> OpnsenseHost:
async with SessionLocal() as db:
return await db.get(OpnsenseHost, uuid.UUID(host_id))
async def _clear_samples():
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
async def _rights_problems():
async with SessionLocal() as db:
return await monitor_checks.check_api_rights_missing(db)
# ── Client: 403 is told apart from everything else (a 401 is a wrong key, not a missing right) ──────────────────────
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [403])
async def test_403_raises_forbidden_error_with_unchanged_message(client, respx_mock, status):
_mock_vip(respx_mock)
host = await _host_row((await _create_host(client, "fw1", "https://fw1.test"))["id"])
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(status, text="denied")
with pytest.raises(opnsense_client.OpnsenseForbiddenError) as exc:
await opnsense_client.get_gateway_status(host)
assert isinstance(exc.value, opnsense_client.OpnsenseAPIError) # existing `except OpnsenseAPIError` stays valid
assert not isinstance(exc.value, opnsense_client.OpnsenseAmbiguousError)
assert str(exc.value).startswith(f"{status} /routes/gateway/status")
@pytest.mark.asyncio
@pytest.mark.parametrize("status,ambiguous", [(500, True), (502, True), (404, False), (400, False), (401, False)])
async def test_other_errors_are_not_forbidden(client, respx_mock, status, ambiguous):
_mock_vip(respx_mock)
host = await _host_row((await _create_host(client, "fw1", "https://fw1.test"))["id"])
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(status)
with pytest.raises(opnsense_client.OpnsenseAPIError) as exc:
await opnsense_client.get_gateway_status(host)
assert not isinstance(exc.value, opnsense_client.OpnsenseForbiddenError)
assert isinstance(exc.value, opnsense_client.OpnsenseAmbiguousError) is ambiguous
@pytest.mark.asyncio
async def test_gateway_endpoint_reports_missing_rights_not_a_generic_502(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
route = respx_mock.get("https://fw1.test/api/routes/gateway/status")
route.respond(403)
resp = await client.get(f"/api/hosts/{host['id']}/gateways")
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "API_RIGHTS_MISSING"
route.respond(500)
assert (await client.get(f"/api/hosts/{host['id']}/gateways")).status_code == 502
# ── Sampler records what was refused ─────────────────────────────────────────
def _mock_optional_reads(respx_mock, *, gateways=200, services=200, swap=200, cpu=200):
base = "https://fw1.test/api"
respx_mock.get(f"{base}/diagnostics/system/systemSwap").respond(swap, json={"swap": []})
respx_mock.get(f"{base}/diagnostics/cpu_usage/getCPUType").respond(cpu, json=["X (4 cores, 4 threads)"])
respx_mock.get(f"{base}/routes/gateway/status").respond(gateways, json=STATUS_PAYLOAD)
respx_mock.get(f"{base}/core/service/search").respond(services, json={"rows": []})
async def _sample_raw() -> dict:
async with SessionLocal() as db:
await resource_monitor_service.poll_all_hosts(db)
return (await db.scalars(select(HostResourceSample).order_by(HostResourceSample.sampled_at.desc()))).first().raw
@pytest.mark.asyncio
async def test_sample_records_only_refused_reads_in_denied(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock)
_mock_optional_reads(respx_mock, gateways=403, services=403, swap=500, cpu=401)
raw = await _sample_raw()
# forbidden -> denied (sorted); a server error and a missing endpoint are "unknown", no finding about rights
assert raw["denied"] == ["gateways", "services"]
assert raw["gateways"] is None and raw["services"] is None and raw["swap_used_pct"] is None
assert raw["ok"] == [] # nothing went through
@pytest.mark.asyncio
async def test_sample_denied_is_an_empty_list_when_nothing_is_refused(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock)
_mock_optional_reads(respx_mock)
raw = await _sample_raw()
assert raw["denied"] == [] and raw["ok"] == ["cpu", "gateways", "services", "swap"]
# unreachable optional endpoints (connect error / timeout) are not refusals either
_mock_optional_reads(respx_mock)
respx_mock.get("https://fw1.test/api/routes/gateway/status").mock(side_effect=httpx.ReadTimeout("slow"))
assert (await _sample_raw())["denied"] == []
@pytest.mark.asyncio
async def test_base_read_refusal_still_fails_the_sample(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock)
respx_mock.get("https://fw1.test/api/diagnostics/system/systemResources").respond(403)
async with SessionLocal() as db:
result = await resource_monitor_service.poll_all_hosts(db)
assert result["hosts_failed"] == 1 and result["sampled"] == 0
# ── The alert ────────────────────────────────────────────────────────────────
DENIED_GW = {"denied": ["gateways"], "ok": ["cpu", "services", "swap"], "gateways": None, "services": {"unbound": 1}, "cpu_threads": 4}
FINE = {"denied": [], "ok": ["cpu", "gateways", "services", "swap"], "gateways": [_gw()], "services": {"unbound": 1}, "cpu_threads": 4}
@pytest.mark.asyncio
async def test_api_rights_alert_needs_consecutive_samples_and_names_the_missing_right(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [DENIED_GW, DENIED_GW, FINE])
assert dict(await _rights_problems()) == {} # two in a row are not enough (debounce)
await _clear_samples()
await _add_samples(hid, [DENIED_GW] * 3)
problems = await _rights_problems()
assert list(problems) == [f"{hid}:gateways"]
ctx = problems[f"{hid}:gateways"]
assert ctx["host_name"] == "fw1" and ctx["fehlendes_recht"] == "System: Gateways"
assert ctx["endpunkt"] == "/api/routes/gateway/status" and "fehlt das Recht" in ctx["hinweis"]
@pytest.mark.asyncio
async def test_api_rights_alert_opens_once_resolves_and_is_delivered(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _only("api_rights_enabled")
await _set(webhook_enabled=True, webhook_url="https://hooks.test/n")
hook = respx_mock.post("https://hooks.test/n").mock(return_value=httpx.Response(200))
await _add_samples(hid, [DENIED_GW] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 1
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 0
assert await _open_count(db, "api_rights_missing") == 1
assert hook.call_count == 1
assert b"API-Rechte fehlen" in hook.calls.last.request.content and b"System: Gateways" in hook.calls.last.request.content
await _clear_samples()
await _add_samples(hid, [FINE] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 1
assert hook.call_count == 2 and b"behoben" in hook.calls.last.request.content
@pytest.mark.asyncio
async def test_api_rights_alert_stays_open_through_unknown_and_flapping_samples(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
key = f"{hid}:gateways"
await _only("api_rights_enabled")
await _add_samples(hid, [DENIED_GW] * 3)
async with SessionLocal() as db:
await run_notification_checks(db)
# the gateway read now times out (gateways None, not denied): no proof the right is back
await _clear_samples()
await _add_samples(hid, [{"denied": [], "gateways": None}] * 3)
assert list(await _rights_problems()) == [key]
# samples from before the denied list existed are unknown too
await _clear_samples()
await _add_samples(hid, [{"gateways": [_gw()]}] * 3)
assert list(await _rights_problems()) == [key]
# one good sample between refusals does not close it
await _clear_samples()
await _add_samples(hid, [DENIED_GW, FINE, DENIED_GW])
assert list(await _rights_problems()) == [key]
@pytest.mark.asyncio
async def test_api_rights_alert_ignores_stale_probe_and_keeps_open_problem(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _only("api_rights_enabled")
await _add_samples(hid, [DENIED_GW] * 3)
async with SessionLocal() as db:
await run_notification_checks(db)
await _clear_samples()
await _add_samples(hid, [FINE] * 3, age_minutes=30) # the host stopped answering
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 0
assert await _open_count(db) == 1
# a stale refusal does not open anything either
await _clear_samples()
async with SessionLocal() as db:
await db.execute(NotificationProblem.__table__.delete())
await db.commit()
await _add_samples(hid, [DENIED_GW] * 3, age_minutes=30)
assert dict(await _rights_problems()) == {}
@pytest.mark.asyncio
async def test_api_rights_alert_is_on_by_default_and_can_be_switched_off(client):
data = (await client.get("/api/notifications/settings")).json()
assert data["api_rights_enabled"] is True
resp = await client.put("/api/notifications/settings", json={"api_rights_enabled": False})
assert resp.status_code == 200 and resp.json()["api_rights_enabled"] is False
host = await _create_host(client, "fw1", "https://fw1.test")
await _add_samples(uuid.UUID(host["id"]), [DENIED_GW] * 3)
await _only() # everything off, including api_rights_enabled
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 0
def test_alert_label_exists():
assert notification_delivery._LABELS["api_rights_missing"] == "API-Rechte fehlen"
# ── Dashboard and metrics ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_dashboard_and_metrics_show_missing_rights(client):
host = await _create_host(client, "fw1", "https://fw1.test", cluster_name=None)
hid = uuid.UUID(host["id"])
await _add_samples(hid, [{"mem": 10, "load": 0.1, **DENIED_GW, "denied": ["gateways", "services"]}])
summary = (await client.get("/api/dashboard")).json()["hosts"][0]
assert summary["api_rights_missing"] == ["gateways", "services"] and summary["gateways"] is None
text = (await client.get("/api/metrics")).text
assert 'opnexus_host_api_rights_missing{host="fw1",cluster="",read="gateways"} 1' in text
assert 'opnexus_host_api_rights_missing{host="fw1",cluster="",read="services"} 1' in text
await _clear_samples()
await _add_samples(hid, [{"mem": 10, "load": 0.1, **FINE}])
assert (await client.get("/api/dashboard")).json()["hosts"][0]["api_rights_missing"] == []
assert 'api_rights_missing{' not in (await client.get("/api/metrics")).text.replace("# HELP", "").replace("# TYPE", "")
await _clear_samples()
await _add_samples(hid, [DENIED_GW], age_minutes=20)
assert (await client.get("/api/dashboard")).json()["hosts"][0]["api_rights_missing"] is None
# ── Mute a single gateway ────────────────────────────────────────────────────
BAD = {"gateways": [_gw("offline", 100.0, None), _gw("offline", 100.0, None, name="LTE_GW")]}
@pytest.mark.asyncio
async def test_muted_gateway_raises_no_alert_but_the_others_do(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
await _add_samples(uuid.UUID(hid), [BAD] * 3)
assert sorted(await _gateway_problems()) == sorted([f"{hid}:WAN_GW", f"{hid}:LTE_GW"])
resp = await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": False})
assert resp.status_code == 200 and resp.json() == {"name": "LTE_GW", "alert_enabled": False}
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"]
# a mute is per host: the same name on another host is still watched
other = await _create_host(client, "fw2", "https://fw2.test")
await _add_samples(uuid.UUID(other["id"]), [BAD] * 3)
assert f"{other['id']}:LTE_GW" in await _gateway_problems()
# switching it on again brings the alert back
await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": True})
assert f"{hid}:LTE_GW" in await _gateway_problems()
@pytest.mark.asyncio
async def test_muting_resolves_an_open_gateway_problem(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
await _only("gateway_down_enabled")
await _add_samples(uuid.UUID(hid), [BAD] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 2
await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": False})
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 1
open_keys = (await db.scalars(select(NotificationProblem.dedup_key).where(NotificationProblem.status == "open"))).all()
assert open_keys == [f"{hid}:WAN_GW"]
@pytest.mark.asyncio
async def test_mute_endpoint_is_idempotent_validates_and_audits(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
for _ in range(2):
assert (await client.put(f"/api/hosts/{hid}/gateways/WAN_DHCP6/alert", json={"enabled": False})).status_code == 200
stored = (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"]
assert stored == [f"{hid}:WAN_DHCP6"] # no duplicate
assert (await client.put(f"/api/hosts/{hid}/gateways/WAN_DHCP6/alert", json={})).status_code == 422
assert (await client.put(f"/api/hosts/{hid}/gateways/WAN_DHCP6/alert", json={"enabled": "maybe"})).status_code == 422
for bad in ["x" * 65, "a%0Ab", "a%20b", "%E2%80%AE", "a%3Ab", "a%2Fb"]:
resp = await client.put(f"/api/hosts/{hid}/gateways/{bad}/alert", json={"enabled": False})
assert resp.status_code in (404, 422), (bad, resp.status_code)
assert resp.status_code != 200
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == stored
missing = await client.put(f"/api/hosts/{uuid.uuid4()}/gateways/WAN_GW/alert", json={"enabled": False})
assert missing.status_code == 404
async with SessionLocal() as db:
events = (await db.scalars(select(AuditEvent).where(AuditEvent.action.like("gateway_alert_%")))).all()
assert [e.action for e in events] == ["gateway_alert_off", "gateway_alert_off"]
assert events[0].host_id == hid and events[0].message == "Gateway WAN_DHCP6" and events[0].actor
@pytest.mark.asyncio
async def test_mute_list_is_capped(client, monkeypatch):
from app.routers import gateways
monkeypatch.setattr(gateways, "MAX_MUTED_GATEWAYS", 3)
hid = (await _create_host(client, "fw1", "https://fw1.test"))["id"]
for i in range(3):
assert (await client.put(f"/api/hosts/{hid}/gateways/GW{i}/alert", json={"enabled": False})).status_code == 200
resp = await client.put(f"/api/hosts/{hid}/gateways/GW3/alert", json={"enabled": False})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "GATEWAY_MUTE_LIMIT"
# an already muted one and un-muting still work at the limit
assert (await client.put(f"/api/hosts/{hid}/gateways/GW0/alert", json={"enabled": False})).status_code == 200
assert (await client.put(f"/api/hosts/{hid}/gateways/GW0/alert", json={"enabled": True})).status_code == 200
@pytest.mark.asyncio
async def test_only_admins_may_switch_a_gateway_alert(client):
from tests.test_api_tokens import _login
from tests.test_operator_role import PW, _operator
hid = (await _create_host(client, "fw1", "https://fw1.test"))["id"]
ro = await client.post("/api/users", json={"username": "reader", "password": PW, "role": "read_only"})
assert ro.status_code == 201
readers = [await _operator(client, "gw-op"), await _login("reader", PW)]
try:
for who in readers:
resp = await who.put(f"/api/hosts/{hid}/gateways/WAN_GW/alert", json={"enabled": False})
assert resp.status_code == 403, resp.text
finally:
for who in readers:
await who.aclose()
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == []
@pytest.mark.asyncio
async def test_gateway_list_and_dashboard_carry_the_alert_flag(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(200, json=STATUS_PAYLOAD)
await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": False})
rows = {g["name"]: g for g in (await client.get(f"/api/hosts/{hid}/gateways")).json()}
assert rows["LTE_GW"]["alert_enabled"] is False and rows["WAN_GW"]["alert_enabled"] is True
await _add_samples(uuid.UUID(hid), [{"gateways": [_gw(), _gw(name="LTE_GW")]}])
dash = {g["name"]: g for g in (await client.get("/api/dashboard")).json()["hosts"][0]["gateways"]}
assert dash["LTE_GW"]["alert_enabled"] is False and dash["WAN_GW"]["alert_enabled"] is True
@pytest.mark.asyncio
async def test_api_rights_endpoint_reads_the_newest_fresh_sample(client):
hid = (await _create_host(client, "fw1", "https://fw1.test"))["id"]
url = f"/api/hosts/{hid}/gateways/api-rights"
assert (await client.get(url)).json() == {"missing": None} # no sample at all: unknown, not "fine"
await _add_samples(uuid.UUID(hid), [DENIED_GW, FINE])
assert (await client.get(url)).json() == {"missing": ["gateways"]}
await _clear_samples()
await _add_samples(uuid.UUID(hid), [FINE, DENIED_GW])
assert (await client.get(url)).json() == {"missing": []}
await _clear_samples()
await _add_samples(uuid.UUID(hid), [DENIED_GW], age_minutes=20)
assert (await client.get(url)).json() == {"missing": None} # stale
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/gateways/api-rights")).status_code == 404
@pytest.mark.asyncio
async def test_right_back_closes_the_alert_even_when_the_answer_is_unparsable(client):
"""cpu_threads stays None when the CPU string has no "threads"/"cores": only the `ok` list proves the read went through."""
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _only("api_rights_enabled")
await _add_samples(hid, [{"denied": ["cpu"], "ok": ["gateways", "services", "swap"], "cpu_threads": None}] * 3)
assert list(await _rights_problems()) == [f"{hid}:cpu"]
await _clear_samples()
await _add_samples(hid, [{"denied": [], "ok": ["cpu", "gateways", "services", "swap"], "cpu_threads": None}] * 3)
assert list(await _rights_problems()) == []
@pytest.mark.asyncio
async def test_mute_refuses_an_unknown_gateway_name_but_unmute_always_works(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [FINE])
assert (await client.put(f"/api/hosts/{hid}/gateways/wan_gw/alert", json={"enabled": False})).status_code == 404
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == []
assert (await client.put(f"/api/hosts/{hid}/gateways/{_gw()['name']}/alert", json={"enabled": False})).status_code == 200
assert (await client.put(f"/api/hosts/{hid}/gateways/wan_gw/alert", json={"enabled": True})).status_code == 200
@pytest.mark.asyncio
async def test_deleting_a_host_removes_its_gateway_mutes(client):
keep = await _create_host(client, "fw-keep", "https://fwkeep.test")
gone = await _create_host(client, "fw-gone", "https://fwgone.test")
for h in (keep, gone):
assert (await client.put(f"/api/hosts/{h['id']}/gateways/WAN_GW/alert", json={"enabled": False})).status_code == 200
assert (await client.delete(f"/api/hosts/{gone['id']}")).status_code == 204
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == [f"{keep['id']}:WAN_GW"]
+166 -3
View File
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from app.config import settings
from app.database import SessionLocal
@@ -181,11 +181,11 @@ async def test_ticket_refused_without_secure_cookie(admin, monkeypatch):
resp = await plain.post(f"/api/hosts/{host_id}/gui-ticket")
assert resp.status_code == 409 and resp.json()["detail"]["code"] in ("GUI_REQUIRES_HTTPS", "GUI_GATEWAY_NOT_CONFIGURED")
monkeypatch.setattr(settings, "cookie_secure", True)
# A legacy plain-name cookie (pre-migration) is not enough either.
# A legacy plain-name cookie is not a session on an HTTPS deployment any more (1.29): not authenticated at all.
async with AsyncClient(transport=ASGITransport(app=app), base_url="https://opnexus.example") as legacy:
legacy.cookies.set("opnexus-session", admin.cookies.get(SECURE_COOKIE_NAME))
resp = await legacy.post(f"/api/hosts/{host_id}/gui-ticket")
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "GUI_REQUIRES_HTTPS"
assert resp.status_code == 401
@pytest.mark.asyncio
@@ -328,3 +328,166 @@ async def test_revalidate_enforces_absolute_limit_and_end(admin):
assert (await _internal("end", {**ref2, "reason": "idle"})).json() == {"ok": True}
assert (await _internal("revalidate", ref2)).json() == {"ok": False, "reason": "idle"}
assert (await _internal("end", {**ref2, "session_hash": sha("x"), "reason": "idle"})).json() == {"ok": False}
# ── S1: slot cooldown ─────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_slot_cooldown_for_another_firewall_but_not_for_the_same(admin):
a, b = await _host("fw-cool-a"), await _host("fw-cool-b", "https://fw-cool-b.test")
await _enable(admin, slot_count=1)
ref = await _open(admin, a)
# Session of firewall A still open (any browser session of the user): B must not get the slot.
other_browser = await _https_login(settings.admin_username, settings.admin_password)
blocked = await other_browser.post(f"/api/hosts/{b}/gui-ticket")
assert blocked.status_code == 409 and blocked.json()["detail"]["code"] == "GUI_SLOT_COOLING_DOWN"
# Just ended: still cooling down for B, free again at once for A.
assert (await _internal("end", {**ref, "reason": "logout"})).json() == {"ok": True}
blocked = await admin.post(f"/api/hosts/{b}/gui-ticket")
assert blocked.status_code == 409 and blocked.json()["detail"] == {
"code": "GUI_SLOT_COOLING_DOWN", "params": {"minutes": "5"}}
assert (await admin.post(f"/api/hosts/{a}/gui-ticket")).status_code == 200
# After the cooldown B takes the slot (the pending ticket for A is gone).
async with SessionLocal() as db:
await db.execute(delete(GuiTicket))
await db.execute(update(GuiSession).values(ended_at=datetime.now(timezone.utc) - timedelta(minutes=4)))
await db.commit()
still = await admin.post(f"/api/hosts/{b}/gui-ticket")
assert still.status_code == 409 and still.json()["detail"] == {
"code": "GUI_SLOT_COOLING_DOWN", "params": {"minutes": "1"}}
async with SessionLocal() as db:
await db.execute(update(GuiSession).values(ended_at=datetime.now(timezone.utc) - timedelta(minutes=5, seconds=5)))
await db.commit()
assert (await admin.post(f"/api/hosts/{b}/gui-ticket")).status_code == 200
await other_browser.aclose()
@pytest.mark.asyncio
async def test_cooling_slot_skipped_when_another_slot_is_free(admin):
a, b = await _host("fw-cool-a"), await _host("fw-cool-b", "https://fw-cool-b.test")
await _enable(admin, slot_count=2)
ref = await _open(admin, a)
slot_a = (await admin.post(f"/api/hosts/{a}/gui-ticket")).json()["slot"]
await _internal("end", {**ref, "reason": "logout"})
slot_b = (await admin.post(f"/api/hosts/{b}/gui-ticket")).json()["slot"]
assert slot_b != slot_a # A's slot cools down although it is the least recently used
@pytest.mark.asyncio
async def test_cooldown_is_per_user(admin):
a, b = await _host("fw-cool-a"), await _host("fw-cool-b", "https://fw-cool-b.test")
await _enable(admin, slot_count=1)
ref = await _open(admin, a)
await _internal("end", {**ref, "reason": "logout"})
second = await _user(admin, "gui-cool-admin-2", "admin") # another person, another browser
assert (await second.post(f"/api/hosts/{b}/gui-ticket")).status_code == 200
# ── S4: target change, session cap ────────────────────────────────────────
async def _open_with_fp(admin: AsyncClient, host_id) -> dict:
url = (await admin.post(f"/api/hosts/{host_id}/gui-ticket")).json()["url"]
data = (await _redeem(admin, url)).json()
assert len(data["target_fp"]) == 64
return {"gui_session_id": data["gui_session_id"], "session_hash": sha(admin.cookies.get(SECURE_COOKIE_NAME)),
"target_fp": data["target_fp"]}
@pytest.mark.asyncio
@pytest.mark.parametrize("field,value", [
("base_url", "https://fw-moved.test"),
("tls_ca_pem", "-----BEGIN CERTIFICATE-----\nOTHER\n-----END CERTIFICATE-----\n"),
])
async def test_session_ends_when_target_changes(admin, field, value):
host_id = await _host()
await _enable(admin)
ref = await _open_with_fp(admin, host_id)
assert (await _internal("revalidate", ref)).json()["ok"] is True
async with SessionLocal() as db:
await db.execute(update(OpnsenseHost).where(OpnsenseHost.id == host_id).values(**{field: value}))
await db.commit()
assert (await _internal("revalidate", ref)).json() == {"ok": False, "reason": "target"}
async with SessionLocal() as db:
assert (await db.scalars(select(GuiSession.end_reason))).one() == "target"
@pytest.mark.asyncio
async def test_session_ends_when_overlay_route_changes(admin, monkeypatch):
from app.services import overlay_service
host_id = await _host()
await _enable(admin)
ref = await _open_with_fp(admin, host_id)
monkeypatch.setattr(overlay_service, "proxy_for", lambda base_url: "http://wg-hub:8888")
assert (await _internal("revalidate", ref)).json() == {"ok": False, "reason": "target"}
@pytest.mark.asyncio
async def test_revalidate_without_fingerprint_still_works(admin):
host_id = await _host()
await _enable(admin)
ref = await _open_with_fp(admin, host_id)
ref.pop("target_fp") # older gateway image
assert (await _internal("revalidate", ref)).json()["ok"] is True
@pytest.mark.asyncio
async def test_session_cap_rejects_new_ticket_and_keeps_running_ones(admin, monkeypatch):
from app.services import gui_ticket_service
monkeypatch.setattr(gui_ticket_service, "MAX_GATEWAY_SESSIONS", 2)
a, b, c = await _host("fw-a"), await _host("fw-b", "https://fw-b.test"), await _host("fw-c", "https://fw-c.test")
await _enable(admin)
ref_a, ref_b = await _open(admin, a), await _open(admin, b)
full = await admin.post(f"/api/hosts/{c}/gui-ticket")
assert full.status_code == 409 and full.json()["detail"]["code"] == "GUI_TOO_MANY_SESSIONS"
assert full.json()["detail"]["params"] == {"max": "2"}
# The same firewall again replaces its own session and does not count twice.
assert (await admin.post(f"/api/hosts/{a}/gui-ticket")).status_code == 200
assert (await _internal("revalidate", ref_a)).json()["ok"] and (await _internal("revalidate", ref_b)).json()["ok"]
# Ended sessions free room again.
await _internal("end", {**ref_b, "reason": "logout"})
assert (await admin.post(f"/api/hosts/{c}/gui-ticket")).status_code == 200
def test_session_cap_equal_in_gateway_and_backend():
from app import gui_gateway
from app.services import gui_ticket_service
assert gui_gateway.MAX_SESSIONS == gui_ticket_service.MAX_GATEWAY_SESSIONS
# ── Review findings 1.29 (S1 follow-up) ───────────────────────────────────
@pytest.mark.asyncio
async def test_redeem_refuses_to_take_over_a_slot_that_holds_another_firewall(admin):
"""Two quick tickets both saw the slot free: the second redeem must not supersede A's session for B."""
a, b = await _host("fw-race-a"), await _host("fw-race-b", "https://fw-race-b.test")
await _enable(admin, slot_count=1)
ref = await _open(admin, a)
async with SessionLocal() as db:
gs = await db.get(GuiSession, uuid.UUID(ref["gui_session_id"]))
slot, user_id = gs.slot, gs.user_id
now = datetime.now(timezone.utc)
db.add(GuiTicket(ticket_hash=sha("race-ticket"), session_hash=ref["session_hash"], user_id=user_id, host_id=b,
slot=slot, created_at=now, expires_at=now + timedelta(seconds=30)))
await db.commit()
denied = await _internal("redeem", {"ticket_hash": sha("race-ticket"), "slot": slot, "session_hash": ref["session_hash"],
"client_ip": "192.0.2.10"})
assert denied.status_code != 200 and "slot" in str(denied.json()) # the same refusal as a slot conflict at redeem
assert (await _internal("revalidate", ref)).json()["ok"] is True # A's session was left alone
@pytest.mark.asyncio
async def test_session_nobody_re_checked_counts_as_ended_for_the_cooldown(admin):
a, b = await _host("fw-zombie-a"), await _host("fw-zombie-b", "https://fw-zombie-b.test")
await _enable(admin, slot_count=1)
await _open(admin, a)
other_browser = await _https_login(settings.admin_username, settings.admin_password) # another browser of the same user
async with SessionLocal() as db: # the gateway died: no /end, no re-check for 2 minutes
await db.execute(update(GuiSession).values(last_checked_at=datetime.now(timezone.utc) - timedelta(minutes=2)))
await db.commit()
blocked = await other_browser.post(f"/api/hosts/{b}/gui-ticket")
assert blocked.status_code == 409 and blocked.json()["detail"] == {"code": "GUI_SLOT_COOLING_DOWN", "params": {"minutes": "3"}}
async with SessionLocal() as db: # silent for longer than the cooldown: free again, also past the absolute limit
await db.execute(update(GuiSession).values(last_checked_at=datetime.now(timezone.utc) - timedelta(minutes=6)))
await db.commit()
assert (await other_browser.post(f"/api/hosts/{b}/gui-ticket")).status_code == 200
await other_browser.aclose()
+43
View File
@@ -434,6 +434,49 @@ async def test_backend_unreachable_fails_closed(setup):
assert len(setup["fw"].requests) == 0
@pytest.mark.asyncio
async def test_session_ends_when_host_address_changes(setup):
from sqlalchemy import update
from app.models.host import OpnsenseHost
cookie = await _open(setup)
assert (await _get(setup, "/ui/x", cookie)).status_code == 200
async with SessionLocal() as db:
await db.execute(update(OpnsenseHost).where(OpnsenseHost.id == setup["host_id"]).values(base_url="https://fw-moved.test"))
await db.commit()
assert (await _get(setup, "/ui/x", cookie)).status_code == 200 # check not due yet
setup["clock"].now += 21
assert (await _get(setup, "/ui/x", cookie)).status_code == 401
assert setup["gateway"].sessions == {}
async with SessionLocal() as db:
assert (await db.scalars(select(GuiSession.end_reason))).one() == "target"
@pytest.mark.asyncio
async def test_session_limit_refuses_new_instead_of_evicting(setup, monkeypatch):
monkeypatch.setattr(gw, "MAX_SESSIONS", 1)
gateway = setup["gateway"]
other = await _https_login(settings.admin_username, settings.admin_password)
first = await _open(setup)
ticket = (await other.post(f"/api/hosts/{setup['host_id']}/gui-ticket")).json()
assert ticket["slot"] == 9441
resp = await setup["browser"].get(f"/__onx/redeem?t={ticket['url'].split('?t=', 1)[1]}", headers={
"X-Onx-Slot": "9441", "Sec-Fetch-Site": "same-site",
"Cookie": f"{SECURE_COOKIE_NAME}={other.cookies.get(SECURE_COOKIE_NAME)}"})
assert resp.status_code == 503 and "set-cookie" not in resp.headers
assert len(gateway.sessions) == 1
async with SessionLocal() as db: # refused up front: the ticket was not even redeemed at the backend
assert len((await db.scalars(select(GuiSession))).all()) == 1
assert (await _get(setup, "/ui/x", first)).status_code == 200 # the running session survived
# An expired session is cleared to make room.
next(iter(gateway.sessions.values())).absolute_deadline = gateway.wall_clock() - 1
ticket = (await other.post(f"/api/hosts/{setup['host_id']}/gui-ticket")).json()
resp = await setup["browser"].get(f"/__onx/redeem?t={ticket['url'].split('?t=', 1)[1]}", headers={
"X-Onx-Slot": "9441", "Sec-Fetch-Site": "same-site",
"Cookie": f"{SECURE_COOKIE_NAME}={other.cookies.get(SECURE_COOKIE_NAME)}"})
assert resp.status_code == 200
await other.aclose()
# ── upstream transport and isolation ──────────────────────────────────────
def _pem() -> str:
+34
View File
@@ -201,3 +201,37 @@ async def test_window_writes_follow_the_deploy_right(client):
finally:
await op.aclose()
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
# ── 1.29 (S6): overlapping occurrences, exceptions in the fingerprint definition ──
def test_overlapping_occurrences_the_latest_started_one_counts():
# Night window Saturday and Sunday 22:00 (8 h, new starts for 60 min). Saturday's occurrence was moved to Sunday
# 20:00, so it overlaps the regular Sunday one (22:00-06:00).
w = _w(timezone="UTC", weekdays=[5, 6], start_time=time(22, 0), duration_minutes=8 * 60, start_cutoff_minutes=60,
exceptions=[MaintenanceWindowException(occurrence_date=date(2026, 10, 17), kind="move",
moved_start=datetime(2026, 10, 18, 20, 0, tzinfo=UTC), reason="x", actor="a")])
now = datetime(2026, 10, 18, 22, 30, tzinfo=UTC)
open_now = [o for o in ws.occurrences(w, now, 5) if o.start <= now < o.end]
assert [o.start for o in open_now] == [datetime(2026, 10, 18, 20, 0, tzinfo=UTC), datetime(2026, 10, 18, 22, 0, tzinfo=UTC)]
# Not the earliest (its cut-off at 21:00 is long past) but the one that started last: new work may start.
assert ws.current(w, now).start == datetime(2026, 10, 18, 22, 0, tzinfo=UTC)
assert ws.startable(w, now) is not None
# Before the second one starts, the first is the only open one and past its cut-off.
before = datetime(2026, 10, 18, 21, 30, tzinfo=UTC)
assert ws.current(w, before).start == datetime(2026, 10, 18, 20, 0, tzinfo=UTC)
assert ws.startable(w, before) is None
def test_definition_unchanged_without_exceptions_and_changes_with_them():
w = _w()
assert "exceptions" not in ws.definition(w)
skip = MaintenanceWindowException(occurrence_date=date(2026, 10, 25), kind="skip", reason="Feiertag", actor="a")
w.exceptions = [skip]
with_skip = ws.definition(w)
assert with_skip["exceptions"] == [["2026-10-25", "skip", None]]
skip.reason, skip.actor = "anderer Grund", "b"
assert ws.definition(w) == with_skip # the explanation is not part of what an activation covered
w.exceptions = [MaintenanceWindowException(occurrence_date=date(2026, 10, 25), kind="move",
moved_start=datetime(2026, 10, 24, 20, 0, tzinfo=UTC), reason="x", actor="a")]
assert ws.definition(w)["exceptions"] == [["2026-10-25", "move", "2026-10-24T20:00:00+00:00"]]
+12
View File
@@ -102,3 +102,15 @@ async def test_read_token_can_scrape(client):
async with _bearer(created["token"]) as api:
resp = await api.get("/api/metrics")
assert resp.status_code == 200 and "opnexus_info" in resp.text
@pytest.mark.asyncio
async def test_rollout_runs_are_counted_by_status(client):
from app.models.rollout import RolloutRun
async with SessionLocal() as db:
for status in ("paused", "succeeded", "succeeded"):
db.add(RolloutRun(plan_name="p", action="update", status=status, started_by="admin", trigger="manual"))
await db.commit()
values = _parse((await client.get("/api/metrics")).text)
assert values['opnexus_rollout_runs{status="succeeded"}'] == 2
assert values['opnexus_rollout_runs{status="paused"}'] == 1
+77
View File
@@ -256,3 +256,80 @@ async def test_0020_upgrade_keeps_existing_settings_and_downgrade_is_clean(scrat
await _alembic(scratch, "upgrade", "head")
assert await _version(scratch) == _head()
assert await _schema_diff(scratch) == []
async def test_0025_adds_api_rights_flag_and_gateway_mute_list_and_is_idempotent(scratch):
"""1.28 -> 1.29: an existing settings row gets api_rights_enabled = true and an empty mute list; running the
statements again (pre-Alembic bridge) changes nothing; a downgrade removes exactly the two columns."""
await _alembic(scratch, "upgrade", "0024")
async with scratch.begin() as conn:
required = (await conn.execute(text(
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'notification_settings' "
"AND is_nullable = 'NO' AND column_default IS NULL"
))).all()
by_type = {"boolean": "true", "integer": "300", "json": "'[]'", "timestamp with time zone": "now()"}
values = {name: by_type.get(kind, "'x'") for name, kind in required}
values.update({"id": "1", "webhook_url": "'https://hooks.example/x'"})
await conn.execute(text(
f"INSERT INTO notification_settings ({', '.join(values)}) VALUES ({', '.join(values.values())})"
))
await _alembic(scratch, "upgrade", "head")
async with scratch.connect() as conn:
row = (await conn.execute(text(
"SELECT webhook_url, api_rights_enabled, gateway_alert_muted::text FROM notification_settings"
))).one()
assert tuple(row) == ("https://hooks.example/x", True, "[]")
assert await _schema_diff(scratch) == []
await _alembic(scratch, "downgrade", "0024")
async with scratch.connect() as conn:
columns = set((await conn.execute(text(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'notification_settings'"
))).scalars().all())
assert "api_rights_enabled" not in columns and "gateway_alert_muted" not in columns
assert (await conn.execute(text("SELECT webhook_url FROM notification_settings"))).scalar() == "https://hooks.example/x"
await _alembic(scratch, "upgrade", "head")
await _alembic(scratch, "downgrade", "0024")
await _alembic(scratch, "upgrade", "head") # IF NOT EXISTS / IF EXISTS: repeatable
assert await _version(scratch) == _head()
assert await _schema_diff(scratch) == []
async def test_0026_adds_account_ids_matches_existing_names_and_is_idempotent(scratch):
"""1.28 -> 1.29: existing plans/runs get the id of the account with that name (if it still exists), the others stay
empty; the statements can run twice (pre-Alembic bridge); a downgrade drops exactly the two columns."""
await _alembic(scratch, "upgrade", "0025")
uid = "11111111-1111-1111-1111-111111111111"
async with scratch.begin() as conn:
await conn.execute(text("INSERT INTO admin_users (id, username, password_hash) VALUES (:id, 'still-here', 'x')"), {"id": uid})
await conn.execute(text(
"INSERT INTO rollout_plans (id, name, description, action, auto_start, gate_checks, compliance_mode, activated_by, "
"created_by, created_at, updated_at) VALUES "
"(gen_random_uuid(), 'p1', '', 'update', true, '{}', 'warn', 'still-here', 'x', now(), now()), "
"(gen_random_uuid(), 'p2', '', 'update', true, '{}', 'warn', 'gone', 'x', now(), now()), "
"(gen_random_uuid(), 'p3', '', 'update', false, '{}', 'warn', NULL, 'x', now(), now())"))
await conn.execute(text(
"INSERT INTO rollout_runs (id, plan_name, plan_snapshot, action, status, current_wave, started_by, trigger, "
"progress, revision, created_at) VALUES "
"(gen_random_uuid(), 'p1', '{}', 'update', 'succeeded', 0, 'still-here', 'manual', '{}', 0, now()), "
"(gen_random_uuid(), 'p2', '{}', 'update', 'succeeded', 0, 'gone', 'manual', '{}', 0, now())"))
await _alembic(scratch, "upgrade", "head")
async with scratch.connect() as conn:
plans = dict((await conn.execute(text("SELECT name, activated_by_id::text FROM rollout_plans"))).all())
runs = dict((await conn.execute(text("SELECT started_by, started_by_id::text FROM rollout_runs"))).all())
assert plans == {"p1": uid, "p2": None, "p3": None} and runs == {"still-here": uid, "gone": None}
assert await _schema_diff(scratch) == []
await _alembic(scratch, "downgrade", "0025")
async with scratch.connect() as conn:
for table, column in (("rollout_plans", "activated_by_id"), ("rollout_runs", "started_by_id")):
names = set((await conn.execute(text(
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table}'"))).scalars().all())
assert column not in names
assert (await conn.execute(text("SELECT count(*) FROM rollout_plans"))).scalar() == 3
await _alembic(scratch, "upgrade", "head")
await _alembic(scratch, "downgrade", "0025")
await _alembic(scratch, "upgrade", "head")
assert await _version(scratch) == _head() == "0026"
assert await _schema_diff(scratch) == []
+26 -1
View File
@@ -159,7 +159,7 @@ _ALL_FLAGS = [
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled", "revert_failed_enabled", "maintenance_unknown_enabled",
"carp_health_enabled", "nat_drift_enabled", "network_drift_enabled", "carp_auto_revert_failed_enabled",
"scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled",
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled", "api_rights_enabled",
]
@@ -347,6 +347,31 @@ async def test_swap_and_cpu_per_thread(client):
assert dict(await _resource_problems()) == {}
@pytest.mark.asyncio
async def test_open_resource_alert_without_a_current_reading_shows_no_number(client):
"""An open alert stays open while the value is unreadable (verdict: unreadable is no evidence either way); the
message must then not say "None %"."""
from app.services import notification_dedup
from app.services.notification_delivery import render_message
from app.models.notification import NotificationProblem
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 0.1, "swap_used_pct": 70.0, "cpu_threads": 2}] * 5)
async with SessionLocal() as db:
opened, _ = await notification_dedup.diff_and_update(db, "resource_threshold", await monitor_checks.check_resource_threshold(db))
await db.commit()
assert [o.dedup_key for o in opened] == [f"{hid}:swap"] and opened[0].context["wert"] == "70.0 %"
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 0.1, "swap_used_pct": None, "cpu_threads": 2}] * 5)
problems = await _resource_problems()
context = problems[f"{hid}:swap"]
assert context["wert"] is None and context["schwelle"].endswith(" %")
_subject, body = render_message(NotificationProblem(problem_type="resource_threshold", dedup_key="k", context=context), "opened")
assert "None" not in body and "schwelle:" in body and "wert:" not in body
# ── Services ─────────────────────────────────────────────────────────────────────
async def _service_problems():
+226
View File
@@ -0,0 +1,226 @@
"""S7 (1.29): channels are served in parallel, each with its own timeout, and one cycle has an upper bound."""
import asyncio
import logging
import time
from types import SimpleNamespace
import pytest
from app.models.notification import NotificationProblem, NotificationSettings
from app.services import notification_channels, notification_delivery, notification_service
def _channel(name):
return SimpleNamespace(name=name, channel_type="ntfy")
def _problem(key="h1"):
return NotificationProblem(problem_type="host_offline", dedup_key=key, context={"host_name": "fw1"})
def _settings(**over):
return NotificationSettings(webhook_enabled=False, email_enabled=False, **over)
@pytest.fixture
def fast_limits(monkeypatch):
monkeypatch.setattr(notification_service, "DELIVERY_TIMEOUT_SECONDS", 0.5)
monkeypatch.setattr(notification_service, "DELIVERY_CYCLE_BUDGET_SECONDS", 3.0)
@pytest.mark.asyncio
async def test_channels_run_at_the_same_time_not_one_after_another(monkeypatch, fast_limits):
started, delivered = [], []
everybody = asyncio.Event()
async def send(channel, problem, transition):
started.append(channel.name)
if len(started) == 3:
everybody.set()
await everybody.wait() # sequential delivery would never get past the first channel
delivered.append(channel.name)
monkeypatch.setattr(notification_channels, "send", send)
problem = _problem()
await notification_service._deliver(_settings(), problem, "opened", [_channel("a"), _channel("b"), _channel("c")])
assert sorted(delivered) == ["a", "b", "c"] and problem.last_notified_at is not None
@pytest.mark.asyncio
async def test_hanging_channel_times_out_alone_others_still_delivered(monkeypatch, fast_limits, caplog):
delivered = []
async def send(channel, problem, transition):
if channel.name == "stuck":
await asyncio.sleep(60)
delivered.append(channel.name)
monkeypatch.setattr(notification_channels, "send", send)
problem = _problem()
started = time.monotonic()
with caplog.at_level(logging.WARNING):
await notification_service._deliver(_settings(), problem, "opened", [_channel("stuck"), _channel("a"), _channel("b")])
assert time.monotonic() - started < 2.0
assert sorted(delivered) == ["a", "b"]
assert problem.last_notified_at is not None # attempted once, not retried
assert "stuck" in caplog.text and "timed out" in caplog.text
@pytest.mark.asyncio
async def test_failing_channel_does_not_stop_the_others_and_log_has_no_url(monkeypatch, fast_limits, caplog):
delivered = []
async def send(channel, problem, transition):
if channel.name == "bad":
raise notification_delivery.NotificationDeliveryError("Kanal „bad“ (ntfy): Zustellung fehlgeschlagen (HTTP 500)")
if channel.name == "boom":
raise RuntimeError("https://ntfy.example/secret-topic exploded")
delivered.append(channel.name)
monkeypatch.setattr(notification_channels, "send", send)
with caplog.at_level(logging.WARNING):
await notification_service._deliver(_settings(), _problem(), "opened", [_channel("bad"), _channel("boom"), _channel("ok")])
assert delivered == ["ok"]
assert "HTTP 500" in caplog.text
assert "secret-topic" not in caplog.text # unexpected errors are logged by type only
@pytest.mark.asyncio
async def test_webhook_and_email_are_not_blocked_by_a_hanging_channel(monkeypatch, fast_limits):
done = []
async def hang(channel, problem, transition):
await asyncio.sleep(60)
async def webhook(row, problem, transition):
done.append("webhook")
async def email(row, subject, body):
done.append("email")
monkeypatch.setattr(notification_channels, "send", hang)
monkeypatch.setattr(notification_delivery, "send_webhook", webhook)
monkeypatch.setattr(notification_delivery, "send_email", email)
row = NotificationSettings(webhook_enabled=True, email_enabled=True)
await notification_service._deliver(row, _problem(), "opened", [_channel("stuck")])
assert sorted(done) == ["email", "webhook"]
@pytest.mark.asyncio
async def test_cycle_has_an_upper_bound_and_records_attempts(monkeypatch, caplog):
monkeypatch.setattr(notification_service, "DELIVERY_TIMEOUT_SECONDS", 30.0)
monkeypatch.setattr(notification_service, "DELIVERY_CYCLE_BUDGET_SECONDS", 0.4)
async def hang(channel, problem, transition):
await asyncio.sleep(60)
monkeypatch.setattr(notification_channels, "send", hang)
problems = [_problem(f"h{i}") for i in range(3)]
started = time.monotonic()
with caplog.at_level(logging.WARNING):
await notification_service._deliver_all(_settings(), [(p, "opened") for p in problems], [_channel("stuck")])
assert time.monotonic() - started < 2.0
assert all(p.last_notified_at is not None for p in problems)
assert "stopped after" in caplog.text
@pytest.mark.asyncio
async def test_parallelism_is_limited(monkeypatch, fast_limits):
monkeypatch.setattr(notification_service, "DELIVERY_PARALLEL", 2)
running = peak = 0
async def send(channel, problem, transition):
nonlocal running, peak
running += 1
peak = max(peak, running)
await asyncio.sleep(0.05)
running -= 1
monkeypatch.setattr(notification_channels, "send", send)
await notification_service._deliver_all(_settings(), [(_problem("a"), "opened"), (_problem("b"), "opened")],
[_channel(str(i)) for i in range(3)])
assert peak == 2
@pytest.mark.asyncio
async def test_event_like_resolved_problems_send_nothing(monkeypatch, fast_limits):
calls = []
async def send(channel, problem, transition):
calls.append(channel.name)
monkeypatch.setattr(notification_channels, "send", send)
problem = NotificationProblem(problem_type="rollout_start_failed", dedup_key="r1", context={})
await notification_service._deliver(_settings(), problem, "resolved", [_channel("a")])
assert calls == [] and problem.resolved_notified_at is not None
@pytest.mark.asyncio
async def test_check_cycle_with_a_hanging_webhook_still_finishes_and_records(client, respx_mock, monkeypatch):
"""End to end through run_notification_checks: a webhook that never answers is cut off by its timeout."""
import httpx
from sqlalchemy import select
from app.database import SessionLocal
from tests.test_notifications import _create_host
from app.services import notification_settings_service
monkeypatch.setattr(notification_service, "DELIVERY_TIMEOUT_SECONDS", 0.3)
respx_mock.get("https://fw-hang.test/api/diagnostics/interface/getVipStatus").mock(side_effect=httpx.ConnectError("no route"))
await _create_host(client, "fw-hang", "https://fw-hang.test")
async def hang(row, problem, transition):
await asyncio.sleep(60)
monkeypatch.setattr(notification_delivery, "send_webhook", hang)
async with SessionLocal() as db:
row = await notification_settings_service.get_settings(db)
row.webhook_enabled, row.webhook_url = True, "https://hooks.test/notify"
await db.commit()
started = time.monotonic()
result = await notification_service.run_notification_checks(db)
assert time.monotonic() - started < 5 and result["opened"] >= 1
problem = (await db.execute(select(NotificationProblem).where(NotificationProblem.problem_type == "host_offline"))).scalar_one()
assert problem.last_notified_at is not None
@pytest.mark.asyncio
async def test_messages_never_tried_before_the_budget_ended_are_not_recorded_as_sent(monkeypatch):
monkeypatch.setattr(notification_service, "DELIVERY_TIMEOUT_SECONDS", 30.0)
monkeypatch.setattr(notification_service, "DELIVERY_CYCLE_BUDGET_SECONDS", 0.4)
monkeypatch.setattr(notification_service, "DELIVERY_PARALLEL", 1)
async def hang(channel, problem, transition):
await asyncio.sleep(60)
monkeypatch.setattr(notification_channels, "send", hang)
problems = [_problem(f"h{i}") for i in range(3)]
await notification_service._deliver_all(_settings(), [(p, "opened") for p in problems], [_channel("stuck")])
tried = [p for p in problems if p.last_notified_at is not None]
assert len(tried) == 1 # only the first got a slot; the other two were never attempted
@pytest.mark.asyncio
async def test_open_problem_that_was_never_delivered_is_sent_again_next_cycle(client, monkeypatch):
from sqlalchemy import select
from app.database import SessionLocal
async def fake_check(db):
return {"retry-me": {"host_name": "fw1"}}
sent = []
async def record(settings_row, pending, channels):
sent.append([problem.dedup_key for problem, _ in pending])
monkeypatch.setattr(notification_service, "_CHECKS", [("host_offline", "host_offline_enabled", fake_check)])
monkeypatch.setattr(notification_service, "_deliver_all", record)
async with SessionLocal() as db:
await notification_service.run_notification_checks(db) # opens it; "delivery" records nothing
async with SessionLocal() as db:
await notification_service.run_notification_checks(db) # still never delivered: again
row = (await db.scalars(select(NotificationProblem).where(NotificationProblem.dedup_key == "retry-me"))).one()
row.last_notified_at = notification_service._now()
await db.commit()
async with SessionLocal() as db:
await notification_service.run_notification_checks(db) # delivered once: quiet again
assert sent == [["retry-me"], ["retry-me"], []]
+2
View File
@@ -152,6 +152,8 @@ async def test_nested_alias_waits_for_its_target_but_bulk_orders_them(client, re
refused = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-outer"})
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "OBJECT_NOT_ADOPTABLE"
assert "references_unmanaged_alias" in refused.json()["detail"]["params"]["reasons"]
import json # the UI fills "{detail}" of the reason text from this
assert json.loads(refused.json()["detail"]["params"]["reason_details"]) == {"references_unmanaged_alias": "inner"}
# ... after its target is managed it works
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-base"})).status_code == 201
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-inner"})).status_code == 201
+68 -1
View File
@@ -390,7 +390,7 @@ async def test_sso_sessions_are_short_and_the_state_cookie_is_httponly(client, a
_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
assert cookie.startswith("opnexus-oidc-state=") 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"
@@ -409,3 +409,70 @@ async def test_error_text_from_the_provider_cannot_forge_log_lines(client, anon_
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
# ── S2 (1.29): state cookie on HTTPS ──────────────────────────────────────
@pytest.mark.asyncio
async def test_state_cookie_is_host_prefixed_on_https_and_only_that_name_counts(client, respx_mock, monkeypatch):
from httpx import ASGITransport, AsyncClient
from app.config import settings
from app.main import app
await _configure(client)
monkeypatch.setattr(settings, "cookie_secure", True)
_mock_idp(respx_mock)
async with AsyncClient(transport=ASGITransport(app=app), base_url="https://test") as browser:
start = await browser.get("/api/auth/oidc/start", params={"origin": "https://test"})
assert start.status_code == 302, start.text
raw = start.headers["set-cookie"]
low = raw.lower()
assert raw.startswith("__Host-opnexus-oidc-state=") and "secure" in low and "httponly" in low
assert "path=/;" in low + ";" and "domain" not in low and "samesite=lax" in low
query = {k: v[0] for k, v in parse_qs(urlsplit(start.headers["location"]).query).items()}
state = browser.cookies.get("__Host-opnexus-oidc-state")
assert state == query["state"]
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(200, json={
"id_token": _id_token(_claims(query["nonce"])), "access_token": "at"}))
# A plain-name cookie (planted by a sibling page) with the right state value is not accepted ...
browser.cookies.delete("__Host-opnexus-oidc-state")
browser.cookies.set("opnexus-oidc-state", query["state"])
resp = await browser.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_callback_without_a_valid_state_cookie_always_fails(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
from httpx import ASGITransport, AsyncClient
from app.main import app
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(200, json={
"id_token": _id_token(_claims(query["nonce"])), "access_token": "at"}))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as stranger:
for cookie in (None, "wrong", query["state"] + "x"): # (more would trip the failed-callback throttle)
if cookie is not None:
stranger.cookies.set("opnexus-oidc-state", cookie, domain="test", path="/api/auth/oidc")
resp = await stranger.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
assert _error(resp) == "state", cookie
assert "set-cookie" not in resp.headers or "opnexus-session" not in resp.headers["set-cookie"]
assert await _users("alice") == {}
# The attempt itself was not burned by the strangers: the owner can still finish it.
resp, _ = await _callback(anon_client, respx_mock, query)
assert resp.headers["location"] == "/dashboard"
@pytest.mark.asyncio
@pytest.mark.parametrize("headers,blocked", [
({"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"}, False), # the IdP's redirect
({"Sec-Fetch-Site": "cross-site"}, False),
({"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "no-cors", "Sec-Fetch-Dest": "image"}, True),
({"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty"}, True),
({"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "iframe"}, True),
])
async def test_sec_fetch_exemption_of_the_callback_is_only_for_top_level_navigation(client, headers, blocked):
# `client` carries a session cookie, so the Sec-Fetch check applies; the callback itself answers 302 (login error).
resp = await client.get("/api/auth/oidc/callback", params={"code": "a", "state": "b"}, headers=headers)
assert (resp.status_code == 403) is blocked, resp.text
other = await client.get("/api/hosts", headers={"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"})
assert other.status_code == 403 # no other endpoint is exempt
+21
View File
@@ -185,3 +185,24 @@ async def test_the_refusal_is_audited_and_covers_operator_tokens(client):
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}
@pytest.mark.asyncio
async def test_channel_test_button_is_admin_only_on_the_server_too(client):
"""The UI hides "Testen" for non-admins; the server refuses it regardless (a test sends a message to an outside server)."""
channel_id = "00000000-0000-0000-0000-000000000001" # refused before the id is even looked up
op = await _operator(client, "chantest-op")
ro = None
try:
assert (await client.post("/api/users", json={"username": "chantest-ro", "password": PW, "role": "read_only"})).status_code == 201
ro = await _login("chantest-ro", PW)
resp = await op.post(f"/api/notifications/channels/{channel_id}/test")
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "ADMIN_REQUIRED"
resp = await ro.post(f"/api/notifications/channels/{channel_id}/test")
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "READ_ONLY_ACCESS"
for who in (op, ro):
assert (await who.post("/api/notifications/test", json={"channel": "webhook"})).status_code == 403
finally:
await op.aclose()
if ro is not None:
await ro.aclose()
+93
View File
@@ -618,3 +618,96 @@ def test_target_version_comes_from_the_core_package_not_product_latest():
assert engine.target_of({**status, "all_packages": {}}, "update") == "26.7.7"
assert engine.target_of({"product": {"product_latest": "26.7.7"}}, "update") == "26.7.7"
assert engine.target_of({"upgrade_major_version": "27.1"}, "major_update") == "27.1"
# ── 1.29 (S6): window exceptions in the fingerprint, stable account id ────────
async def _activate(client, plan):
token = (await client.post(f"/api/rollouts/plans/{plan['id']}/preview")).json()["preview_token"]
resp = await client.post(f"/api/rollouts/plans/{plan['id']}/activate", json={"preview_token": token})
assert resp.status_code == 200, resp.text
async def _only_refusal(plan_id):
async with SessionLocal() as db:
return (await db.get(RolloutPlan, uuid.UUID(plan_id))).auto_start_error
@pytest.mark.asyncio
async def test_a_window_exception_invalidates_the_activation(client, respx_mock, clock):
window = await _window_now(client, clock)
fleet, _hosts, _groups, plan = await _setup(client, respx_mock, window_id=window["id"], auto_start=True)
await _activate(client, plan)
# Somebody skips the occurrence two weeks from now: what the activation covered is no longer what will happen.
later = (clock.now + timedelta(days=14)).date()
resp = await client.post(f"/api/maintenance-windows/{window['id']}/exceptions",
json={"occurrence_date": later.isoformat(), "kind": "skip", "reason": "Feiertag"})
assert resp.status_code == 201, resp.text
async with SessionLocal() as db:
await engine.auto_start(db)
async with SessionLocal() as db:
assert (await db.scalars(select(RolloutRun))).all() == []
assert (await _only_refusal(plan["id"]))["code"] == "plan_changed"
@pytest.mark.asyncio
async def test_activation_survives_a_window_without_exceptions_and_a_reason_edit(client, respx_mock, clock):
window = await _window_now(client, clock)
_fleet, _hosts, _groups, plan = await _setup(client, respx_mock, window_id=window["id"], auto_start=True)
await _activate(client, plan)
async with SessionLocal() as db:
await engine.auto_start(db)
async with SessionLocal() as db:
assert len((await db.scalars(select(RolloutRun))).all()) == 1 # unchanged window: starts as before
@pytest.mark.asyncio
async def test_run_and_plan_store_the_account_id(client, respx_mock, clock):
window = await _window_now(client, clock)
_fleet, _hosts, _groups, plan = await _setup(client, respx_mock, window_id=window["id"], auto_start=True)
admin_id = next(u["id"] for u in (await client.get("/api/users")).json() if u["username"] == "admin")
await _activate(client, plan)
async with SessionLocal() as db:
stored = await db.get(RolloutPlan, uuid.UUID(plan["id"]))
assert str(stored.activated_by_id) == admin_id and stored.activated_by == "admin"
await engine.auto_start(db)
async with SessionLocal() as db:
(run,) = (await db.scalars(select(RolloutRun))).all()
assert str(run.started_by_id) == admin_id and run.started_by == "admin"
await client.post(f"/api/rollouts/plans/{plan['id']}/deactivate")
async with SessionLocal() as db:
stored = await db.get(RolloutPlan, uuid.UUID(plan["id"]), populate_existing=True)
assert stored.activated_by is None and stored.activated_by_id is None
@pytest.mark.asyncio
async def test_a_recreated_account_with_the_same_name_is_not_the_activating_person(client, respx_mock, clock):
from tests.test_operator_role import PW
from tests.test_api_tokens import _login
window = await _window_now(client, clock)
_fleet, _hosts, _groups, plan = await _setup(client, respx_mock, window_id=window["id"], auto_start=True)
made = await client.post("/api/users", json={"username": "second-admin", "password": PW, "role": "admin"})
assert made.status_code == 201, made.text
second = await _login("second-admin", PW)
try:
await _activate(second, plan)
finally:
await second.aclose()
assert (await client.delete(f"/api/users/{made.json()['id']}")).status_code in (200, 204)
again = await client.post("/api/users", json={"username": "second-admin", "password": PW, "role": "admin"})
assert again.status_code == 201 and again.json()["id"] != made.json()["id"]
async with SessionLocal() as db:
await engine.auto_start(db)
async with SessionLocal() as db:
assert (await db.scalars(select(RolloutRun))).all() == [] # the name matches, the person does not
assert (await _only_refusal(plan["id"]))["code"] == "actor_invalid"
await client.delete(f"/api/users/{again.json()['id']}") # users outlive a test within one session
@pytest.mark.asyncio
async def test_rows_without_an_account_id_still_work_by_name(client, respx_mock, clock):
"""Rows written before 1.29 (and not matched by the migration) keep being checked by name."""
async with SessionLocal() as db:
assert await engine.may_roll_out(db, "admin") is True
assert await engine.may_roll_out(db, "nobody-here") is False
assert await engine.may_roll_out(db, "admin", uuid.uuid4()) is False # a given id that does not exist is final
+2
View File
@@ -787,6 +787,8 @@ async def test_reader_sees_only_the_webhook_host(client):
@pytest.mark.asyncio
async def test_cross_scheme_origin_is_blocked_on_https_deployments(client, monkeypatch):
monkeypatch.setattr(settings, "cookie_secure", True)
# The fixture logged in over plain HTTP; an HTTPS deployment only accepts the __Host- cookie name (1.29).
client.cookies.set("__Host-opnexus-session", client.cookies.get("opnexus-session"))
blocked = await client.put("/api/logs/settings", json={"retention_days": 5}, headers={"Origin": "http://test"})
assert blocked.status_code == 403
allowed = await client.put("/api/logs/settings", json={"retention_days": 5}, headers={"Origin": "https://test"})
+38 -8
View File
@@ -1,4 +1,4 @@
"""1.27 prerequisite hardening: __Host- session cookie (with soft migration) and
"""1.27 prerequisite hardening: __Host- session cookie (the plain name is not accepted on HTTPS since 1.29) and
the Sec-Fetch-Site check for cookie-authenticated /api requests."""
import pytest
from httpx import ASGITransport, AsyncClient
@@ -46,21 +46,51 @@ async def test_plain_name_cookie_without_https():
assert not any(c.startswith(SECURE_COOKIE_NAME) for c in _set_cookies(resp))
@pytest.mark.asyncio
async def test_legacy_cookie_is_migrated_not_logged_out(monkeypatch):
# A session issued before the upgrade (plain cookie name) ...
async def _issue_plain_session() -> str:
"""A session as issued before 1.27 / by an HTTP-only deployment (plain cookie name)."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
await _login(ac)
legacy = ac.cookies.get(COOKIE_NAME)
assert legacy
return legacy
@pytest.mark.asyncio
async def test_plain_cookie_not_accepted_on_https_deployments(monkeypatch):
legacy = await _issue_plain_session()
monkeypatch.setattr(settings, "cookie_secure", True)
async with _https_client() as ac:
ac.cookies.set(COOKIE_NAME, legacy)
assert (await ac.get("/api/hosts")).status_code == 200 # still accepted
assert (await ac.get("/api/hosts")).status_code == 401 # 1.29: no fallback any more
me = await ac.get("/api/auth/me")
assert me.json()["authenticated"] is True
moved = [c for c in _set_cookies(me) if c.startswith(f"{SECURE_COOKIE_NAME}={legacy}")]
assert moved and "Secure" in moved[0]
assert me.json() == {"authenticated": False}
assert not [c for c in _set_cookies(me) if c.startswith(SECURE_COOKIE_NAME)] # and no silent migration
# Logging in again works and replaces the old cookie.
login = await ac.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
assert login.status_code == 200
assert (await ac.get("/api/hosts")).status_code == 200
# The very same token still works where the plain name is the normal one (HTTP-only deployment).
monkeypatch.setattr(settings, "cookie_secure", False)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
ac.cookies.set(COOKIE_NAME, legacy)
assert (await ac.get("/api/hosts")).status_code == 200
@pytest.mark.asyncio
async def test_logout_still_deletes_and_revokes_the_old_cookie(monkeypatch):
legacy = await _issue_plain_session()
monkeypatch.setattr(settings, "cookie_secure", True)
async with _https_client() as ac:
await _login(ac)
ac.cookies.set(COOKIE_NAME, legacy)
out = await ac.post("/api/auth/logout")
assert out.status_code == 200
deleted = [c for c in _set_cookies(out) if c.startswith(f"{COOKIE_NAME}=")]
assert deleted and "Max-Age=0" in deleted[0]
monkeypatch.setattr(settings, "cookie_secure", False)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
ac.cookies.set(COOKIE_NAME, legacy)
assert (await ac.get("/api/hosts")).status_code == 401 # the old session is gone server-side as well
@pytest.mark.asyncio
+182
View File
@@ -0,0 +1,182 @@
"""S5 (1.29): the DNS-rebinding window between the URL check and the connection is closed. These tests open REAL
sockets (a local server) so that an httpx/httpcore upgrade that breaks the hook fails here, not silently."""
import asyncio
import ipaddress
import httpcore
import httpx
import pytest
from app.models.notification import NotificationChannel, NotificationProblem, NotificationSettings
from app.services import notification_delivery, oidc_service, url_guard
from app.services.notification_delivery import NotificationDeliveryError
class Server:
"""Local HTTP server on 127.0.0.1 that records connections and Host headers."""
def __init__(self):
self.connections = 0
self.hosts: list[str] = []
self.port = 0
async def _handle(self, reader, writer):
self.connections += 1
head = await reader.readuntil(b"\r\n\r\n")
for line in head.decode().split("\r\n"):
if line.lower().startswith("host:"):
self.hosts.append(line.split(":", 1)[1].strip())
writer.write(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok")
await writer.drain()
writer.close()
async def __aenter__(self):
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
self.port = self._server.sockets[0].getsockname()[1]
return self
async def __aexit__(self, *exc):
self._server.close()
await self._server.wait_closed()
def resolver(*answers):
"""Resolver mock: answer i for call i (the last one repeats); records what it was asked."""
calls = []
async def fake(host, port=None):
calls.append(host)
try:
ipaddress.ip_address(host)
return [host]
except ValueError:
pass
return list(answers[min(len(calls) - 1, len(answers) - 1)])
fake.calls = calls
return fake
@pytest.mark.asyncio
async def test_rebinding_public_then_private_is_refused(monkeypatch):
async with Server() as server:
monkeypatch.setattr(url_guard, "resolve_host", resolver(["203.0.113.9"], ["127.0.0.1"]))
url = f"http://rebind.test:{server.port}/hook"
await url_guard.ensure_resolved_safe(url) # the early check sees the public answer ...
async with url_guard.guarded_client(timeout=5) as client:
with pytest.raises(httpx.ConnectError) as exc:
await client.get(url) # ... the connection resolves again, gets the private one and must not connect
assert isinstance(exc.value.__cause__, url_guard.BlockedAddress)
assert url_guard.failure_reason(exc.value) == url_guard.BLOCKED_MESSAGE
assert server.connections == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("answer", [["127.0.0.1"], ["169.254.169.254"], ["::1"], ["0.0.0.0"], ["::ffff:127.0.0.1"],
["203.0.113.9", "127.0.0.1"], []])
async def test_any_forbidden_or_empty_answer_blocks_the_connection(monkeypatch, answer):
async with Server() as server:
monkeypatch.setattr(url_guard, "resolve_host", resolver(answer))
async with url_guard.guarded_client(timeout=5) as client:
with pytest.raises(httpx.ConnectError):
await client.get(f"http://mixed.test:{server.port}/")
assert server.connections == 0
@pytest.mark.asyncio
async def test_literal_loopback_is_refused_at_connection_time_too():
async with Server() as server:
async with url_guard.guarded_client(timeout=5) as client:
with pytest.raises(httpx.ConnectError):
await client.get(f"http://127.0.0.1:{server.port}/")
assert server.connections == 0
@pytest.mark.asyncio
async def test_connects_to_the_checked_address_and_keeps_name_as_host(monkeypatch):
"""Private ranges stay allowed (self-hosted ntfy/Gotify); the request goes to exactly the address checked,
the Host header keeps the name. (Loopback is only allowed here to have a socket to talk to.)"""
async with Server() as server:
monkeypatch.setattr(url_guard, "_forbidden", lambda ip: False)
fake = resolver(["127.0.0.1"])
monkeypatch.setattr(url_guard, "resolve_host", fake)
async with url_guard.guarded_client(timeout=5) as client:
resp = await client.get(f"http://ntfy.internal.test:{server.port}/topic")
assert resp.status_code == 200 and resp.text == "ok"
assert server.hosts == [f"ntfy.internal.test:{server.port}"]
assert fake.calls == ["ntfy.internal.test"] # one resolution, no second look-up inside the client
def test_cloud_metadata_addresses_outside_link_local_are_forbidden():
for bad in ("fd00:ec2::254", "100.100.100.200", "169.254.169.254", "::ffff:100.100.100.200"):
assert url_guard._any_forbidden([bad]), bad
@pytest.mark.asyncio
async def test_private_ranges_are_not_forbidden():
for ok in ("10.20.30.40", "192.168.1.5", "172.16.0.9", "203.0.113.9", "2001:db8::1"):
assert not url_guard._any_forbidden([ok])
@pytest.mark.asyncio
async def test_falls_back_to_the_next_checked_address(monkeypatch):
async with Server() as server:
monkeypatch.setattr(url_guard, "_forbidden", lambda ip: False)
# The server listens on IPv4 only: "::1" is refused (or unreachable) and the next checked address is tried.
monkeypatch.setattr(url_guard, "resolve_host", resolver(["::1", "127.0.0.1"]))
async with url_guard.guarded_client(timeout=5) as client:
resp = await client.get(f"http://dual.test:{server.port}/")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_unresolvable_name_is_a_plain_connect_error(monkeypatch):
async def fail(host, port=None):
raise OSError("no such host")
monkeypatch.setattr(url_guard, "resolve_host", fail)
async with url_guard.guarded_client(timeout=5) as client:
with pytest.raises(httpx.ConnectError) as exc:
await client.get("http://nowhere.test/")
assert not isinstance(exc.value.__cause__, url_guard.BlockedAddress)
def test_transport_hook_is_installed_and_proxy_env_ignored(monkeypatch):
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.invalid:3128")
monkeypatch.setenv("HTTP_PROXY", "http://proxy.invalid:3128")
client = url_guard.guarded_client()
assert isinstance(client._transport._pool._network_backend, url_guard._GuardedBackend)
assert not client._mounts # no environment proxy mounted next to the guarded transport
@pytest.mark.asyncio
async def test_webhook_delivery_refuses_a_rebound_name(monkeypatch):
async with Server() as server:
monkeypatch.setattr(url_guard, "resolve_host", resolver(["203.0.113.9"], ["127.0.0.1"]))
row = NotificationSettings(webhook_url=f"http://hooks.rebind.test:{server.port}/notify", webhook_signing_secret_set=False)
problem = NotificationProblem(problem_type="host_offline", dedup_key="h1", context={"host_name": "fw1"})
with pytest.raises(NotificationDeliveryError) as exc:
await notification_delivery.send_webhook(row, problem, "opened")
assert url_guard.BLOCKED_MESSAGE in str(exc.value) and str(server.port) not in str(exc.value)
assert server.connections == 0
@pytest.mark.asyncio
async def test_channel_delivery_refuses_a_rebound_name(monkeypatch):
from app.services import notification_channels
async with Server() as server:
monkeypatch.setattr(url_guard, "resolve_host", resolver(["203.0.113.9"], ["127.0.0.1"]))
channel = NotificationChannel(name="ntfy", channel_type="ntfy", enabled=True, secret_set=False,
config={"server_url": f"http://ntfy.rebind.test:{server.port}", "topic": "alerts"})
problem = NotificationProblem(problem_type="host_offline", dedup_key="h1", context={"host_name": "fw1"})
with pytest.raises(NotificationDeliveryError) as exc:
await notification_channels.send(channel, problem, "opened")
assert url_guard.BLOCKED_MESSAGE in str(exc.value)
assert server.connections == 0
@pytest.mark.asyncio
async def test_oidc_discovery_uses_the_guard():
async with Server() as server:
with pytest.raises(oidc_service.OidcError) as exc:
await oidc_service._get_json(f"https://127.0.0.1:{server.port}/.well-known/openid-configuration")
assert exc.value.code == "provider"
assert server.connections == 0 # refused before any connection was made
+83 -14
View File
@@ -272,7 +272,7 @@ und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschl
- **Lokale Konten bleiben:** Die Anmeldung mit Benutzername und Passwort funktioniert weiter, etwa für den
Notfall, wenn der Anbieter ausfällt.
- **Geprüft** werden Signatur (nur asymmetrische Verfahren, nie `none` oder HMAC), Aussteller, Zielgruppe, Ablauf
und Nonce des ID-Tokens; jeder Anmeldeversuch (State) gilt genau einmal und 10 Minuten. Gruppen kommen aus dem
und Nonce des ID-Tokens; jeder Anmeldeversuch (State) gilt genau einmal und 10 Minuten und ist über ein Cookie (bei HTTPS `__Host-opnexus-oidc-state`) an den Browser gebunden, der ihn gestartet hat; ein Aufruf ohne dieses Cookie scheitert immer. Gruppen kommen aus dem
ID-Token, ersatzweise aus dem Userinfo-Endpunkt. Aussteller und Endpunkte müssen `https://` sein. Das ist
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token) und gegen echte Server von Keycloak 26 und
Authelia 4.39 (Anmeldung, Rollen aus Gruppen, Rollenentzug, abgefangener Callback); andere Anbieter: melde, was bei deinem nicht klappt.
@@ -472,15 +472,26 @@ Protection) in die Whitelist ein, bevor du die Funktion benutzt.
- **Kein WebSocket** (Funktionen, die einen brauchen, gehen nicht) und **keine automatische Anmeldung**.
- **Höchstens 4 Plätze gleichzeitig.** Ein Platz bleibt pro OPNexus-Sitzung bei derselben Firewall; sind alle
belegt, schließe eine Oberfläche oder warte, bis sie abläuft. Ein noch offener Tab einer alten Sitzung auf
einem wiederverwendeten Platz bleibt ein theoretisches Restrisiko; schließe Tabs nach der Arbeit.
belegt, schließe eine Oberfläche oder warte, bis sie abläuft.
- **Abkühlzeit:** Ein frei gewordener Platz geht an eine **andere** Firewall erst **5 Minuten** nach dem Ende der
letzten Sitzung dort (derselbe Benutzer; eine noch offene Sitzung zählt als belegt). Dieselbe Firewall bekommt
ihn sofort wieder. Grund: Ein Tab der alten Firewall-Oberfläche kann noch offen sein und teilt sich mit dem
Platz die Cookies. Sind alle Plätze belegt oder noch in der Abkühlzeit, nennt OPNexus den Grund; schließe die
alten Tabs nach der Arbeit.
- **Änderung der Firewall-Adresse:** Ändern sich Adresse, festgesetztes Zertifikat oder Tunnelweg einer Firewall,
während ihre Oberfläche offen ist, endet die Sitzung spätestens bei der nächsten Prüfung (20 Sekunden).
- **Höchstens 64 Sitzungen** laufen gleichzeitig. Eine laufende Sitzung wird nie von einer neuen verdrängt; ist die
Grenze erreicht, wird die neue abgelehnt, bis eine endet.
- Nach einem **Neustart des wg-hub** braucht der Management-Tunnel 2 bis 4 Minuten, bis die Firewalls
sich wieder verbunden haben; bis dahin lässt sich die Oberfläche nicht öffnen.
- Mit `COOKIE_SECURE=false` ist die Funktion nicht verfügbar.
**Hinweis für Betreiber mit eigenen Skripten:** Seit dieser Funktion heißt das Sitzungs-Cookie bei HTTPS
`__Host-opnexus-session` (bestehende Sitzungen werden beim nächsten Seitenaufruf übernommen, niemand wird abgemeldet), und Anfragen an `/api` mit Cookie
und `Sec-Fetch-Site: same-site` oder `cross-site` werden abgelehnt. Skripte mit API-Token (`Authorization: Bearer`)
`__Host-opnexus-session`, und Anfragen an `/api` mit Cookie
und `Sec-Fetch-Site: same-site` oder `cross-site` werden abgelehnt. Seit 1.29 wird das alte Cookie `opnexus-session`
bei HTTPS **nicht mehr akzeptiert**: wer sich seit der Umstellung nicht neu angemeldet hat, muss das einmal tun
(Skripte mit einem rohen Sitzungs-Cookie müssen den neuen Namen verwenden; ohne HTTPS bleibt `opnexus-session` der
normale Name). Skripte mit API-Token (`Authorization: Bearer`)
sind nicht betroffen.
**Alternative ohne diese Funktion:** Du richtest dir einen eigenen WireGuard-Zugang in das Netz der Firewalls ein
@@ -596,6 +607,40 @@ OPNexus verweist dann darauf und ändert den Modus nicht.
Einstellungen, die OPNexus nicht verwaltet (Protokollierung und Pool-Optionen beim Port Forward, Tags, Kategorien, statischer
NAT-Port), werden nie überschrieben und im Dialog als „Bleibt auf der Firewall unverändert“ angezeigt.
#### Gleiches Objekt auf mehreren Firewalls: verknüpfen statt doppelt übernehmen
Gibt es dieselbe Regel, denselben Alias oder dieselbe NAT-Regel auf mehreren Firewalls (typisch: beide Knoten eines
HA-Clusters, mehrere Standorte mit gleicher Vorgabe), übernimmst du sie **einmal** und **verknüpfst** sie auf den anderen
Firewalls mit dem zentralen Objekt. Das gilt für Regeln, Aliase, Port Forward, 1:1-NAT und Outbound-NAT.
1. Auf der ersten Firewall normal **übernehmen**.
2. Im Dialog die zweite Firewall wählen. Bei jedem Eintrag, der einem zentralen Objekt **exakt** entspricht, steht
„Gibt es schon zentral“ und die Aktion **Verknüpfen** (daneben **Als eigenes Objekt übernehmen**). Verknüpfen trägt die
Firewall als weiteres Ziel in das Objekt ein und merkt sich die UUID dort. Auf der Firewall wird nichts geändert.
3. Die Vorschau zeigt danach für diese Firewall „keine Änderung“. Eine spätere Änderung am Objekt rollst du wie gewohnt für
alle Ziele gemeinsam aus; es entsteht kein Duplikat.
**Was „exakt“ heißt:** Alle von OPNexus verwalteten Felder sind gleich, nachdem die `{{VARIABLEN}}` des zentralen Objekts mit
den Werten **dieser** Firewall ersetzt wurden (ein Alias `{{LAN_NET}}` passt also zu `192.168.2.0/24`, wenn das die Variable
dieser Firewall ist). Bei Aliasen müssen Name, Typ, Inhalt einschließlich der Verweise auf andere Aliase, Beschreibung und
Aktiv-Status stimmen. Fehlt der Firewall ein Variablenwert, passt das Objekt nicht, der Dialog nennt die Variable.
**Abweichungen:** Gibt es ein Objekt mit gleichem Namen (Alias) bzw. gleicher Beschreibung (Regeln, NAT), das sich im Inhalt
unterscheidet, zeigt der Dialog „Ähnlich“ mit den abweichenden Feldern (zentral gegenüber Firewall). Verknüpft wird dann **nie**,
auch nicht automatisch. Gleiche entweder die Firewall oder das zentrale Objekt an, oder übernimm das Objekt getrennt (bei Aliasen
geht das nur nach einer Umbenennung, der Name ist zentral eindeutig).
**Alle übernehmbaren:** Der Dialog zeigt vorher, was passiert („3 verknüpfen, 2 neu übernehmen, 1 nicht übernehmbar“), und du
bestätigst. Verknüpft wird nur, wenn genau **ein** zentrales Objekt exakt passt; gibt es mehrere identische (Altlast aus früheren
Einzelübernahmen), entscheidest du bei dem Eintrag selbst, und „Alle“ übernimmt ihn als neues Objekt. Ein zentrales Objekt wird in
einem Lauf höchstens einmal verknüpft.
**Sicherheit:** Die Firewall wird beim Verknüpfen frisch gelesen und noch einmal exakt verglichen; hat sich zwischen Anzeige und Klick
etwas geändert, wird nichts verknüpft und die Meldung nennt die Felder. Ein zweiter Klick ändert nichts. Die Aktion steht im
Audit-Log und in den Revisionen des Objekts („Mit <Firewall> verknüpft“). Rollen wie beim Übernehmen: Operatoren dürfen, Nur-Lese-Benutzer
nicht. Hat das zentrale Objekt eine offene Bestätigung oder einen offenen Rollback, wird erst nach deren Klärung verknüpft.
„Alle übernehmbaren“ lässt solche Einträge dann aus (Hinweis im Ergebnis). **Wichtig:** Ein verknüpftes Objekt wird wie jedes verwaltete Objekt behandelt: Wird das zentrale Objekt gelöscht oder die Firewall aus dessen Zielen entfernt, löscht der nächste Rollout das Objekt auch auf dieser Firewall.
---
## Gerätegruppen und hierarchische Policies
@@ -991,10 +1036,15 @@ auslassen oder verschieben. Ein Fenster, das ein Plan nutzt, lässt sich nicht l
**Automatischer Start:** Ein Plan mit Fenster und stabilem Update kann zu jedem Termin von
selbst starten. Dazu muss ihn jemand mit Ausroll-Recht nach der Vorschau **aktivieren**.
OPNexus merkt sich dabei einen Fingerabdruck von Plan, aufgelösten Hosts und Fenster; ändert
sich davon etwas (auch die Mitglieder einer Gruppe), startet nichts, und es kommt „Rollout
nicht automatisch gestartet“. Jede Bearbeitung des Plans deaktiviert ihn. So kann niemand
die Ziele eines aktivierten Plans unbemerkt ändern.
OPNexus merkt sich dabei einen Fingerabdruck von Plan, aufgelösten Hosts und Fenster
(**einschließlich der ausgelassenen oder verschobenen Termine**); ändert sich davon etwas
(auch die Mitglieder einer Gruppe), startet nichts, und es kommt „Rollout nicht
automatisch gestartet“. Jede Bearbeitung des Plans deaktiviert ihn. So kann niemand
die Ziele eines aktivierten Plans unbemerkt ändern. Wer einen Termin auslässt oder
verschiebt, muss einen automatischen Plan danach neu aktivieren. Die aktivierende Person
wird zusätzlich über ihre Konto-ID erkannt: wird ihr Konto gelöscht und ein neues mit
gleichem Namen angelegt, startet der Plan nicht mehr. Überlappen sich zwei Termine eines
Fensters (z. B. ein verschobener mit dem regulären), zählt der zuletzt begonnene.
Das **Update Center** zeigt den laufenden Rollout als Banner; Hosts, die er noch bearbeitet,
sind dort für manuelle Firmware-Aktionen gesperrt. Ein Neustart oder Update von OPNexus
@@ -1139,15 +1189,20 @@ Einstellungen › **Benachrichtigungen**. Kanäle:
Datenbank, werden nie wieder angezeigt (nur „gesetzt“) und stehen nicht in Fehlermeldungen oder Logs.
Weil bei Telegram und Teams die Adresse selbst das Geheimnis ist, wird sie wie ein Token behandelt.
Ziele müssen `http(s)` sein, ohne Zugangsdaten in der Adresse; lokale Adressen (`localhost`, Loopback,
Link-Local, Metadaten-Dienst) werden abgelehnt, Ziele im eigenen Netz sind erlaubt. Weiterleitungen werden
nicht verfolgt. Ein ausgefallener Kanal hält die anderen nicht auf; zugestellt wird einmal je Ereignis.
Link-Local, Metadaten-Dienst) werden abgelehnt, Ziele im eigenen Netz sind erlaubt. Die Prüfung gilt auch
beim Verbindungsaufbau: OPNexus verbindet genau zu der Adresse, die es geprüft hat (Schutz vor DNS-Rebinding,
Meldung „Zieladresse nicht erlaubt“); dasselbe gilt für die Adressen des SSO-Anbieters. Weiterleitungen werden
nicht verfolgt. Webhook, E-Mail und alle Kanäle werden **gleichzeitig** bedient, jeweils mit eigener
Zeitgrenze (20 Sekunden) und insgesamt höchstens 90 Sekunden je Prüfdurchlauf: ein ausgefallener oder
hängender Kanal hält weder die anderen noch die Prüfung auf (im Log steht „timed out“). Zugestellt wird
einmal je Ereignis, ohne Wiederholung.
Ereignisse (jeweils einzeln schaltbar): Host nicht erreichbar, Zertifikat läuft ab,
Regel-Drift im Cluster, NAT-Drift im Cluster, Netzwerk-Abweichung im Cluster,
fehlgeschlagener Rollback, Wartungsergebnis unklar, CARP-Zustand kritisch
(Split-Brain/kein Master), CARP-Auto-Revert fehlgeschlagen, geplante Wartung nicht
ausgeführt, Gateway gestört, Ressourcen-Schwelle überschritten, Dienst ausgefallen
(die letzten drei sind unten beschrieben). Geprüft wird im eingestellten Intervall (Standard 300 s). Ein Problem
ausgeführt, Gateway gestört, Ressourcen-Schwelle überschritten, Dienst ausgefallen,
API-Rechte fehlen (die letzten vier sind unten beschrieben). Geprüft wird im eingestellten Intervall (Standard 300 s). Ein Problem
meldet sich einmal und löst sich selbst auf, sobald es behoben ist; die
Dashboard-Karte „Warnungen“ zeigt die offenen. Ausnahme „geplante Wartung nicht
ausgeführt“: Sie bleibt drei Tage nach dem geplanten Zeitpunkt offen und schließt dann
@@ -1168,6 +1223,17 @@ beurteilt OPNexus sie nicht (das meldet „Host nicht erreichbar“) und lässt
OPNsense selbst entscheidet über „down“ nach den Grenzen im Gateway (Einstellung „Gateway-Überwachung“). Von Hand abgeschaltete
Gateways („Gateway deaktivieren/erzwungen aus“) und Gateways ohne Überwachung (kein Monitor-IP, Werte „~“) melden nichts.
Der Status steht auch auf der Host-Seite (Abschnitt „Gateways“, live gelesen), als Kachel auf dem Dashboard und in Prometheus.
**Einzelne Gateways abschalten:** Ein ruhendes Zweit-WAN oder ein totes IPv6-Gateway melden dauerhaft „offline“. Auf der Host-Seite schaltet die
Glocke hinter dem Gateway den Alarm nur für dieses Gateway aus (Tooltip „Alarm für dieses Gateway“); das Gateway trägt dann das Badge „Alarm aus“,
ein offener Alarm löst sich auf, und ein Klick auf die Glocke schaltet ihn wieder ein. Das dürfen nur Administratoren (wie die übrigen
Benachrichtigungs-Einstellungen), die Änderung steht im Audit-Log. Höchstens 200 Gateways lassen sich abschalten.
- **API-Rechte fehlen** – **standardmäßig an**. Der API-Benutzer von OPNexus braucht in OPNsense Leserechte für die Messwerte, auf denen die Alarme
beruhen: *System: Gateways* (Gateway-Status), *Status: Services* (Dienste) und *Lobby: Dashboard* (CPU, Swap; dieselbe Berechtigung wie die
Basiswerte). Verweigert die Firewall einen dieser Zugriffe ausdrücklich (HTTP 403), würde der zugehörige Alarm nie auslösen. Dann zeigt die
Host-Seite und das Dashboard das Warn-Badge „API-Rechte fehlen: …“ (Tooltip nennt die fehlende Berechtigung), es gibt einen Alarm je Firewall und
Zugriff, und er löst sich auf, sobald der Zugriff wieder klappt. Zeitüberschreitungen und Serverfehler gelten nur als „unbekannt“ und lösen weder
etwas aus noch schließen sie den Alarm. Abhilfe: in OPNsense unter System › Zugang beim Benutzer oder der Gruppe des API-Schlüssels die genannte
Berechtigung ergänzen. Prometheus: `opnexus_host_api_rights_missing{host,read}` (1, nur solange es fehlt).
- **Ressourcen-Schwelle** – standardmäßig **aus**: Auf einer bestehenden Flotte würde das Einschalten sonst gleich alles melden, was schon länger voll
ist. Schwellen: Festplatte ab 90 % (je ZFS-Pool bzw. UFS-Gerät ein Alarm), Arbeitsspeicher ab 95 %, Swap ab 50 %, CPU-Last ab 2,0 je Thread
(1-Minuten-Last geteilt durch die Zahl der CPU-Threads). Jede Schwelle lässt sich mit 0 abschalten.
@@ -1292,15 +1358,18 @@ scrape_configs:
| `opnexus_host_swap_used_percent` | Swap-Belegung der letzten Messung (fehlt ohne Swap) |
| `opnexus_host_gateway_up{gateway}` | 1, solange OPNsense das Gateway nicht als down führt |
| `opnexus_host_gateway_loss_percent`, `…_delay_seconds`, `…_stddev_seconds` `{gateway}` | Paketverlust, Laufzeit und Abweichung zum Monitor (fehlen ohne Messung) |
| `opnexus_host_api_rights_missing{read}` | 1, solange die Firewall den Lesezugriff `read` (gateways, services, swap, cpu) wegen fehlender API-Rechte verweigert; sonst fehlt die Zeile |
| `opnexus_host_service_running{service,instance}` | 1 = läuft, nur für Dienste aus der Überwachungsliste |
| `opnexus_host_compliance_score`, `opnexus_host_compliance_findings{severity}` | Sicherheitslage |
| `opnexus_pending_actions{kind,status}` | Offene Aktionen wie auf dem Dashboard |
| `opnexus_problems_open{type}` | Offene Probleme der Benachrichtigungen |
| `opnexus_scheduled_maintenance_pending` | Noch nicht gelaufene geplante Wartungen |
| `opnexus_rollout_runs{status}` | Flotten-Rollouts nach Status (wartend, laufend, pausiert, erfolgreich, …) |
Ein fertiges **Grafana-Dashboard** liegt unter [`docs/grafana-opnexus.json`](grafana-opnexus.json)
(Grafana › Dashboards › Importieren, Prometheus-Datenquelle wählen): erreichbare Firewalls, offene Aktionen
und Probleme, Sicherheitswert und Befunde, Last, Speicher, Platte und CARP-Rolle.
und Probleme, Sicherheitswert und Befunde, Last, Speicher, Swap, Platte und CARP-Rolle sowie Gateways (erreichbar, Paketverlust, Latenz),
gestoppte Dienste, fehlende API-Rechte und Rollouts nach Status.
### KI-Assistenten (MCP)
+251 -1
View File
@@ -25,7 +25,7 @@
],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"version": 2,
"refresh": "1m",
"time": {
"from": "now-24h",
@@ -611,6 +611,256 @@
"overrides": []
},
"options": {}
},
{
"id": 14,
"title": "Gateway erreichbar / Gateway up",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 0,
"y": 26,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_gateway_up",
"legendFormat": "{{host}} · {{gateway}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"min": 0,
"max": 1,
"custom": {
"lineInterpolation": "stepAfter"
}
},
"overrides": []
},
"options": {}
},
{
"id": 15,
"title": "Gateway Paketverlust / Loss %",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 8,
"y": 26,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_gateway_loss_percent",
"legendFormat": "{{host}} · {{gateway}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {}
},
{
"id": 16,
"title": "Gateway Latenz / Delay",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 16,
"y": 26,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_gateway_delay_seconds",
"legendFormat": "{{host}} · {{gateway}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {}
},
{
"id": 17,
"title": "Swap %",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 0,
"y": 33,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_swap_used_percent",
"legendFormat": "{{host}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {}
},
{
"id": 18,
"title": "Dienste gestoppt / Stopped services",
"type": "table",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 8,
"y": 33,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_service_running == 0",
"legendFormat": "",
"instant": true,
"format": "table"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"showHeader": true
}
},
{
"id": 19,
"title": "API-Rechte fehlen / Missing API rights",
"type": "table",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 16,
"y": 33,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_host_api_rights_missing",
"legendFormat": "",
"instant": true,
"format": "table"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"showHeader": true
}
},
{
"id": 20,
"title": "Rollouts nach Status / Rollouts by status",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"x": 0,
"y": 40,
"w": 12,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "opnexus_rollout_runs",
"legendFormat": "{{status}}",
"instant": false
}
],
"fieldConfig": {
"defaults": {
"custom": {
"lineInterpolation": "stepAfter"
}
},
"overrides": []
},
"options": {}
}
],
"description": "Fleet overview from the OPNexus /api/metrics endpoint (see the OPNexus handbook, chapter Automation)."
+15 -2
View File
@@ -22,7 +22,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useLanguage } from "@/components/LanguageProvider";
import { GatewayStateBadge, gatewayNumbers } from "@/components/GatewayStatus";
import { ApiRightsBadge, GatewayStateBadge, gatewayNumbers } from "@/components/GatewayStatus";
import { labelFor, type Language } from "@/lib/i18n";
const PENDING_KIND_LABELS: Record<string, string> = {
@@ -182,7 +182,7 @@ export default function DashboardPage() {
};
}, [errorMessage, router]);
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift", "gateway_down", "resource_threshold", "service_down"].includes(p.problem_type));
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift", "gateway_down", "resource_threshold", "service_down", "api_rights_missing"].includes(p.problem_type));
const gatewayOrder: Record<string, number> = { offline: 0, degraded: 1, unknown: 2, forced_down: 3, online: 4 };
const gatewayRows = (summary?.hosts ?? [])
.flatMap((host) => (host.gateways ?? []).map((gateway) => ({ host, gateway })))
@@ -241,6 +241,7 @@ export default function DashboardPage() {
{host.name}
</Link>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{host.api_rights_missing && host.api_rights_missing.length > 0 && <ApiRightsBadge reads={host.api_rights_missing} />}
{host.cluster_name && <Badge tone="neutral">{host.cluster_name}</Badge>}
{host.carp_status && <Badge tone={host.carp_status === "MASTER" ? "success" : "info"}>{host.carp_status}</Badge>}
<StatusDot status={host.status} />
@@ -265,10 +266,14 @@ export default function DashboardPage() {
</Link>
<span className="flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">{gatewayNumbers(gateway, t)}</span>
{!gateway.alert_enabled && <Badge tone="neutral">{t("gateway.alert.off")}</Badge>}
<GatewayStateBadge state={gateway.state} />
</span>
</li>
))}
{gatewayRows.length > 8 && (
<li className="py-2 text-xs text-slate-500">{t("dashboard.moreGateways", { count: gatewayRows.length - 8 })}</li>
)}
</ul>
)}
</SectionCard>
@@ -341,6 +346,14 @@ export default function DashboardPage() {
<Badge tone="danger" className="shrink-0">{t(`dashboard.alert.${problem.problem_type}`)}</Badge>
</Link>
)}
{problem.problem_type === "api_rights_missing" && (
<Link href={`/hosts/${String(problem.context.host_id)}`} className="flex items-center justify-between gap-3 hover:underline">
<span className="min-w-0 truncate font-medium text-slate-900">
{t("dashboard.apiRightsLine", { name: String(problem.context.host_name), read: t(`gateway.rights.read.${String(problem.context.lesezugriff)}`) })}
</span>
<Badge tone="warning" className="shrink-0">{t("dashboard.alert.api_rights_missing")}</Badge>
</Link>
)}
{problem.problem_type === "cert_expiry" && (
<Link href="/certificates" className="flex items-center justify-between gap-3 hover:underline">
<span className="min-w-0 truncate font-medium text-slate-900">
+1 -1
View File
@@ -196,7 +196,7 @@ function Snapshots({ hostId, readOnly }: { hostId: string; readOnly: boolean })
<fieldset disabled={readOnly} className="contents">
<div className="flex justify-end">
{(row.active === "-" || row.active === "") && (
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => remove(row)}>
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} title={t("network.rowDelete")} onClick={() => remove(row)}>
<Trash2 size={14} />
</Button>
)}
+2 -2
View File
@@ -231,10 +231,10 @@ function RowActions({ kind, uuid, onChange }: { kind: NetworkKind; uuid: string;
const { t } = useLanguage();
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" aria-label={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid })}>
<Button variant="ghost" aria-label={t("network.rowEdit")} title={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid })}>
<Pencil size={14} />
</Button>
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid })}>
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} title={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid })}>
<Trash2 size={14} />
</Button>
</div>
+2 -2
View File
@@ -638,8 +638,8 @@ function RuleTable({
</div>
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
<span className="cursor-grab text-slate-400" title={t("ph.dragTitle")} aria-hidden="true"><GripVertical size={16} /></span>
<Button variant="ghost" disabled={i === 0} onClick={() => onMove(rule, "up")} aria-label={t("ph.moveUp")}><ArrowUp size={14} /></Button>
<Button variant="ghost" disabled={i === rules.length - 1} onClick={() => onMove(rule, "down")} aria-label={t("ph.moveDown")}><ArrowDown size={14} /></Button>
<Button variant="ghost" disabled={i === 0} onClick={() => onMove(rule, "up")} aria-label={t("ph.moveUp")} title={t("ph.moveUp")}><ArrowUp size={14} /></Button>
<Button variant="ghost" disabled={i === rules.length - 1} onClick={() => onMove(rule, "down")} aria-label={t("ph.moveDown")} title={t("ph.moveDown")}><ArrowDown size={14} /></Button>
<Button variant="outline" onClick={() => onEdit(rule)} disabled={rule.deployments.some((d) => ["pending", "revert_failed"].includes(d.status))}><Pencil size={14} /></Button>
<Button onClick={() => onReview(rule, "preview")}><Rocket size={14} />{t("rules.reviewPush")}</Button>
<Button variant="ghost" onClick={() => onReview(rule, "history")} title={t("rules.history")}><History size={14} /></Button>
+4 -4
View File
@@ -189,8 +189,8 @@ export default function RolloutsPage() {
? <Button variant="outline" onClick={() => act(() => api.deactivateRolloutPlan(p.id), "rollouts.plan.deactivated")}><PowerOff size={14} />{t("rollouts.plan.deactivate")}</Button>
: <Button variant="outline" onClick={() => openPreview(p, "activate")}><Power size={14} />{t("rollouts.plan.activate")}</Button>)}
{p.auto_start && p.active && !p.fingerprint_ok && <Button variant="outline" onClick={() => openPreview(p, "activate")}><Power size={14} />{t("rollouts.plan.activate")}</Button>}
<Button variant="outline" aria-label={t("rollouts.plan.edit")} onClick={() => setEditPlan(p)}><Pencil size={14} /></Button>
<Button variant="dangerSubtle" aria-label={t("common.delete")} onClick={() => confirm(t("rollouts.plan.deleteConfirm", { name: p.name })) && act(() => api.deleteRolloutPlan(p.id), "rollouts.plan.deleted")}><Trash2 size={14} /></Button>
<Button variant="outline" aria-label={t("rollouts.plan.edit")} title={t("rollouts.plan.edit")} onClick={() => setEditPlan(p)}><Pencil size={14} /></Button>
<Button variant="dangerSubtle" aria-label={t("common.delete")} title={t("common.delete")} onClick={() => confirm(t("rollouts.plan.deleteConfirm", { name: p.name })) && act(() => api.deleteRolloutPlan(p.id), "rollouts.plan.deleted")}><Trash2 size={14} /></Button>
</div>
)}
</div>
@@ -218,8 +218,8 @@ export default function RolloutsPage() {
</div>
{!readOnly && (
<div className="flex gap-2">
<Button variant="outline" aria-label={t("mwindows.edit")} onClick={() => setEditWindow(w)}><Pencil size={14} /></Button>
<Button variant="dangerSubtle" aria-label={t("common.delete")} onClick={() => confirm(t("mwindows.deleteConfirm", { name: w.name })) && act(() => api.deleteMaintenanceWindow(w.id), "mwindows.deleted")}><Trash2 size={14} /></Button>
<Button variant="outline" aria-label={t("mwindows.edit")} title={t("mwindows.edit")} onClick={() => setEditWindow(w)}><Pencil size={14} /></Button>
<Button variant="dangerSubtle" aria-label={t("common.delete")} title={t("common.delete")} onClick={() => confirm(t("mwindows.deleteConfirm", { name: w.name })) && act(() => api.deleteMaintenanceWindow(w.id), "mwindows.deleted")}><Trash2 size={14} /></Button>
</div>
)}
</div>
+4
View File
@@ -72,6 +72,7 @@ function NotificationsSection() {
const [resourceThreshold, setResourceThreshold] = useState(false);
const [serviceDown, setServiceDown] = useState(false);
const [rolloutProblems, setRolloutProblems] = useState(true);
const [apiRights, setApiRights] = useState(true);
const [gatewayLoss, setGatewayLoss] = useState(20);
const [gatewayDelay, setGatewayDelay] = useState(500);
const [diskLimit, setDiskLimit] = useState(90);
@@ -110,6 +111,7 @@ function NotificationsSection() {
setResourceThreshold(data.resource_threshold_enabled);
setServiceDown(data.service_down_enabled);
setRolloutProblems(data.rollout_problems_enabled ?? true);
setApiRights(data.api_rights_enabled ?? true);
setGatewayLoss(data.gateway_loss_threshold_pct);
setGatewayDelay(data.gateway_delay_threshold_ms);
setDiskLimit(data.disk_threshold_pct);
@@ -166,6 +168,7 @@ function NotificationsSection() {
resource_threshold_enabled: resourceThreshold,
service_down_enabled: serviceDown,
rollout_problems_enabled: rolloutProblems,
api_rights_enabled: apiRights,
gateway_loss_threshold_pct: gatewayLoss,
gateway_delay_threshold_ms: gatewayDelay,
disk_threshold_pct: diskLimit,
@@ -313,6 +316,7 @@ function NotificationsSection() {
[t("settings.notify.event.resourceThreshold"), resourceThreshold, setResourceThreshold],
[t("settings.notify.event.serviceDown"), serviceDown, setServiceDown],
[t("settings.notify.event.rolloutProblems"), rolloutProblems, setRolloutProblems],
[t("settings.notify.event.apiRights"), apiRights, setApiRights],
].map(([label, value, setter]) => (
<label key={label as string} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={value as boolean} onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)} />
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Select } from "@/components/ui/Field";
import { Tooltip } from "@/components/ui/Tooltip";
import { useLanguage } from "@/components/LanguageProvider";
import { labelFor } from "@/lib/i18n";
import type { LinkDiff, LinkTarget } from "@/lib/api";
import { useState } from "react";
interface Props {
linkable: boolean;
matches: LinkTarget[];
similar: LinkTarget[];
/** The object could also be adopted as a new central object (false e.g. for an alias whose name is taken). */
canAdoptNew: boolean;
busy: boolean;
anyBusy: boolean;
onLink: (objectId: string) => void;
onAdoptNew: () => void;
}
function shorten(value: string): string {
const flat = value.replace(/\n/g, " ⏎ ");
return flat.length > 120 ? `${flat.slice(0, 117)}…` : flat;
}
function showValue(value: string, t: (key: string) => string): string {
if (value === "true") return t("adopt.link.yes");
if (value === "false") return t("adopt.link.no");
return value === "" ? "–" : shorten(value);
}
/**
* What to do with an object found on a firewall when central objects already exist: link it to its exact
* twin (the default, no duplicate), adopt it as an own object, or — when there is only a similar object —
* see exactly which fields differ. Nothing is ever linked without the user's click, and the server
* compares again with the live state before it links.
*/
export function AdoptLinkChoice({ linkable, matches, similar, canAdoptNew, busy, anyBusy, onLink, onAdoptNew }: Props) {
const { t } = useLanguage();
const [chosen, setChosen] = useState("");
const selected = matches.find((m) => m.id === chosen) ?? matches[0];
const fieldLabel = (diff: LinkDiff) => labelFor(t, "adopt.link.fields", diff.field);
if (linkable && selected) {
return (
<div className="mt-2 space-y-2 rounded-lg border border-[rgb(var(--tone-info-border))] bg-[rgb(var(--tone-info-bg))] p-3 text-sm text-[rgb(var(--tone-info-text))]">
<p>{t("adopt.link.found", { name: selected.name, hosts: selected.targets.join(", ") || "–" })}</p>
{matches.length > 1 && (
<label className="flex flex-wrap items-center gap-2 text-xs">
{t("adopt.link.choose")}
<Select className="w-auto" value={selected.id} onChange={(e) => setChosen(e.target.value)}>
{matches.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.targets.join(", ") || "–"})
</option>
))}
</Select>
</label>
)}
<div className="flex flex-wrap gap-2">
<Tooltip content={t("adopt.link.linkTip")}>
<Button onClick={() => onLink(selected.id)} disabled={anyBusy} loading={busy}>
{t("adopt.link.action")}
</Button>
</Tooltip>
<Tooltip content={canAdoptNew ? t("adopt.link.newTip") : t("adopt.link.newBlockedTip")} focusable={!canAdoptNew}>
<Button variant="outline" onClick={onAdoptNew} disabled={anyBusy || !canAdoptNew}>
{t("adopt.link.adoptNew")}
</Button>
</Tooltip>
</div>
</div>
);
}
if (similar.length > 0) {
return (
<div className="mt-2 space-y-1 rounded-lg border border-[rgb(var(--tone-warning-border))] bg-[rgb(var(--tone-warning-bg))] p-3 text-xs text-[rgb(var(--tone-warning-text))]">
{similar.map((s) => (
<div key={s.id}>
<p className="font-medium">{t("adopt.link.similar", { name: s.name, hosts: s.targets.join(", ") || "–" })}</p>
<ul className="mt-1 space-y-0.5">
{s.diff.map((d) => (
<li key={d.field} className="break-words">
<span className="font-medium">{fieldLabel(d)}:</span>{" "}
{d.field === "variables"
? t("adopt.link.variableMissing", { names: d.central })
: t("adopt.link.diffValues", { central: showValue(d.central, t), live: showValue(d.live, t) })}
</li>
))}
</ul>
</div>
))}
<p>{t("adopt.link.similarNext")}</p>
</div>
);
}
return null;
}
+54 -11
View File
@@ -2,10 +2,12 @@
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Field";
import { useToast } from "@/components/ui/Toast";
import { useLanguage } from "@/components/LanguageProvider";
import { AdoptLinkChoice } from "@/components/AdoptLinkChoice";
import { AdoptAllResult, AdoptKind, api, Host, UnmanagedObject } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
@@ -52,6 +54,7 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [result, setResult] = useState<AdoptAllResult | null>(null);
const [confirming, setConfirming] = useState(false);
function load(id: string) {
setItems(null);
@@ -68,6 +71,7 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
useEffect(() => {
if (hostId) {
setResult(null);
setConfirming(false);
load(hostId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -78,7 +82,10 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
[items]
);
const systemCount = (items ?? []).filter(isSystem).length;
const bulkCount = shown.filter((i) => i.bulk_adoptable).length;
const linkCount = shown.filter((i) => i.bulk_action === "link").length;
const newCount = shown.filter((i) => i.bulk_action === "new").length;
const leftCount = shown.length - linkCount - newCount;
const bulkCount = linkCount + newCount;
function reasonText(code: string, details: Record<string, string>): string {
return t(`adopt.reasons.${code}`, { detail: details[code] ?? "" });
@@ -98,11 +105,11 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
}
}
async function adopt(item: UnmanagedObject) {
async function adopt(item: UnmanagedObject, linkTo?: string) {
setBusy(item.uuid);
try {
await api.adoptObject(kind, hostId, item.uuid);
toast.show(t("adopt.adopted", { name: item.title }), "success");
await api.adoptObject(kind, hostId, item.uuid, linkTo);
toast.show(t(linkTo ? "adopt.linked" : "adopt.adopted", { name: item.title }), "success");
onAdopted();
load(hostId);
} catch (e) {
@@ -114,10 +121,11 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
async function adoptAll() {
setBusy("all");
setConfirming(false);
try {
const done = await api.adoptAllObjects(kind, hostId);
const done = await api.adoptAllObjects(kind, hostId, true);
setResult(done);
toast.show(t("adopt.allDone", { count: done.adopted.length, skipped: done.skipped.length }), done.adopted.length ? "success" : "info");
toast.show(allDoneText(done), done.adopted.length ? "success" : "info");
onAdopted();
load(hostId);
} catch (e) {
@@ -127,6 +135,14 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
}
}
function allDoneText(done: AdoptAllResult): string {
return t("adopt.allDoneLinked", {
count: done.adopted.length,
linked: done.adopted.filter((a) => a.linked).length,
skipped: done.skipped.length,
});
}
return (
<Modal title={t(TITLE_KEY[kind])} onClose={onClose}>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
@@ -143,17 +159,32 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
</Select>
</label>
{bulkCount > 0 && (
<Button onClick={adoptAll} disabled={busy !== null} loading={busy === "all"}>
<Button onClick={() => setConfirming(true)} disabled={busy !== null || confirming} loading={busy === "all"}>
{t("adopt.all", { count: bulkCount })}
</Button>
)}
</div>
{confirming && (
<div role="alertdialog" aria-label={t("adopt.confirm.title")} className="space-y-2 rounded-lg border border-slate-300 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-medium">{t("adopt.confirm.summary", { link: linkCount, created: newCount, left: leftCount })}</p>
<p className="text-xs text-slate-500">{t("adopt.confirm.note")}</p>
<div className="flex flex-wrap gap-2">
<Button onClick={adoptAll} disabled={busy !== null} loading={busy === "all"}>
{t("adopt.confirm.go")}
</Button>
<Button variant="ghost" onClick={() => setConfirming(false)}>
{t("common.cancel")}
</Button>
</div>
</div>
)}
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
{items === null && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{enabledHosts.length === 0 && <EmptyState title={t("adopt.noHosts.title")} description={t("adopt.noHosts.description")} />}
{enabledHosts.length > 0 && items === null && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{items !== null && shown.length === 0 && !error && <p className="text-sm text-slate-500">{t("adopt.none")}</p>}
{result && (
<div role="status" className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-medium">{t("adopt.allDone", { count: result.adopted.length, skipped: result.skipped.length })}</p>
<p className="font-medium">{allDoneText(result)}</p>
{result.skipped.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-slate-500">
{result.skipped.map((s) => (
@@ -180,7 +211,9 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
)}
{kind === "aliases" && item.info.description && <p className="mt-1 text-xs text-slate-500">{item.info.description}</p>}
</div>
{item.adoptable ? (
{item.linkable ? (
<Badge tone="info">{t("adopt.link.badge")}</Badge>
) : item.adoptable ? (
<Button variant="outline" onClick={() => adopt(item)} disabled={busy !== null} loading={busy === item.uuid}>
{t("adopt.action")}
</Button>
@@ -190,7 +223,17 @@ export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
</Badge>
)}
</div>
{!item.adoptable && (
<AdoptLinkChoice
linkable={item.linkable}
matches={item.matches}
similar={item.similar}
canAdoptNew={item.adoptable}
busy={busy === item.uuid}
anyBusy={busy !== null}
onLink={(objectId) => adopt(item, objectId)}
onAdoptNew={() => adopt(item)}
/>
{!item.adoptable && !item.linkable && (
<p className="mt-2 text-xs text-slate-500">{item.reasons.map((code) => reasonText(code, item.reason_details)).join(" · ")}</p>
)}
{item.kept.length > 0 && (
+21 -6
View File
@@ -2,9 +2,11 @@
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { useLanguage } from "@/components/LanguageProvider";
import { AdoptLinkChoice } from "@/components/AdoptLinkChoice";
import { api, Host, UnmanagedRule } from "@/lib/api";
import { useEffect, useState } from "react";
@@ -52,11 +54,11 @@ export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
async function adopt(rule: UnmanagedRule) {
async function adopt(rule: UnmanagedRule, linkTo?: string) {
setBusy(rule.uuid);
try {
await api.adoptRule(hostId, rule.uuid);
toast.show(t("rules.adopt.adopted", { name: rule.description }), "success");
await api.adoptRule(hostId, rule.uuid, linkTo);
toast.show(t(linkTo ? "adopt.linked" : "rules.adopt.adopted", { name: rule.description }), "success");
onAdopted();
load(hostId);
} catch (e) {
@@ -85,7 +87,8 @@ export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
</select>
</label>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
{rules === null && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{enabledHosts.length === 0 && <EmptyState title={t("adopt.noHosts.title")} description={t("adopt.noHosts.description")} />}
{enabledHosts.length > 0 && rules === null && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{rules !== null && rules.length === 0 && !error && (
<p className="text-sm text-slate-500">{t("rules.adopt.none")}</p>
)}
@@ -101,7 +104,9 @@ export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
{rule.log ? ` · ${t("rules.logPackets")}` : ""}
</p>
</div>
{rule.adoptable ? (
{rule.linkable ? (
<Badge tone="info">{t("adopt.link.badge")}</Badge>
) : rule.adoptable ? (
<Button variant="outline" onClick={() => adopt(rule)} disabled={busy !== null} loading={busy === rule.uuid}>
{t("rules.adopt.action")}
</Button>
@@ -109,7 +114,17 @@ export function AdoptRulesModal({ hosts, onClose, onAdopted }: Props) {
<Badge tone="neutral">{t("rules.adopt.notPossible")}</Badge>
)}
</div>
{!rule.adoptable && (
<AdoptLinkChoice
linkable={rule.linkable}
matches={rule.matches}
similar={rule.similar}
canAdoptNew={rule.adoptable}
busy={busy === rule.uuid}
anyBusy={busy !== null}
onLink={(objectId) => adopt(rule, objectId)}
onAdoptNew={() => adopt(rule)}
/>
{!rule.adoptable && !rule.linkable && (
<p className="mt-2 text-xs text-slate-500">
{rule.reasons.map((code) => t(`rules.adopt.reasons.${code}`)).join(" · ")}
</p>
+60 -4
View File
@@ -5,9 +5,12 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { Tooltip } from "@/components/ui/Tooltip";
import { useLanguage } from "@/components/LanguageProvider";
import { api, GatewayHealth, GatewayState, Host } from "@/lib/api";
import { Globe, RefreshCw } from "lucide-react";
import { api, ApiError, GatewayHealth, GatewayState, Host } from "@/lib/api";
import { useNotAdmin } from "@/lib/useReadOnly";
import { Bell, BellOff, Globe, KeyRound, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
const TONE: Record<GatewayState, BadgeTone> = {
@@ -28,17 +31,57 @@ export function gatewayNumbers(g: GatewayHealth, t: (key: string, params?: Recor
return parts.join(" · ");
}
/** "API rights missing: gateways, services" with a tooltip that names the OPNsense permission to grant. */
export function ApiRightsBadge({ reads }: { reads: string[] }) {
const { t } = useLanguage();
const names = reads.map((r) => t(`gateway.rights.read.${r}`)).join(", ");
const how = reads.map((r) => `${t(`gateway.rights.read.${r}`)}: ${t(`gateway.rights.acl.${r}`)}`).join(" · ");
return (
<Tooltip content={`${t("gateway.rights.hint")} ${how}`} focusable>
<Badge tone="warning"><KeyRound size={12} aria-hidden="true" />{t("gateway.rights.badge", { reads: names })}</Badge>
</Tooltip>
);
}
/** Live gateway state of one firewall (host detail page). */
export function HostGateways({ host }: { host: Host }) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
// The alert switch is a notification setting: admins only (the server enforces it too).
const notAdmin = useNotAdmin();
const [gateways, setGateways] = useState<GatewayHealth[] | null>(null);
const [error, setError] = useState<string | null>(null);
// Two independent sources (stored sample / live read), merged, so neither answer can wipe the other.
const [storedMissing, setStoredMissing] = useState<string[]>([]);
const [liveDenied, setLiveDenied] = useState(false);
const missing = liveDenied && !storedMissing.includes("gateways") ? [...storedMissing, "gateways"] : storedMissing;
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(() => {
setError(null);
api.hostGateways(host.id).then(setGateways).catch((e) => { setGateways([]); setError(errorMessage(e, "gateway.loadFailed")); });
setLiveDenied(false);
// Stored data (newest sample): which side readings the API key may not take.
api.hostApiRights(host.id).then((r) => setStoredMissing(r.missing ?? [])).catch(() => setStoredMissing([]));
api.hostGateways(host.id).then(setGateways).catch((e) => {
setGateways([]);
if (e instanceof ApiError && e.code === "API_RIGHTS_MISSING") setLiveDenied(true);
else setError(errorMessage(e, "gateway.loadFailed"));
});
}, [host.id, errorMessage]);
async function toggleAlert(g: GatewayHealth) {
setBusy(g.name);
try {
const result = await api.setGatewayAlert(host.id, g.name, !g.alert_enabled);
setGateways((rows) => (rows ?? []).map((row) => (row.name === g.name ? { ...row, alert_enabled: result.alert_enabled } : row)));
toast.show(t(result.alert_enabled ? "gateway.alert.nowOn" : "gateway.alert.nowOff", { name: g.name }), "success");
} catch (e) {
toast.show(errorMessage(e, "gateway.alert.failed"), "danger");
} finally {
setBusy(null);
}
}
useEffect(() => { load(); }, [load]);
return (
@@ -48,9 +91,10 @@ export function HostGateways({ host }: { host: Host }) {
<Button type="button" variant="ghost" onClick={load} aria-label={t("common.refresh")}><RefreshCw size={14} />{t("common.refresh")}</Button>
</div>
<Card className="mb-8 p-5">
{missing.length > 0 && <div className="mb-3"><ApiRightsBadge reads={missing} /></div>}
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
{!error && gateways === null && <Skeleton className="h-12 w-full" />}
{!error && gateways !== null && gateways.length === 0 && <EmptyState icon={Globe} title={t("gateway.none")} />}
{!error && gateways !== null && gateways.length === 0 && !missing.includes("gateways") && <EmptyState icon={Globe} title={t("gateway.none")} />}
{gateways !== null && gateways.length > 0 && (
<ul className="divide-y divide-slate-100">
{gateways.map((g) => (
@@ -61,7 +105,19 @@ export function HostGateways({ host }: { host: Host }) {
</span>
<span className="flex flex-wrap items-center gap-3">
<span className="text-xs text-slate-500">{gatewayNumbers(g, t)}</span>
{!g.alert_enabled && <Badge tone="neutral">{t("gateway.alert.off")}</Badge>}
<GatewayStateBadge state={g.state} />
<Tooltip content={notAdmin ? t("gateway.alert.adminOnly") : t(g.alert_enabled ? "gateway.alert.tooltipOn" : "gateway.alert.tooltipOff")} focusable={notAdmin}>
<Button
type="button" variant="ghost" className="px-2 py-1"
aria-pressed={g.alert_enabled}
aria-label={t("gateway.alert.label", { name: g.name })}
disabled={notAdmin || busy === g.name}
onClick={() => toggleAlert(g)}
>
{g.alert_enabled ? <Bell size={14} aria-hidden="true" /> : <BellOff size={14} aria-hidden="true" />}
</Button>
</Tooltip>
</span>
</li>
))}
+1 -1
View File
@@ -80,7 +80,7 @@ export function HostVariables({ host }: { host: Host }) {
<Input value={values[name] ?? ""} onChange={(e) => setValues({ ...values, [name]: e.target.value })} placeholder={info?.missing ? t("variables.required") : ""} aria-label={name} />
{info && info.used_by.length > 0 && <p className="mt-1 text-xs text-slate-500">{t("variables.usedBy", { list: info.used_by.map((u) => u.label).slice(0, 4).join(", ") })}</p>}
</div>
<Button type="button" variant="ghost" onClick={() => { setValues({ ...values, [name]: "" }); setAdded(added.filter((n) => n !== name)); }} aria-label={t("rules.delete")}>
<Button type="button" variant="ghost" onClick={() => { setValues({ ...values, [name]: "" }); setAdded(added.filter((n) => n !== name)); }} aria-label={t("rules.delete")} title={t("rules.delete")}>
<Trash2 size={14} />
</Button>
</div>
+3 -2
View File
@@ -3,7 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { api } from "@/lib/api";
import { Language, translate, translateServerParams, translateServerText, TranslationParams } from "@/lib/i18n";
import { Language, translate, translateReasonParams, translateServerParams, translateServerText, TranslationParams } from "@/lib/i18n";
interface LanguageContextValue {
language: Language;
@@ -67,7 +67,8 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) {
const params = "params" in error && error.params && typeof error.params === "object"
? error.params as Record<string, string>
: undefined;
return translate(language, `errors.${error.code}`, translateServerParams(language, params) as TranslationParams | undefined);
const readable = translateReasonParams(language, error.code, translateServerParams(language, params));
return translate(language, `errors.${error.code}`, readable as TranslationParams | undefined);
}
return error instanceof Error ? error.message : translate(language, fallbackKey);
}, [language]);
+1 -1
View File
@@ -62,7 +62,7 @@ export function LoginInfoBanner() {
)}
{sessionExpires && <p className="text-xs opacity-80">{t("loginBanner.sessionUntil", { time: sessionExpires })}</p>}
</div>
<button onClick={() => setInfo(null)} aria-label={t("common.close")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
<button onClick={() => setInfo(null)} aria-label={t("common.close")} title={t("common.close")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
<X size={16} />
</button>
</div>
+2 -2
View File
@@ -99,10 +99,10 @@ export function NetworkObjectSection({ hostId, kind, title, reloadKey, readOnly,
</Button>
) : (
<>
<Button variant="ghost" aria-label={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid: row.uuid })}>
<Button variant="ghost" aria-label={t("network.rowEdit")} title={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid: row.uuid })}>
<Pencil size={14} />
</Button>
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid: row.uuid })}>
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} title={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid: row.uuid })}>
<Trash2 size={14} />
</Button>
</>
@@ -206,9 +206,9 @@ export function NotificationChannelsSection() {
{!c.enabled && <Badge tone="neutral">{t("settings.channels.disabled")}</Badge>}
</span>
<span className="flex flex-wrap items-center gap-1">
<Button type="button" variant="ghost" disabled={busy === c.id} onClick={() => test(c)}>{t("settings.channels.test")}</Button>
{!readOnly && (
<>
<Button type="button" variant="ghost" disabled={busy === c.id} onClick={() => test(c)}>{t("settings.channels.test")}</Button>
<Button type="button" variant="ghost" disabled={busy === c.id} onClick={() => toggle(c)}>{c.enabled ? t("settings.channels.disable") : t("settings.channels.enable")}</Button>
<Button type="button" variant="ghost" disabled={busy === c.id} onClick={() => setEditing(c)}>{t("settings.channels.editAction")}</Button>
<Button type="button" variant="ghost" disabled={busy === c.id} onClick={() => remove(c)}>{t("settings.channels.deleteAction")}</Button>
+5 -1
View File
@@ -38,7 +38,11 @@ export function OpenFirewallUi({ host }: { host: Host }) {
let reason: string | null = null;
if (!cfg) reason = null;
else if (!cfg.available) reason = t(`guiAccess.reason.${cfg.unavailable_reasons[0]}`);
else if (!cfg.available) {
// Plain-language explanation of the first missing prerequisite (with the next step); hint at the rest.
reason = t(`guiOpen.reason.unavailable.${cfg.unavailable_reasons[0]}`);
if (cfg.unavailable_reasons.length > 1) reason += ` ${t("guiOpen.reason.more")}`;
}
else if (!cfg.enabled) reason = t("guiOpen.reason.off");
else {
const r = hostReason(host);
+3 -3
View File
@@ -96,9 +96,9 @@ export function RolloutPlanEditor({ plan, groups, windows, onClose, onSaved }: {
</FieldGroup>
</div>
<div className="mb-0.5 flex gap-1">
<Button variant="ghost" aria-label={t("rollouts.plan.moveUp")} disabled={i === 0} onClick={() => moveWave(i, -1)}><ArrowUp size={14} /></Button>
<Button variant="ghost" aria-label={t("rollouts.plan.moveDown")} disabled={i === form.waves.length - 1} onClick={() => moveWave(i, 1)}><ArrowDown size={14} /></Button>
<Button variant="dangerSubtle" aria-label={t("rollouts.plan.removeWave")} disabled={form.waves.length === 1}
<Button variant="ghost" aria-label={t("rollouts.plan.moveUp")} title={t("rollouts.plan.moveUp")} disabled={i === 0} onClick={() => moveWave(i, -1)}><ArrowUp size={14} /></Button>
<Button variant="ghost" aria-label={t("rollouts.plan.moveDown")} title={t("rollouts.plan.moveDown")} disabled={i === form.waves.length - 1} onClick={() => moveWave(i, 1)}><ArrowDown size={14} /></Button>
<Button variant="dangerSubtle" aria-label={t("rollouts.plan.removeWave")} title={t("rollouts.plan.removeWave")} disabled={form.waves.length === 1}
onClick={() => set({ waves: form.waves.filter((_, j) => j !== i) })}><Trash2 size={14} /></Button>
</div>
</div>
+1 -1
View File
@@ -79,7 +79,7 @@ export function UpdateBanner() {
)}
</p>
</div>
<button onClick={dismiss} aria-label={t("updateBanner.dismiss")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
<button onClick={dismiss} aria-label={t("updateBanner.dismiss")} title={t("updateBanner.dismiss")} className="shrink-0 rounded p-0.5 hover:bg-black/5">
<X size={16} />
</button>
</div>
+1 -1
View File
@@ -62,7 +62,7 @@ export function Modal({ title, onClose, children }: ModalProps) {
<button
onClick={onClose}
className="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
aria-label={t("common.close")}
aria-label={t("common.close")} title={t("common.close")}
>
<X size={18} />
</button>
+1 -1
View File
@@ -77,7 +77,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
<p className="flex-1">{item.message}</p>
<button
onClick={() => dismiss(item.id)}
aria-label={t("common.close")}
aria-label={t("common.close")} title={t("common.close")}
className="shrink-0 opacity-70 hover:opacity-100"
>
<X size={16} />
+1 -1
View File
@@ -104,7 +104,7 @@ export function Tooltip({
id={id}
role="tooltip"
style={{ left: pos?.left ?? 0, top: pos?.top ?? 0, visibility: pos ? "visible" : "hidden" }}
className="pointer-events-none fixed z-[60] max-w-xs rounded-lg border border-[#3f3f46] bg-[#18181b] px-3 py-2 text-xs font-normal normal-case leading-snug tracking-normal text-[#f8fafc] shadow-lg"
className="pointer-events-none fixed z-[60] max-w-xs [overflow-wrap:anywhere] rounded-lg border border-[#3f3f46] bg-[#18181b] px-3 py-2 text-xs font-normal normal-case leading-snug tracking-normal text-[#f8fafc] shadow-lg"
>
{content}
</span>,
+39 -7
View File
@@ -599,6 +599,26 @@ export interface UnmanagedRule {
log: boolean;
adoptable: boolean;
reasons: string[];
/** Existing central rules equal to this one (resolved for the host); first = preferred. */
matches: LinkTarget[];
/** Same description, different content: shown as a hint with the differing fields, never linked. */
similar: LinkTarget[];
linkable: boolean;
}
export interface LinkDiff {
/** Model field or "variables" (a {{VARIABLE}} of the central object has no value on this host). */
field: string;
central: string;
live: string;
}
/** An existing central object a live object could be linked to (exact match) or resembles (diff). */
export interface LinkTarget {
id: string;
name: string;
targets: string[];
diff: LinkDiff[];
}
export type AdoptKind = "aliases" | "port-forward" | "one-to-one" | "outbound-nat";
@@ -616,10 +636,15 @@ export interface UnmanagedObject {
/** Settings OPNexus does not manage that stay untouched on the firewall. */
kept: string[];
info: Record<string, string>;
matches: LinkTarget[];
similar: LinkTarget[];
linkable: boolean;
/** What "adopt all with linking" does with it: link to its twin, adopt as new, or leave it. */
bulk_action: "link" | "new" | "skip" | null;
}
export interface AdoptAllResult {
adopted: { uuid: string; title: string; id: string }[];
adopted: { uuid: string; title: string; id: string; linked: boolean }[];
skipped: { uuid: string; title: string; reasons: string[]; reason_details: Record<string, string> }[];
}
@@ -1316,6 +1341,9 @@ export const api = {
deleteNotificationChannel: (id: string) => request<void>(`/notifications/channels/${id}`, { method: "DELETE" }),
testNotificationChannel: (id: string) => request<{ success: boolean }>(`/notifications/channels/${id}/test`, { method: "POST" }),
hostGateways: (id: string) => request<GatewayHealth[]>(`/hosts/${id}/gateways`),
hostApiRights: (id: string) => request<ApiRightsState>(`/hosts/${id}/gateways/api-rights`),
setGatewayAlert: (id: string, name: string, enabled: boolean) =>
request<{ name: string; alert_enabled: boolean }>(`/hosts/${id}/gateways/${encodeURIComponent(name)}/alert`, { method: "PUT", body: JSON.stringify({ enabled }) }),
testNotification: (channel: "webhook" | "email") => request<{ success: boolean }>("/notifications/test", { method: "POST", body: JSON.stringify({ channel }) }),
notificationProblems: (includeResolved = false) => request<NotificationProblem[]>(`/notifications/problems?include_resolved=${includeResolved}`),
carpEvents: (clusterName?: string) => request<CarpEvent[]>(`/cluster/carp-events${clusterName ? `?cluster_name=${encodeURIComponent(clusterName)}` : ""}`),
@@ -1473,9 +1501,9 @@ export const api = {
createRule: (data: RuleCreate) => request<Rule>("/rules", { method: "POST", body: JSON.stringify(data) }),
listUnmanagedRules: (hostId: string) => request<UnmanagedRule[]>(`/rules/unmanaged?host_id=${hostId}`),
listUnmanagedObjects: (kind: AdoptKind, hostId: string) => request<UnmanagedObject[]>(`/${kind}/unmanaged?host_id=${hostId}`),
adoptObject: (kind: AdoptKind, hostId: string, uuid: string) => request<{ id: string }>(`/${kind}/adopt`, { method: "POST", body: JSON.stringify({ host_id: hostId, uuid }) }),
adoptAllObjects: (kind: AdoptKind, hostId: string) => request<AdoptAllResult>(`/${kind}/adopt-all`, { method: "POST", body: JSON.stringify({ host_id: hostId }) }),
adoptRule: (hostId: string, ruleUuid: string) => request<Rule>("/rules/adopt", { method: "POST", body: JSON.stringify({ host_id: hostId, rule_uuid: ruleUuid }) }),
adoptObject: (kind: AdoptKind, hostId: string, uuid: string, linkTo?: string) => request<{ id: string }>(`/${kind}/adopt`, { method: "POST", body: JSON.stringify({ host_id: hostId, uuid, link_to: linkTo ?? null }) }),
adoptAllObjects: (kind: AdoptKind, hostId: string, linkExisting = true) => request<AdoptAllResult>(`/${kind}/adopt-all`, { method: "POST", body: JSON.stringify({ host_id: hostId, link_existing: linkExisting }) }),
adoptRule: (hostId: string, ruleUuid: string, linkTo?: string) => request<Rule>("/rules/adopt", { method: "POST", body: JSON.stringify({ host_id: hostId, rule_uuid: ruleUuid, link_to: linkTo ?? null }) }),
updateRule: (id: string, data: RuleCreate, expectedUpdatedAt: string) => request<Rule>(`/rules/${id}`, { method: "PUT", body: JSON.stringify({ ...data, expected_updated_at: expectedUpdatedAt }) }),
previewRule: (id: string) => request<RulePreview>(`/rules/${id}/preview`),
ruleRevisions: (id: string) => request<RuleRevision[]>(`/rules/${id}/revisions`),
@@ -1763,8 +1791,10 @@ export interface AuditEvent { id: string; created_at: string; correlation_id: st
export interface AuditPage { total: number; offset: number; limit: number; items: AuditEvent[]; }
export type GatewayState = "online" | "degraded" | "offline" | "forced_down" | "unknown";
export interface GatewayHealth { name: string; address: string | null; status: string; state: GatewayState; loss: number | null; delay: number | null; stddev: number | null; monitor: string | null; }
export interface DashboardHost { id: string; name: string; status: HostStatus; carp_status: string | null; cluster_name: string | null; gateways: GatewayHealth[] | null; }
export interface GatewayHealth { name: string; address: string | null; status: string; state: GatewayState; loss: number | null; delay: number | null; stddev: number | null; monitor: string | null; alert_enabled: boolean; }
/** Side readings the newest fresh sample could not take because the API key lacks the right; null = unknown. */
export interface ApiRightsState { missing: string[] | null; }
export interface DashboardHost { id: string; name: string; status: HostStatus; carp_status: string | null; cluster_name: string | null; gateways: GatewayHealth[] | null; api_rights_missing: string[] | null; }
export interface PendingAction { kind: string; status: string; entity_name: string; host_name: string; message: string | null; created_at: string; link: string; }
export interface ActivityItem { id: string; created_at: string; actor: string; kind: string; action: string; status: string; entity_name: string | null; host_name: string | null; message: string | null; }
export interface DashboardSummary { hosts: DashboardHost[]; pending_actions: PendingAction[]; recent_activity: ActivityItem[]; }
@@ -1906,6 +1936,7 @@ export interface NotificationSettings {
resource_threshold_enabled: boolean;
service_down_enabled: boolean;
rollout_problems_enabled: boolean;
api_rights_enabled: boolean;
gateway_loss_threshold_pct: number;
gateway_delay_threshold_ms: number;
disk_threshold_pct: number;
@@ -1963,6 +1994,7 @@ export interface NotificationSettingsUpdate {
resource_threshold_enabled?: boolean;
service_down_enabled?: boolean;
rollout_problems_enabled?: boolean;
api_rights_enabled?: boolean;
gateway_loss_threshold_pct?: number;
gateway_delay_threshold_ms?: number;
disk_threshold_pct?: number;
@@ -1999,7 +2031,7 @@ export type ProblemType =
| "backup_stale"
| "backup_offsite_failed"
| "compliance_finding"
| "gateway_down" | "resource_threshold" | "service_down"
| "gateway_down" | "resource_threshold" | "service_down" | "api_rights_missing"
| "rollout_paused" | "rollout_start_failed";
export interface CarpEvent {
+144 -44
View File
@@ -71,10 +71,11 @@
"gatewayDown": "Gateway gestört (offline, Paketverlust oder Latenz über der Schwelle)",
"resourceThreshold": "Ressourcen-Schwelle überschritten (Festplatte, Speicher, Swap, CPU-Last; standardmäßig aus)",
"serviceDown": "Überwachter Dienst ausgefallen (standardmäßig aus)",
"rolloutProblems": "Rollout angehalten oder nicht automatisch gestartet"
"rolloutProblems": "Rollout angehalten oder nicht automatisch gestartet",
"apiRights": "API-Rechte fehlen (Gateway- oder Dienststatus nicht lesbar)"
},
"pollInterval": "Prüfintervall (Sekunden)",
"pollHint": "Mindestens 60s, um die Firewalls nicht unnötig zu belasten.",
"pollHint": "Mindestens 60 Sekunden, um die Firewalls nicht unnötig zu belasten.",
"monitoring": "Schwellen und Entprellung",
"monitoringHint": "Alarme für Gateways, Ressourcen und Dienste stützen sich auf die Messungen, die OPNexus jede Minute aufzeichnet. Ein Alarm entsteht erst, wenn die letzten Messungen alle schlecht sind, und endet erst, wenn die letzten Messungen alle wieder gut sind – Einzelspitzen und flackernde Verbindungen lösen keine Meldungen aus. 0 schaltet eine einzelne Schwelle ab.",
"limit": {
@@ -370,8 +371,11 @@
"alert": {
"gateway_down": "Gateway",
"resource_threshold": "Ressource",
"service_down": "Dienst"
}
"service_down": "Dienst",
"api_rights_missing": "API-Rechte"
},
"moreGateways": "+ {count} weitere Gateways (auf der Seite der Firewall)",
"apiRightsLine": "{name}: API-Recht fehlt ({read})"
},
"hosts": {
"add": "Host hinzufügen",
@@ -511,6 +515,10 @@
},
"adopt": {
"button": "Von Firewall übernehmen",
"noHosts": {
"title": "Keine aktive Firewall",
"description": "Es gibt keine aktive Firewall, von der etwas übernommen werden könnte. Lege unter „Hosts“ eine Firewall an oder aktiviere eine vorhandene."
},
"title": {
"aliases": "Aliase von der Firewall übernehmen",
"portForward": "Port-Forwards von der Firewall übernehmen",
@@ -524,6 +532,55 @@
"all": "Alle übernehmbaren übernehmen ({count})",
"adopted": "„{name}“ übernommen.",
"allDone": "{count} übernommen, {skipped} übersprungen.",
"linked": "„{name}“ mit vorhandenem Objekt verknüpft.",
"allDoneLinked": "{count} übernommen (davon {linked} verknüpft), {skipped} übersprungen.",
"confirm": {
"title": "Alle übernehmbaren übernehmen",
"summary": "{link} verknüpfen, {created} neu übernehmen, {left} nicht übernehmbar.",
"note": "Verknüpft wird nur, was exakt einem vorhandenen zentralen Objekt entspricht. Auf der Firewall wird nichts geändert.",
"go": "Bestätigen und übernehmen"
},
"link": {
"badge": "Gibt es schon zentral",
"found": "Entspricht dem vorhandenen Objekt „{name}“ (Firewalls: {hosts}). Verknüpfen trägt diese Firewall dort als weiteres Ziel ein, statt ein zweites Objekt anzulegen.",
"choose": "Verknüpfen mit",
"action": "Verknüpfen",
"linkTip": "Trägt diese Firewall als weiteres Ziel des vorhandenen Objekts ein. Auf der Firewall wird nichts geändert; spätere Änderungen rollst du für alle Firewalls gemeinsam aus.",
"adoptNew": "Als eigenes Objekt übernehmen",
"newTip": "Legt ein zweites, unabhängiges Objekt an. Nur sinnvoll, wenn diese Firewall dauerhaft eigene Werte haben soll.",
"newBlockedTip": "Ein Objekt mit diesem Namen gibt es schon, deshalb geht nur Verknüpfen. Willst du es getrennt führen, benenne es zuerst auf der Firewall um.",
"similar": "Ähnlich: „{name}“ (Firewalls: {hosts}) unterscheidet sich und wird deshalb nicht verknüpft.",
"similarNext": "Gleiche das Objekt auf der Firewall oder das zentrale Objekt an, dann lässt es sich verknüpfen. Oder übernimm es getrennt.",
"diffValues": "zentral „{central}“, auf der Firewall „{live}“",
"variableMissing": "Für diese Firewall fehlt der Wert der Variable {names}. Trage ihn bei den Host-Variablen ein.",
"yes": "Ja",
"no": "Nein",
"fields": {
"name": "Name",
"type": "Typ",
"content": "Inhalt",
"description": "Beschreibung",
"enabled": "Aktiv",
"interface": "Schnittstelle",
"direction": "Richtung",
"ipprotocol": "IP-Version",
"protocol": "Protokoll",
"action": "Aktion",
"source_net": "Quelle",
"source_port": "Quellport",
"destination_net": "Ziel",
"destination_port": "Zielport",
"log": "Protokollieren",
"target": "Ziel-IP",
"local_port": "Ziel-Port",
"target_port": "Ziel-Port (NAT)",
"natreflection": "NAT-Reflection",
"external": "Externe Adresse",
"source_not": "Quelle invertiert",
"destination_not": "Ziel invertiert",
"variables": "Variablen"
}
},
"failed": "Übernahme fehlgeschlagen",
"loadFailed": "Objekte konnten nicht geladen werden.",
"notPossible": "Nicht übernehmbar",
@@ -550,6 +607,7 @@
"unsupported_type": "Typ „{detail}“ unterstützt OPNexus nicht",
"name_invalid": "Der Name enthält Zeichen, die OPNexus nicht zulässt (nur Buchstaben, Ziffern, _)",
"name_exists": "In OPNexus gibt es schon einen Alias mit diesem Namen",
"twin_busy": "Das gleiche zentrale Objekt wartet noch auf eine Bestätigung. Danach erneut versuchen",
"content_too_large": "Inhalt zu groß (über 8000 Zeichen)",
"proto_set": "Hat eine IPv4/IPv6-Einschränkung, die OPNexus nicht abbildet",
"value_too_long": "Ein Wert ist länger, als OPNexus speichert",
@@ -729,6 +787,8 @@
"GUI_ACCESS_DISABLED": "Der GUI-Zugriff ist ausgeschaltet. Ein Admin kann ihn in den Einstellungen einschalten.",
"GUI_HOST_NOT_ELIGIBLE": "Diese Firewall lässt sich nicht über OPNexus öffnen: Sie muss aktiv sein, per https:// erreichbar und mit geprüftem oder gepinntem Zertifikat.",
"GUI_NO_FREE_SLOT": "Alle {slots} Plätze für Firewall-Oberflächen sind belegt. Schließe eine geöffnete Oberfläche oder warte, bis sie abläuft.",
"GUI_SLOT_COOLING_DOWN": "Ein Platz wurde gerade erst frei, wird aber für eine andere Firewall erst nach {minutes} Min. neu vergeben (Schutz, falls dort noch ein Fenster offen ist). Öffne dieselbe Firewall erneut oder warte kurz.",
"GUI_TOO_MANY_SESSIONS": "Es laufen schon zu viele Firewall-Oberflächen gleichzeitig (höchstens {max}). Warte, bis eine endet, oder beende eine nicht mehr benötigte Sitzung.",
"TLS_PROBE_FAILED": "Das Zertifikat der Firewall konnte nicht abgerufen werden",
"TLS_PEM_INVALID": "Kein gültiges PEM-Zertifikat",
"TLS_PIN_MISMATCH": "Die Firewall präsentiert ein anderes Zertifikat als das festgesetzte",
@@ -740,6 +800,10 @@
"RULE_NOT_ADOPTABLE": "Diese Regel kann nicht übernommen werden ({reasons})",
"OBJECT_NOT_ADOPTABLE": "Dieses Objekt kann nicht übernommen werden ({reasons})",
"ADOPT_OBJECT_NOT_FOUND": "Das Objekt wurde auf der Firewall nicht gefunden. Liste neu laden.",
"ADOPT_LINK_TARGET_NOT_FOUND": "Das zentrale Objekt gibt es nicht mehr. Liste neu laden.",
"ADOPT_LINK_TARGET_CHANGED": "Das zentrale Objekt und die Firewall unterscheiden sich inzwischen ({fields}). Liste neu laden und prüfen.",
"ADOPT_LINK_HOST_HAS_OBJECT": "Diese Firewall verwaltet dieses Objekt schon unter einer anderen UUID. Liste neu laden.",
"ADOPT_LINK_BUSY": "Das Objekt hat eine offene Bestätigung oder einen offenen Rollback. Erst bestätigen oder klären, dann verknüpfen.",
"OUTBOUND_MODE_BLOCKS_ADOPT": "{host}: Im Outbound-NAT-Modus „{mode}“ zeigt OPNsense keine manuellen Regeln an. Modus in OPNsense selbst auf Hybrid/Manuell umstellen, dann lassen sie sich übernehmen. OPNexus ändert diesen Modus nicht.",
"NETWORK_ACK_REQUIRED": "Diese Änderung kann die Verbindung zur Firewall trennen und muss ausdrücklich bestätigt werden ({reasons})",
"NETWORK_APPLY_UNCERTAIN": "Die Änderung wurde geschrieben, das Anwenden auf „{host}“ ist aber fehlgeschlagen oder unklar. Stand auf der Firewall prüfen; Backup: {backup_id}",
@@ -800,7 +864,11 @@
"ANALYSE_FAMILIEN_MISCHUNG": "Quelle und Ziel müssen beide IPv4 oder beide IPv6 sein.",
"ANALYSE_INTERFACE_UNBEKANNT": "Interface „{interface}“ gibt es auf dieser Firewall nicht. Vorhanden: {known}.",
"ANALYSE_PARAMETER_UNGUELTIG": "Ungültiger Wert für {field}: {value}.",
"ANALYSE_FIREWALL_NICHT_LESBAR": "Die Regeln dieser Firewall konnten nicht gelesen werden. Ist sie erreichbar?"
"ANALYSE_FIREWALL_NICHT_LESBAR": "Die Regeln dieser Firewall konnten nicht gelesen werden. Ist sie erreichbar?",
"API_RIGHTS_MISSING": "Dem API-Benutzer fehlt das Recht, den Gateway-Status zu lesen",
"GATEWAY_NAME_INVALID": "Ungültiger Gateway-Name",
"GATEWAY_UNKNOWN": "Dieses Gateway kennt OPNexus auf der Firewall nicht (Name prüfen, Groß-/Kleinschreibung)",
"GATEWAY_MUTE_LIMIT": "Es können höchstens {limit} Gateways stummgeschaltet werden"
},
"workflow": {
"attention": "Fehler prüfen",
@@ -810,7 +878,7 @@
"disabled": "Ziel-Hosts deaktiviert",
"draft": "Änderungen ausstehend",
"synced": "Ausgerollt",
"tooltip": "Stand der von OPNexus gespeicherten Deployments; kein Live-Abgleich"
"tooltip": "Zeigt, was OPNexus zuletzt ausgerollt hat. Die Firewall wird dafür nicht live abgefragt."
},
"certStatus": {
"expired": "Abgelaufen",
@@ -885,7 +953,7 @@
"status": "Status",
"running": "läuft",
"stopped": "gestoppt",
"locked": "Von OPNsense gesperrt",
"locked": "Dieser Dienst ist in OPNsense gesperrt und lässt sich hier nicht steuern.",
"startTitle": "Starten",
"stopTitle": "Stoppen",
"restartTitle": "Neustarten"
@@ -1431,9 +1499,9 @@
"reasserting": "Reassert läuft…",
"reassert": "Reihenfolge neu herstellen",
"preRules": "Pre-Regeln",
"preHint": "Ausgewertet, bevor die Post-Regeln greifen. Über mehrere Ebenen: root-most Gruppe zuerst.",
"preHint": "Wird vor den Post-Regeln ausgewertet. Bei mehreren Ebenen zuerst die oberste Gruppe.",
"postRules": "Post-Regeln",
"postHint": "Ausgewertet nach den Pre-Regeln. Über mehrere Ebenen: leaf-most (spezifischste) Gruppe zuerst.",
"postHint": "Wird nach den Pre-Regeln ausgewertet. Bei mehreren Ebenen zuerst die unterste (spezifischste) Gruppe.",
"editGroup": "Gruppe bearbeiten",
"parentGroup": "Übergeordnete Gruppe (optional)",
"rootGroup": "(keine — Wurzelgruppe)",
@@ -1503,7 +1571,7 @@
"registration": "Registrierung: {state}",
"registrationOk": "erfolgreich",
"registrationFailed": "fehlgeschlagen",
"pushFirst": "Zuerst pushen",
"pushFirst": "Noch nicht auf der Firewall angelegt: Übertrage es zuerst mit „Push“.",
"register": "Registrieren",
"requests": "ACME-Zertifikatsanfragen",
"newRequest": "Neue Anfrage",
@@ -1540,7 +1608,7 @@
"deleted": "Tunnel „{name}“ gelöscht",
"title": "IPsec-VPN (Site-to-Site)",
"new": "Neuer Tunnel",
"intro": "Site-to-Site-Tunnel mit Pre-Shared-Key-Authentifizierung, zentral gepflegt und auf Ziel-Hosts oder Gerätegruppen ausgerollt — analog zu den Firewall-Regeln, aber ohne deren automatisches Rollback-Sicherheitsnetz: ein fehlgeschlagener Dienst-Reload kann genau die Verbindung kappen, über die OPNexus den Host erreicht. v1 unterstützt nur PSK-Auth, ein Phase-2 (Child) pro Tunnel und einfache 1:1-Netz-zu-Netz-Tunnel — kein OpenVPN/WireGuard, keine Zertifikate/EAP, keine Pools.",
"intro": "Site-to-Site-Tunnel mit Pre-Shared Key, zentral gepflegt und auf Firewalls oder Gerätegruppen ausgerollt. Anders als bei Firewall-Regeln gibt es hier kein automatisches Rollback: Ein fehlgeschlagener Dienst-Reload kann die Verbindung kappen, über die OPNexus die Firewall erreicht. Unterstützt werden ein Phase-2 pro Tunnel und einfache Netz-zu-Netz-Tunnel (keine Zertifikate/EAP, keine Pools).",
"showAll": "Alle Tunnel anzeigen",
"none": "Noch keine IPsec-Tunnel angelegt.",
"createFirst": "Ersten Tunnel anlegen",
@@ -1582,7 +1650,7 @@
"deleted": "Instanz „{name}“ gelöscht",
"title": "OpenVPN-Instanzen",
"new": "Neue Instanz",
"intro": "OpenVPN-Instanzen (Server- oder Client-Rolle), zentral gepflegt und auf Ziel-Hosts oder Gerätegruppen ausgerollt — analog zum IPsec-Feature, aber ohne dessen automatisches Rollback-Sicherheitsnetz: ein fehlgeschlagener Dienst-Reload kann genau die Verbindung kappen, über die OPNexus den Host erreicht. v1 benötigt ein bereits auf jedem Ziel-Host vorhandenes Zertifikat/CA — auswählbar per Picker aus dem jeweiligen Host-Trust-Store, da eine Zertifikat-UUID host-lokal ist —, unterstützt optionales TLS-Hardening (tls-auth/tls-crypt) über ein Static-Key, aber keine zertifikatsfreie „Shared-Key“-Instanz (im modernen OPNsense-Modell nicht vorgesehen).",
"intro": "OpenVPN-Instanzen (Server oder Client), zentral gepflegt und auf Firewalls oder Gerätegruppen ausgerollt. Wie bei IPsec gibt es kein automatisches Rollback: Ein fehlgeschlagener Dienst-Reload kann die Verbindung kappen, über die OPNexus die Firewall erreicht. Auf jeder Ziel-Firewall muss bereits ein Zertifikat samt CA vorhanden sein (Auswahl aus deren Zertifikatsspeicher); optional gibt es TLS-Absicherung (tls-auth/tls-crypt) per Static Key, aber keine Instanzen mit „Shared Key“ ohne Zertifikat.",
"showAll": "Alle Instanzen anzeigen",
"none": "Noch keine OpenVPN-Instanzen angelegt.",
"createFirst": "Erste Instanz anlegen",
@@ -1622,7 +1690,7 @@
"created": "Tunnel „{name}“ angelegt",
"deleteConfirm": "Diesen WireGuard-Tunnel wirklich löschen? Er wird auch von allen Ziel-Hosts entfernt (Peer, dann Server) und der WireGuard-Dienst neu geladen.",
"title": "WireGuard-Tunnel",
"intro": "WireGuard-Interconnects (ein Server-Interface + genau ein Peer pro Tunnel), zentral gepflegt und auf Ziel-Hosts oder Gerätegruppen ausgerollt — analog zu IPsec/OpenVPN, ohne deren automatisches Rollback-Sicherheitsnetz: ein fehlgeschlagener Dienst-Reload kann genau die Verbindung kappen, über die OPNexus den Host erreicht. v1 verlangt Server-Privatschlüssel UND -Public-Key sowie den Peer-Public-Key manuell vom Betreiber (kein Schlüsselpaar-Generator), unterstützt optional WireGuards eigenen Pre-Shared-Key pro Peer, und bildet genau eine Server↔Peer-Verbindung ab (kein Multi-Peer-Hub).",
"intro": "WireGuard-Verbindungen (ein Server-Interface und genau ein Peer pro Tunnel), zentral gepflegt und auf Firewalls oder Gerätegruppen ausgerollt. Wie bei IPsec gibt es kein automatisches Rollback: Ein fehlgeschlagener Dienst-Reload kann die Verbindung kappen, über die OPNexus die Firewall erreicht. Die Schlüssel erzeugst und trägst du selbst ein; ein Pre-Shared Key pro Peer ist optional.",
"none": "Noch keine WireGuard-Tunnel angelegt.",
"noPrivateKey": "Kein Privatschlüssel hinterlegt",
"pskMissing": "PSK aktiviert, aber nicht hinterlegt",
@@ -1717,7 +1785,7 @@
},
"overlay": {
"title": "Management-Tunnel",
"intro": "Lässt OPNexus Firewalls verwalten, die keine öffentliche Adresse haben (hinter NAT, CGNAT, Mobilfunk). Jede Firewall wählt sich per WireGuard NACH AUSSEN bei einem Hub auf diesem Server ein; OPNexus erreicht ihre API dann durch den Tunnel. An der Firewall muss nichts aus dem Internet erreichbar sein. Braucht den Hub-Container (COMPOSE_PROFILES=overlay) und einen aus dem Internet erreichbaren UDP-Port.",
"intro": "Lässt OPNexus Firewalls verwalten, die keine öffentliche Adresse haben (hinter NAT, CGNAT, Mobilfunk). Jede Firewall wählt sich per WireGuard nach außen bei einem Hub auf diesem Server ein; OPNexus erreicht ihre API dann durch den Tunnel. An der Firewall muss nichts aus dem Internet erreichbar sein. Auf dem Server nötig: der Hub-Container (COMPOSE_PROFILES=overlay) und ein aus dem Internet erreichbarer UDP-Port.",
"loadFailed": "Der Management-Tunnel konnte nicht geladen werden",
"saved": "Gespeichert",
"enable": "Management-Tunnel einschalten",
@@ -2013,7 +2081,7 @@
"colCreated": "Angelegt",
"colLastLogin": "Letzte Anmeldung",
"you": "(du)",
"cannotDeleteSelf": "Der eigene Account kann nicht gelöscht werden",
"cannotDeleteSelf": "Du kannst dein eigenes Konto nicht löschen – das muss ein anderer Admin tun.",
"created": "Benutzer angelegt",
"updated": "Benutzer „{name}“ aktualisiert",
"createFailed": "Benutzer konnte nicht angelegt werden",
@@ -2242,19 +2310,19 @@
},
"noUpdate": {
"title": "Kein installierbares Update",
"text": "Die letzte Prüfung meldet kein stabiles Paketupdate."
"text": "Die letzte Prüfung meldet kein stabiles Paketupdate. Starte bei Bedarf eine neue Prüfung."
},
"noMajor": {
"title": "Kein installierbares Major-Upgrade",
"text": "Die letzte Prüfung meldet kein verfügbares Major-Upgrade."
"text": "Die letzte Prüfung meldet kein verfügbares Major-Upgrade. Starte bei Bedarf eine neue Prüfung."
},
"pending": {
"title": "Wartung noch offen",
"text": "Eine frühere Wartung muss nach der technischen Prüfung zuerst abgeschlossen werden."
"text": "Eine frühere Wartung muss zuerst abgeschlossen werden: Prüfe das Ergebnis und schließe sie auf der Host-Seite ab."
},
"change": {
"title": "Änderung noch offen",
"text": "Ein Deployment oder Rollback im Cluster ist noch nicht abgeschlossen."
"text": "Ein Deployment oder Rollback im Cluster läuft noch. Warte, bis es abgeschlossen ist."
},
"locked": "Update gesperrt"
},
@@ -2288,7 +2356,7 @@
"intro": "Beim Öffnen wird automatisch auf allen geeigneten Hosts nach Updates gesucht. Major-Upgrades und HA-Rollenwechsel bleiben bewusste manuelle Schritte.",
"checkAgain": "Erneut nach Updates suchen",
"refreshView": "Anzeige aktualisieren",
"readOnlyNote": "Nur-Lese-Zugriff: Status, Paketänderungen und Protokolle sind sichtbar; Prüfungen und Updates sind gesperrt.",
"readOnlyNote": "Mit deiner Rolle (Nur-Lesen) kannst du Status, Paketänderungen und Protokolle ansehen, aber keine Prüfungen oder Updates starten.",
"sumHosts": "Verwaltete Hosts",
"sumStable": "Stabile Updates",
"sumMajor": "Major-Upgrades",
@@ -2355,10 +2423,10 @@
"tipCheckAll": "Fragt alle geeigneten Firewalls nach neuen Updates. Es wird nichts installiert.",
"tipRefreshView": "Lädt den zuletzt bekannten Status aller Hosts neu, ohne die Firewalls zu fragen.",
"tipCheck": "Fragt diese Firewall nach verfügbaren Updates. Es wird nichts installiert.",
"tipPrepare": "Zeigt zuerst eine Vorschau. Installiert wird erst nach Ihrer ausdrücklichen Bestätigung.",
"tipPrepare": "Zeigt zuerst eine Vorschau. Installiert wird erst, nachdem du ausdrücklich bestätigst.",
"tipPrepareMajor": "Wechsel auf eine neue Hauptversion: dauert länger und ist riskanter. Zuerst folgt eine Vorschau.",
"tipView": "Status dieses Hosts neu laden",
"tipFinish": "Prüfen Sie das Ergebnis auf der Firewall und dokumentieren Sie den Abschluss.",
"tipFinish": "Prüfe das Ergebnis auf der Firewall und dokumentiere den Abschluss.",
"tipCancel": "Schließt einen hängenden Vorgang, ohne einen Erfolg zu bestätigen. Nur wenn der erwartete Zustand nie eingetreten ist.",
"tipPackages": "Pakete, die beim nächsten Update geändert werden",
"tipLog": "Zeigt das Protokoll der letzten Update-Aktion auf der Firewall",
@@ -2368,15 +2436,15 @@
"tipRebootRequired": "Nach dem Update ist ein Neustart der Firewall nötig. Dabei ist sie kurz nicht erreichbar.",
"tipCarpWarn": "CARP-Wartungsmodus",
"majorWarnShort": "Wechsel auf eine neue Hauptversion",
"majorWarnTip": "Länger dauernd und riskanter als ein Paketupdate, ggf. mit Verhaltensänderungen an Diensten und Konfiguration. Halten Sie ein frisches Backup und Konsolenzugang bereit.",
"majorWarnTip": "Dauert länger und ist riskanter als ein Paketupdate; Dienste und Konfiguration können sich ändern. Halte ein frisches Backup und Konsolenzugang bereit.",
"downtimeShort": "Der Host ist während der Installation zeitweise nicht erreichbar",
"downtimeTip": "Verbindungen können abbrechen. Es gibt keinen automatischen Rollback.",
"tipConfirmName": "Die Eingabe des Hostnamens verhindert, dass versehentlich der falsche Host aktualisiert wird.",
"confirmRunDisabledName": "Hostnamen exakt eingeben, um fortzufahren",
"confirmRunDisabledAck": "Bestätigen Sie zuerst das Wartungsfenster",
"confirmRunDisabledName": "Gib den Hostnamen exakt ein, um fortzufahren.",
"confirmRunDisabledAck": "Bestätige zuerst das Wartungsfenster (Häkchen im Dialog).",
"cancelWarnShort": "Bestätigt keinen erfolgreichen Abschluss",
"cancelWarnTip": "Der Vorgang wird nur geschlossen, damit Host oder Cluster nicht gesperrt bleiben. Nur nutzen, wenn der erwartete Zustand nachweislich nie eingetreten ist (z. B. hängender CARP-Rollenwechsel).",
"finishNoteTip": "Diese Bestätigung dokumentiert Ihre Prüfung und löst keine Firewall-Aktion aus.",
"finishNoteTip": "Diese Bestätigung dokumentiert deine Prüfung und löst keine Aktion auf der Firewall aus.",
"reasonMinHint": "Mindestens 3 Zeichen",
"validUntilShort": "Vorschau gültig bis {time}"
},
@@ -2403,8 +2471,8 @@
"skipVerifyHelp": "Die Verbindung bleibt verschlüsselt, aber OPNexus kann nicht erkennen, ob wirklich die Firewall antwortet. Nur für Tests.",
"probeFailed": "Zertifikat konnte nicht abgerufen werden",
"pinnedTip": "Zertifikat festgesetzt · SHA-256 {fp}…",
"systemTip": "Standard-Zertifikatsprüfung. Für selbstsignierte Firewalls das Zertifikat festsetzen.",
"insecureTip": "Zertifikat wird nicht geprüft. Im Host bearbeiten festsetzen."
"systemTip": "Das Zertifikat wird mit den Standard-Zertifizierungsstellen geprüft. Bei einem selbstsignierten Zertifikat: unter Bearbeiten festsetzen.",
"insecureTip": "Das Zertifikat wird nicht geprüft. Setze es unter Bearbeiten fest, um die Verbindung abzusichern."
},
"twofa": {
"title": "Zwei-Faktor-Anmeldung",
@@ -2423,7 +2491,7 @@
"enterCode": "6-stelliger Code",
"activate": "Aktivieren",
"codesTitle": "Wiederherstellungscodes – jetzt sichern",
"codesHelp": "Jeder Code funktioniert genau einmal, falls du dein Gerät verlierst. Sie werden nur jetzt angezeigt.",
"codesHelp": "Jeder Code funktioniert genau einmal, falls du dein Gerät verlierst. Die Codes werden nur jetzt angezeigt.",
"copy": "Kopieren",
"download": "Als Datei speichern",
"savedAck": "Ich habe die Codes an einem sicheren Ort gespeichert.",
@@ -2892,6 +2960,32 @@
"offline": "Offline",
"forced_down": "Abgeschaltet",
"unknown": "Unbekannt"
},
"alert": {
"label": "Alarm für Gateway {name}",
"off": "Alarm aus",
"tooltipOn": "Alarm für dieses Gateway: an. Klicken, um ihn abzuschalten (z. B. ruhendes Zweit-WAN).",
"tooltipOff": "Alarm für dieses Gateway: aus. Klicken, um ihn wieder einzuschalten.",
"adminOnly": "Nur Administratoren können den Alarm für ein Gateway ändern.",
"nowOn": "Alarm für {name} eingeschaltet",
"nowOff": "Alarm für {name} ausgeschaltet",
"failed": "Alarm konnte nicht geändert werden"
},
"rights": {
"badge": "API-Rechte fehlen: {reads}",
"hint": "Der API-Benutzer von OPNexus darf diese Messwerte nicht lesen, die Alarme dazu können nicht auslösen. In OPNsense beim Benutzer oder der Gruppe unter System ▸ Zugang ergänzen:",
"read": {
"gateways": "Gateways",
"services": "Dienste",
"swap": "Swap",
"cpu": "CPU"
},
"acl": {
"gateways": "System: Gateways",
"services": "Status: Services",
"swap": "Lobby: Dashboard",
"cpu": "Lobby: Dashboard"
}
}
},
"rollouts": {
@@ -3288,7 +3382,7 @@
"egressIface": "Ausgangs-Interface",
"run": "Analysieren",
"refresh": "Neu von der Firewall lesen",
"refreshHint": "Ohne dies werden die Regeln bis zu 60 Sekunden zwischengespeichert.",
"refreshHint": "Liest die Regeln frisch von der Firewall, statt bis zu 60 Sekunden alte Daten aus dem Zwischenspeicher zu nutzen.",
"missing": "Quell- und Ziel-IP sind Pflicht.",
"noHostsSelected": "Wähle mindestens einen Host.",
"noGroupSelected": "Wähle eine Gerätegruppe."
@@ -3307,7 +3401,7 @@
"decisive": "Entscheidende Regel",
"decisiveIfKnown": "Wahrscheinlichste Regel, falls die offenen Punkte nicht greifen",
"noRule": "Keine Regel trifft",
"noRuleHint": "Kein Regeleintrag passt; pf lässt das Paket dann durch.",
"noRuleHint": "Kein Regeleintrag passt; der Paketfilter (pf) lässt das Paket dann durch.",
"unresolved": "Kein Interface bestimmbar",
"unresolvedHint": "Die Quell-IP gehört zu keinem Interface-Netz. Wähle das Eingangs-Interface von Hand.",
"undecided": "Nicht sicher bestimmbar",
@@ -3327,7 +3421,7 @@
"ruleGateway": "Gateway",
"ruleSchedule": "Zeitplan",
"quick": "quick",
"quickHint": "quick: die erste Übereinstimmung entscheidet sofort.",
"quickHint": "quick: Diese Regel entscheidet sofort, weitere Regeln werden nicht mehr geprüft.",
"pfNumber": "pf-Regel @{number}",
"automatic": "automatisch",
"legacy": "älteres Format",
@@ -3363,7 +3457,7 @@
},
"trace": {
"title": "Warum?",
"hint": "Alle Regeln in der Reihenfolge, in der pf sie prüft. Der letzte Treffer gewinnt, außer eine quick-Regel greift vorher.",
"hint": "Alle Regeln in der Reihenfolge, in der der Paketfilter (pf) sie prüft. Der letzte Treffer gewinnt, außer eine quick-Regel greift vorher.",
"leg": "Prüfprotokoll {direction} auf {interface}",
"dirIn": "ein",
"dirOut": "aus",
@@ -3434,7 +3528,7 @@
"RATE_LIMIT": "Ratenbegrenzung",
"RATE_LIMIT_hint": "Die Regel begrenzt Verbindungen; bei Überschreitung kann sie anders wirken.",
"PF_DEFAULT_PASS": "Standard: durchlassen",
"PF_DEFAULT_PASS_hint": "Keine Regel trifft; pf lässt das Paket dann durch.",
"PF_DEFAULT_PASS_hint": "Keine Regel trifft; der Paketfilter (pf) lässt das Paket dann durch.",
"QUICK_UNKNOWN": "quick unklar",
"QUICK_UNKNOWN_hint": "Bei einer Regel ist unklar, ob sie sofort entscheidet.",
"GROUPS_UNAVAILABLE": "Interface-Gruppen nicht lesbar",
@@ -3492,14 +3586,14 @@
"guiAccess": {
"title": "Firewall-Oberfläche über OPNexus",
"intro": "Öffnet die Weboberfläche einer verwalteten Firewall in einem eigenen Browser-Tab über OPNexus, auch wenn die Firewall nur über den Management-Tunnel erreichbar ist. Nur Admins; die Anmeldung an der Firewall erfolgt wie gewohnt dort. Das Ticket gilt einmalig 30 Sekunden, jede Sitzung hat Zeitlimits und wird im Audit protokolliert. Einzelheiten: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
"defaultOff": "Standardmäßig aus. Zum Einrichten braucht der Server das Compose-Profil „gui“, OPNEXUS_GUI_SITES und GUI_GATEWAY_TOKEN (siehe Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“).",
"defaultOff": "Standardmäßig aus. Eingerichtet wird es auf dem Server (Compose-Profil „gui“, OPNEXUS_GUI_SITES, GUI_GATEWAY_TOKEN) – siehe Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
"on": "An",
"off": "Aus",
"notReady": "Noch nicht eingerichtet. Es fehlt:",
"reason": {
"cookie_secure": "HTTPS-Anmeldung (COOKIE_SECURE=true)",
"gateway_token": "GUI_GATEWAY_TOKEN (mindestens 32 Zeichen)",
"gui_sites": "OPNEXUS_GUI_SITES mit den Slot-Adressen (und Compose-Profil „gui“)"
"cookie_secure": "Sichere Anmeldung über HTTPS (Einstellung COOKIE_SECURE=true)",
"gateway_token": "Zugangs-Token für den GUI-Gateway-Dienst (GUI_GATEWAY_TOKEN, mindestens 32 Zeichen)",
"gui_sites": "Ports für die Firewall-Oberflächen (OPNEXUS_GUI_SITES) und das Compose-Profil „gui“"
},
"enabled": "Öffnen der Firewall-Oberfläche erlauben",
"enabledTip": "Schaltet die Funktion für alle Admins ein. Beim Ausschalten werden offene Sitzungen beendet (binnen etwa 20 Sekunden).",
@@ -3508,10 +3602,10 @@
"absolute": "Gesamtdauer (Minuten)",
"absoluteTip": "Spätestens nach dieser Zeit endet jede Sitzung, auch bei Aktivität (5 bis 720).",
"slotCount": "Gleichzeitige Plätze",
"slotCountTip": "Wie viele Firewall-Oberflächen gleichzeitig offen sein können (1 bis 4). Jeder Platz hat einen eigenen Port und damit einen eigenen Browser-Ursprung.",
"slotCountTip": "Wie viele Firewall-Oberflächen gleichzeitig offen sein können (1 bis 4). Jeder Platz hat einen eigenen Port, damit sich die offenen Firewalls im Browser nicht gegenseitig stören.",
"orderError": "Das Inaktivitätslimit darf nicht größer sein als die Gesamtdauer.",
"activeSlots": "Aktive Plätze (Ports)",
"activeSlotsTip": "Die konfigurierten Slot-Ports aus OPNEXUS_GUI_SITES, soweit sie bei der gewählten Anzahl genutzt werden. Welcher Platz gerade belegt ist, steht im Audit (Art „GUI-Zugriff“).",
"activeSlotsTip": "Die auf dem Server für die Firewall-Oberflächen festgelegten Ports, soweit sie bei der gewählten Anzahl genutzt werden. Welcher Platz gerade belegt ist, steht im Audit (Art „GUI-Zugriff“).",
"noSlots": "keine konfiguriert",
"lastChange": "Zuletzt geändert von {who} am {when}",
"saved": "Einstellungen für die Firewall-Oberfläche gespeichert"
@@ -3522,10 +3616,16 @@
"opened": "Oberfläche wird in einem neuen Tab geöffnet (Platz {slot}). Falls nichts passiert, Pop-ups für diese Seite erlauben.",
"failed": "Oberfläche konnte nicht geöffnet werden",
"reason": {
"off": "Ausgeschaltet. Ein Admin kann die Funktion unter Einstellungen einschalten.",
"host_disabled": "Diese Firewall ist deaktiviert.",
"host_https": "Nur Firewalls mit https://-Adresse lassen sich öffnen.",
"host_tls": "Nur mit geprüftem oder gepinntem Zertifikat möglich (TLS-Prüfung ist bei dieser Firewall aus)."
"off": "Ausgeschaltet. Einschalten unter Einstellungen › Firewall-Oberfläche über OPNexus.",
"host_disabled": "Diese Firewall ist deaktiviert. Aktiviere sie unter Hosts › Bearbeiten (Häkchen „Aktiv“).",
"host_https": "Die Adresse dieser Firewall beginnt nicht mit https://. Ändere sie unter Hosts › Bearbeiten.",
"host_tls": "Die Zertifikatsprüfung ist für diese Firewall ausgeschaltet. Schalte sie unter Hosts › Bearbeiten ein oder setze das Zertifikat fest.",
"unavailable": {
"cookie_secure": "Nicht eingerichtet: Die sichere HTTPS-Anmeldung ist auf dem Server nicht aktiv (Einstellung COOKIE_SECURE). Das muss ein Server-Admin ändern – Anleitung: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
"gateway_token": "Nicht eingerichtet: Es fehlt das Zugangs-Token für den GUI-Gateway-Dienst (GUI_GATEWAY_TOKEN, mindestens 32 Zeichen). Das muss ein Server-Admin eintragen – Anleitung: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“.",
"gui_sites": "Nicht eingerichtet: Es sind keine Ports für die Firewall-Oberflächen festgelegt (OPNEXUS_GUI_SITES, Compose-Profil „gui“). Das muss ein Server-Admin eintragen – Anleitung: Handbuch, Kapitel „Firewall-Oberfläche über OPNexus“."
},
"more": "Weitere fehlende Punkte: Einstellungen › Firewall-Oberfläche über OPNexus."
}
}
}
+139 -39
View File
@@ -71,10 +71,11 @@
"gatewayDown": "Gateway failing (offline, packet loss or latency above the limit)",
"resourceThreshold": "Resource threshold exceeded (disk, memory, swap, CPU load; off by default)",
"serviceDown": "Watched service stopped (off by default)",
"rolloutProblems": "Rollout paused or not started automatically"
"rolloutProblems": "Rollout paused or not started automatically",
"apiRights": "API rights missing (gateway or service status not readable)"
},
"pollInterval": "Check interval (seconds)",
"pollHint": "At least 60s, so the firewalls are not loaded unnecessarily.",
"pollHint": "At least 60 seconds, so the firewalls are not queried needlessly.",
"monitoring": "Thresholds and debouncing",
"monitoringHint": "Alerts for gateways, resources and services use the measurements OPNexus records every minute. An alert opens only when the latest measurements are all bad and closes only when they are all good again, so single spikes and flapping links do not send messages. 0 switches a single threshold off.",
"limit": {
@@ -370,8 +371,11 @@
"alert": {
"gateway_down": "Gateway",
"resource_threshold": "Resource",
"service_down": "Service"
}
"service_down": "Service",
"api_rights_missing": "API rights"
},
"moreGateways": "+ {count} more gateways (on the firewall page)",
"apiRightsLine": "{name}: API right missing ({read})"
},
"hosts": {
"add": "Add host",
@@ -511,6 +515,10 @@
},
"adopt": {
"button": "Adopt from firewall",
"noHosts": {
"title": "No active firewall",
"description": "There is no active firewall to adopt anything from. Add a firewall under “Hosts” or enable an existing one."
},
"title": {
"aliases": "Adopt aliases from the firewall",
"portForward": "Adopt port forwards from the firewall",
@@ -524,6 +532,55 @@
"all": "Adopt all adoptable ({count})",
"adopted": "Adopted “{name}”.",
"allDone": "{count} adopted, {skipped} skipped.",
"linked": "“{name}” linked to the existing object.",
"allDoneLinked": "{count} adopted ({linked} of them linked), {skipped} skipped.",
"confirm": {
"title": "Adopt everything adoptable",
"summary": "Link {link}, adopt {created} as new, {left} not adoptable.",
"note": "Only objects that exactly equal an existing central object are linked. Nothing is changed on the firewall.",
"go": "Confirm and adopt"
},
"link": {
"badge": "Already exists centrally",
"found": "Equals the existing object “{name}” (firewalls: {hosts}). Linking adds this firewall there as another target instead of creating a second object.",
"choose": "Link to",
"action": "Link",
"linkTip": "Adds this firewall as another target of the existing object. Nothing is changed on the firewall; later changes are rolled out to all firewalls together.",
"adoptNew": "Adopt as own object",
"newTip": "Creates a second, independent object. Only useful if this firewall should keep its own values for good.",
"newBlockedTip": "An object with this name already exists, so only linking is possible. To keep it separate, rename it on the firewall first.",
"similar": "Similar: “{name}” (firewalls: {hosts}) differs and is therefore not linked.",
"similarNext": "Make the object on the firewall or the central object match, then it can be linked. Or adopt it separately.",
"diffValues": "central “{central}”, on the firewall “{live}”",
"variableMissing": "This firewall has no value for the variable {names}. Enter it in the host variables.",
"yes": "Yes",
"no": "No",
"fields": {
"name": "Name",
"type": "Type",
"content": "Content",
"description": "Description",
"enabled": "Enabled",
"interface": "Interface",
"direction": "Direction",
"ipprotocol": "IP version",
"protocol": "Protocol",
"action": "Action",
"source_net": "Source",
"source_port": "Source port",
"destination_net": "Destination",
"destination_port": "Destination port",
"log": "Log packets",
"target": "Target IP",
"local_port": "Target port",
"target_port": "Target port (NAT)",
"natreflection": "NAT reflection",
"external": "External address",
"source_not": "Source inverted",
"destination_not": "Destination inverted",
"variables": "Variables"
}
},
"failed": "Adopting failed",
"loadFailed": "Could not load objects.",
"notPossible": "Cannot be adopted",
@@ -550,6 +607,7 @@
"unsupported_type": "OPNexus does not support type “{detail}”",
"name_invalid": "The name contains characters OPNexus does not allow (letters, digits, _ only)",
"name_exists": "OPNexus already has an alias with this name",
"twin_busy": "The identical central object is still waiting for a confirmation. Try again afterwards",
"content_too_large": "Content too large (over 8000 characters)",
"proto_set": "Has an IPv4/IPv6 restriction OPNexus cannot represent",
"value_too_long": "A value is longer than OPNexus stores",
@@ -729,6 +787,8 @@
"GUI_ACCESS_DISABLED": "GUI access is switched off. An admin can switch it on in the settings.",
"GUI_HOST_NOT_ELIGIBLE": "This firewall cannot be opened through OPNexus: it must be enabled, reachable via https:// and use a verified or pinned certificate.",
"GUI_NO_FREE_SLOT": "All {slots} slots for firewall interfaces are in use. Close an open interface or wait until it expires.",
"GUI_SLOT_COOLING_DOWN": "A slot was just released but is only reassigned to a different firewall after {minutes} min (protection in case a window is still open there). Open the same firewall again or wait a moment.",
"GUI_TOO_MANY_SESSIONS": "Too many firewall interfaces are open at the same time (at most {max}). Wait for one to end or close one you no longer need.",
"TLS_PROBE_FAILED": "The firewall's certificate could not be retrieved",
"TLS_PEM_INVALID": "Not a valid PEM certificate",
"TLS_PIN_MISMATCH": "The firewall presents a different certificate than the pinned one",
@@ -740,6 +800,10 @@
"RULE_NOT_ADOPTABLE": "This rule cannot be adopted ({reasons})",
"OBJECT_NOT_ADOPTABLE": "This object cannot be adopted ({reasons})",
"ADOPT_OBJECT_NOT_FOUND": "The object was not found on the firewall. Reload the list.",
"ADOPT_LINK_TARGET_NOT_FOUND": "The central object no longer exists. Reload the list.",
"ADOPT_LINK_TARGET_CHANGED": "The central object and the firewall differ now ({fields}). Reload the list and check.",
"ADOPT_LINK_HOST_HAS_OBJECT": "This firewall already manages this object under another UUID. Reload the list.",
"ADOPT_LINK_BUSY": "The object has an open confirmation or rollback. Confirm or resolve it first, then link.",
"OUTBOUND_MODE_BLOCKS_ADOPT": "{host}: in outbound NAT mode “{mode}” OPNsense does not list manual rules. Switch the mode to Hybrid/Manual in OPNsense itself, then they can be adopted. OPNexus does not change this mode.",
"NETWORK_ACK_REQUIRED": "This change can cut the connection to the firewall and must be confirmed explicitly ({reasons})",
"NETWORK_APPLY_UNCERTAIN": "The change was written, but applying it on “{host}” failed or is unclear. Check the firewall; backup: {backup_id}",
@@ -800,7 +864,11 @@
"ANALYSE_FAMILIEN_MISCHUNG": "Source and destination must both be IPv4 or both IPv6.",
"ANALYSE_INTERFACE_UNBEKANNT": "Interface \"{interface}\" does not exist on this firewall. Available: {known}.",
"ANALYSE_PARAMETER_UNGUELTIG": "Invalid value for {field}: {value}.",
"ANALYSE_FIREWALL_NICHT_LESBAR": "Could not read this firewall's rules. Is it reachable?"
"ANALYSE_FIREWALL_NICHT_LESBAR": "Could not read this firewall's rules. Is it reachable?",
"API_RIGHTS_MISSING": "The API user lacks the right to read the gateway status",
"GATEWAY_NAME_INVALID": "Invalid gateway name",
"GATEWAY_UNKNOWN": "OPNexus does not know this gateway on the firewall (check the name and its case)",
"GATEWAY_MUTE_LIMIT": "At most {limit} gateways can be muted"
},
"workflow": {
"attention": "Check errors",
@@ -810,7 +878,7 @@
"disabled": "Target hosts disabled",
"draft": "Changes pending",
"synced": "Deployed",
"tooltip": "State of the deployments stored by OPNexus; not a live comparison"
"tooltip": "Shows what OPNexus rolled out last. The firewall is not queried live for this."
},
"certStatus": {
"expired": "Expired",
@@ -885,7 +953,7 @@
"status": "Status",
"running": "running",
"stopped": "stopped",
"locked": "Locked by OPNsense",
"locked": "This service is locked in OPNsense and cannot be controlled here.",
"startTitle": "Start",
"stopTitle": "Stop",
"restartTitle": "Restart"
@@ -1431,9 +1499,9 @@
"reasserting": "Reassert running…",
"reassert": "Re-establish order",
"preRules": "Pre rules",
"preHint": "Evaluated before the post rules. Across several levels: root-most group first.",
"preHint": "Evaluated before the post rules. With several levels, the topmost group comes first.",
"postRules": "Post rules",
"postHint": "Evaluated after the pre rules. Across several levels: leaf-most (most specific) group first.",
"postHint": "Evaluated after the pre rules. With several levels, the lowest (most specific) group comes first.",
"editGroup": "Edit group",
"parentGroup": "Parent group (optional)",
"rootGroup": "(none — root group)",
@@ -1503,7 +1571,7 @@
"registration": "Registration: {state}",
"registrationOk": "successful",
"registrationFailed": "failed",
"pushFirst": "Push first",
"pushFirst": "Not on the firewall yet: send it there first with “Push”.",
"register": "Register",
"requests": "ACME certificate requests",
"newRequest": "New request",
@@ -1540,7 +1608,7 @@
"deleted": "Tunnel “{name}” deleted",
"title": "IPsec VPN (site-to-site)",
"new": "New tunnel",
"intro": "Site-to-site tunnels with pre-shared-key authentication, maintained centrally and deployed to target hosts or device groups — analogous to the firewall rules, but without their automatic rollback safety net: a failed service reload can cut exactly the connection OPNexus uses to reach the host. v1 only supports PSK auth, one phase 2 (child) per tunnel and simple 1:1 network-to-network tunnels — no OpenVPN/WireGuard, no certificates/EAP, no pools.",
"intro": "Site-to-site tunnels with a pre-shared key, maintained centrally and deployed to firewalls or device groups. Unlike firewall rules there is no automatic rollback: a failed service reload can cut the connection OPNexus uses to reach the firewall. One phase 2 per tunnel and simple network-to-network tunnels are supported (no certificates/EAP, no pools).",
"showAll": "Show all tunnels",
"none": "No IPsec tunnels created yet.",
"createFirst": "Create first tunnel",
@@ -1582,7 +1650,7 @@
"deleted": "Instance “{name}” deleted",
"title": "OpenVPN instances",
"new": "New instance",
"intro": "OpenVPN instances (server or client role), maintained centrally and deployed to target hosts or device groups — analogous to the IPsec feature, but without its automatic rollback safety net: a failed service reload can cut exactly the connection OPNexus uses to reach the host. v1 requires a certificate/CA that already exists on every target host — selectable via a picker from the respective host's trust store, since a certificate UUID is host-local —, supports optional TLS hardening (tls-auth/tls-crypt) via a static key, but no certificate-less “shared key” instance (not provided for in the modern OPNsense model).",
"intro": "OpenVPN instances (server or client), maintained centrally and deployed to firewalls or device groups. As with IPsec there is no automatic rollback: a failed service reload can cut the connection OPNexus uses to reach the firewall. Every target firewall must already have a certificate and CA (selected from its certificate store); optional TLS protection (tls-auth/tls-crypt) via a static key is supported, but no “shared key” instances without a certificate.",
"showAll": "Show all instances",
"none": "No OpenVPN instances created yet.",
"createFirst": "Create first instance",
@@ -1622,7 +1690,7 @@
"created": "Tunnel “{name}” created",
"deleteConfirm": "Really delete this WireGuard tunnel? It will also be removed from all target hosts (peer, then server) and the WireGuard service will be reloaded.",
"title": "WireGuard tunnels",
"intro": "WireGuard interconnects (one server interface + exactly one peer per tunnel), maintained centrally and deployed to target hosts or device groups — analogous to IPsec/OpenVPN, without their automatic rollback safety net: a failed service reload can cut exactly the connection OPNexus uses to reach the host. v1 requires the server private key AND public key as well as the peer public key to be supplied manually by the operator (no key pair generator), optionally supports WireGuard's own pre-shared key per peer, and models exactly one server↔peer connection (no multi-peer hub).",
"intro": "WireGuard connections (one server interface and exactly one peer per tunnel), maintained centrally and deployed to firewalls or device groups. As with IPsec there is no automatic rollback: a failed service reload can cut the connection OPNexus uses to reach the firewall. You generate and enter the keys yourself; a pre-shared key per peer is optional.",
"none": "No WireGuard tunnels created yet.",
"noPrivateKey": "No private key stored",
"pskMissing": "PSK enabled but not stored",
@@ -1717,7 +1785,7 @@
},
"overlay": {
"title": "Management tunnel",
"intro": "Lets OPNexus manage firewalls that have no public address (behind NAT, CGNAT, a mobile link). Each firewall dials OUT to a WireGuard hub on this server; OPNexus then reaches its API through the tunnel. Nothing on the firewall has to be reachable from the internet. Needs the hub container (COMPOSE_PROFILES=overlay) and the UDP port reachable from the internet.",
"intro": "Lets OPNexus manage firewalls that have no public address (behind NAT, CGNAT, a mobile link). Each firewall dials out to a WireGuard hub on this server; OPNexus then reaches its API through the tunnel. Nothing on the firewall has to be reachable from the internet. Needed on the server: the hub container (COMPOSE_PROFILES=overlay) and a UDP port reachable from the internet.",
"loadFailed": "Could not load the management tunnel",
"saved": "Saved",
"enable": "Enable the management tunnel",
@@ -2013,7 +2081,7 @@
"colCreated": "Created",
"colLastLogin": "Last sign-in",
"you": "(you)",
"cannotDeleteSelf": "You cannot delete your own account",
"cannotDeleteSelf": "You cannot delete your own account – another admin has to do that.",
"created": "User created",
"updated": "User “{name}” updated",
"createFailed": "Could not create user",
@@ -2242,19 +2310,19 @@
},
"noUpdate": {
"title": "No installable update",
"text": "The last check reports no stable package update."
"text": "The last check reports no stable package update. Run a new check if needed."
},
"noMajor": {
"title": "No installable major upgrade",
"text": "The last check reports no available major upgrade."
"text": "The last check reports no available major upgrade. Run a new check if needed."
},
"pending": {
"title": "Maintenance still open",
"text": "An earlier maintenance must be completed first, after the technical check."
"text": "An earlier maintenance must be completed first: check its result and finish it on the host page."
},
"change": {
"title": "Change still open",
"text": "A deployment or rollback in the cluster is not finished yet."
"text": "A deployment or rollback in the cluster is still running. Wait until it has finished."
},
"locked": "Update blocked"
},
@@ -2288,7 +2356,7 @@
"intro": "When opened, all eligible hosts are automatically checked for updates. Major upgrades and HA role switches remain deliberate manual steps.",
"checkAgain": "Check for updates again",
"refreshView": "Refresh view",
"readOnlyNote": "Read-only access: status, package changes and logs are visible; checks and updates are blocked.",
"readOnlyNote": "With your role (read-only) you can view status, package changes and logs, but you cannot start checks or updates.",
"sumHosts": "Managed hosts",
"sumStable": "Stable updates",
"sumMajor": "Major upgrades",
@@ -2372,8 +2440,8 @@
"downtimeShort": "The host may be unreachable during installation",
"downtimeTip": "Connections can drop. There is no automatic rollback.",
"tipConfirmName": "Typing the host name prevents updating the wrong host by accident.",
"confirmRunDisabledName": "Enter the host name exactly to continue",
"confirmRunDisabledAck": "Confirm the maintenance window first",
"confirmRunDisabledName": "Enter the host name exactly to continue.",
"confirmRunDisabledAck": "Confirm the maintenance window first (tick the box in the dialog).",
"cancelWarnShort": "Does not confirm a successful completion",
"cancelWarnTip": "The operation is only closed so the host or cluster does not stay locked. Use only if the expected state demonstrably never occurred (e.g. a stuck CARP role switch).",
"finishNoteTip": "This confirmation documents your check and triggers no firewall action.",
@@ -2403,8 +2471,8 @@
"skipVerifyHelp": "The connection stays encrypted, but OPNexus cannot tell whether the firewall is really answering. For testing only.",
"probeFailed": "The certificate could not be retrieved",
"pinnedTip": "Certificate pinned · SHA-256 {fp}…",
"systemTip": "Standard certificate check. For self-signed firewalls, pin the certificate.",
"insecureTip": "The certificate is not verified. Edit the host to pin it."
"systemTip": "The certificate is checked against the standard certificate authorities. For a self-signed certificate, pin it under Edit.",
"insecureTip": "The certificate is not verified. Pin it under Edit to secure the connection."
},
"twofa": {
"title": "Two-factor sign-in",
@@ -2892,6 +2960,32 @@
"offline": "Offline",
"forced_down": "Switched off",
"unknown": "Unknown"
},
"alert": {
"label": "Alert for gateway {name}",
"off": "Alert off",
"tooltipOn": "Alert for this gateway: on. Click to switch it off (e.g. an idle second WAN).",
"tooltipOff": "Alert for this gateway: off. Click to switch it back on.",
"adminOnly": "Only administrators can change the alert for a gateway.",
"nowOn": "Alert for {name} switched on",
"nowOff": "Alert for {name} switched off",
"failed": "Could not change the alert"
},
"rights": {
"badge": "API rights missing: {reads}",
"hint": "OPNexus's API user may not read these values, so the alerts based on them cannot fire. In OPNsense, add to the user or group under System > Access:",
"read": {
"gateways": "Gateways",
"services": "Services",
"swap": "Swap",
"cpu": "CPU"
},
"acl": {
"gateways": "System: Gateways",
"services": "Status: Services",
"swap": "Lobby: Dashboard",
"cpu": "Lobby: Dashboard"
}
}
},
"rollouts": {
@@ -3288,7 +3382,7 @@
"egressIface": "Egress interface",
"run": "Analyze",
"refresh": "Re-read from the firewall",
"refreshHint": "Without this, rules are cached for up to 60 seconds.",
"refreshHint": "Reads the rules fresh from the firewall instead of using data cached for up to 60 seconds.",
"missing": "Source and destination IP are required.",
"noHostsSelected": "Select at least one host.",
"noGroupSelected": "Select a device group."
@@ -3307,7 +3401,7 @@
"decisive": "Deciding rule",
"decisiveIfKnown": "Most likely rule if the open points do not apply",
"noRule": "No rule matches",
"noRuleHint": "No rule entry matches; pf then lets the packet through.",
"noRuleHint": "No rule entry matches; the packet filter (pf) then lets the packet through.",
"unresolved": "No interface could be determined",
"unresolvedHint": "The source IP belongs to no interface network. Choose the ingress interface manually.",
"undecided": "Cannot be determined reliably",
@@ -3327,7 +3421,7 @@
"ruleGateway": "Gateway",
"ruleSchedule": "Schedule",
"quick": "quick",
"quickHint": "quick: the first match decides immediately.",
"quickHint": "quick: this rule decides immediately; no further rules are checked.",
"pfNumber": "pf rule @{number}",
"automatic": "automatic",
"legacy": "legacy format",
@@ -3363,7 +3457,7 @@
},
"trace": {
"title": "Why?",
"hint": "All rules in the order pf checks them. The last match wins unless a quick rule matches earlier.",
"hint": "All rules in the order the packet filter (pf) checks them. The last match wins unless a quick rule matches earlier.",
"leg": "Trace {direction} on {interface}",
"dirIn": "in",
"dirOut": "out",
@@ -3434,7 +3528,7 @@
"RATE_LIMIT": "Rate limit",
"RATE_LIMIT_hint": "The rule limits connections; above the limit it may behave differently.",
"PF_DEFAULT_PASS": "Default: pass",
"PF_DEFAULT_PASS_hint": "No rule matches; pf then lets the packet through.",
"PF_DEFAULT_PASS_hint": "No rule matches; the packet filter (pf) then lets the packet through.",
"QUICK_UNKNOWN": "quick unclear",
"QUICK_UNKNOWN_hint": "For one rule it is unclear whether it decides immediately.",
"GROUPS_UNAVAILABLE": "Interface groups unavailable",
@@ -3492,14 +3586,14 @@
"guiAccess": {
"title": "Firewall interface through OPNexus",
"intro": "Opens the web interface of a managed firewall in its own browser tab through OPNexus, even when the firewall is only reachable over the management tunnel. Admins only; you still sign in on the firewall itself. The ticket works once for 30 seconds, every session has time limits and is logged in the audit. Details: manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
"defaultOff": "Off by default. Setting it up needs the Compose profile “gui”, OPNEXUS_GUI_SITES and GUI_GATEWAY_TOKEN on the server (see the manual, chapter “Firewall-Oberfläche über OPNexus”).",
"defaultOff": "Off by default. It is set up on the server (Compose profile “gui”, OPNEXUS_GUI_SITES, GUI_GATEWAY_TOKEN) – see the manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
"on": "On",
"off": "Off",
"notReady": "Not set up yet. Missing:",
"reason": {
"cookie_secure": "HTTPS sign-in (COOKIE_SECURE=true)",
"gateway_token": "GUI_GATEWAY_TOKEN (at least 32 characters)",
"gui_sites": "OPNEXUS_GUI_SITES with the slot addresses (and the Compose profile “gui”)"
"cookie_secure": "Secure HTTPS sign-in (setting COOKIE_SECURE=true)",
"gateway_token": "Access token for the GUI gateway service (GUI_GATEWAY_TOKEN, at least 32 characters)",
"gui_sites": "Ports for the firewall interfaces (OPNEXUS_GUI_SITES) and the Compose profile “gui”"
},
"enabled": "Allow opening the firewall interface",
"enabledTip": "Turns the feature on for all admins. Turning it off ends open sessions (within about 20 seconds).",
@@ -3508,10 +3602,10 @@
"absolute": "Total duration (minutes)",
"absoluteTip": "Every session ends after this time at the latest, even when active (5 to 720).",
"slotCount": "Simultaneous slots",
"slotCountTip": "How many firewall interfaces can be open at the same time (1 to 4). Each slot has its own port and therefore its own browser origin.",
"slotCountTip": "How many firewall interfaces can be open at the same time (1 to 4). Each slot has its own port so the open firewalls cannot interfere with each other in the browser.",
"orderError": "The inactivity limit must not exceed the total duration.",
"activeSlots": "Active slots (ports)",
"activeSlotsTip": "The slot ports configured in OPNEXUS_GUI_SITES that are used with the chosen count. Which slot is in use right now is shown in the audit (type “GUI access”).",
"activeSlotsTip": "The ports set up on the server for the firewall interfaces, as far as the chosen count uses them. Which slot is in use right now is shown in the audit (type “GUI access”).",
"noSlots": "none configured",
"lastChange": "Last changed by {who} on {when}",
"saved": "Firewall interface settings saved"
@@ -3522,10 +3616,16 @@
"opened": "Opening the interface in a new tab (slot {slot}). If nothing happens, allow pop-ups for this site.",
"failed": "Could not open the interface",
"reason": {
"off": "Switched off. An admin can turn the feature on under Settings.",
"host_disabled": "This firewall is disabled.",
"host_https": "Only firewalls with an https:// address can be opened.",
"host_tls": "Only possible with a verified or pinned certificate (TLS verification is off for this firewall)."
"off": "Switched off. Turn it on under Settings › Firewall interface through OPNexus.",
"host_disabled": "This firewall is disabled. Enable it under Hosts › Edit (tick “Active”).",
"host_https": "This firewall’s address does not start with https://. Change it under Hosts › Edit.",
"host_tls": "Certificate verification is switched off for this firewall. Turn it on under Hosts › Edit or pin the certificate.",
"unavailable": {
"cookie_secure": "Not set up: secure HTTPS sign-in is not active on the server (setting COOKIE_SECURE). A server admin has to change this – instructions: manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
"gateway_token": "Not set up: the access token for the GUI gateway service is missing (GUI_GATEWAY_TOKEN, at least 32 characters). A server admin has to add it – instructions: manual (German), chapter “Firewall-Oberfläche über OPNexus”.",
"gui_sites": "Not set up: no ports are defined for the firewall interfaces (OPNEXUS_GUI_SITES, Compose profile “gui”). A server admin has to add them – instructions: manual (German), chapter “Firewall-Oberfläche über OPNexus”."
},
"more": "More missing items: Settings › Firewall interface through OPNexus."
}
}
}
+28
View File
@@ -58,6 +58,34 @@ export function translateServerParams(language: Language, params?: Record<string
return Object.fromEntries(Object.entries(params).map(([key, value]) => [key, typeof value === "string" ? translateServerText(language, value) : value]));
}
// Errors whose parameter is a comma-separated list of machine codes (the server sends codes, never prose):
// the parameter and the catalog prefix that holds their texts.
const CODE_LIST_PARAMS: Record<string, { param: string; prefix: string }> = {
OBJECT_NOT_ADOPTABLE: { param: "reasons", prefix: "adopt.reasons" },
RULE_NOT_ADOPTABLE: { param: "reasons", prefix: "rules.adopt.reasons" },
ADOPT_LINK_TARGET_CHANGED: { param: "fields", prefix: "adopt.link.fields" },
GUI_GATEWAY_NOT_CONFIGURED: { param: "reasons", prefix: "guiAccess.reason" },
NETWORK_CHANGE_BLOCKED: { param: "reasons", prefix: "network.blocks" },
NETWORK_ACK_REQUIRED: { param: "reasons", prefix: "network.risks" },
};
/** `reasons: "name_exists, proto_set"` -> readable sentences joined by " · "; an unknown code stays as it is.
* `reason_details` (JSON, code -> text) fills {detail}; placeholders nobody can fill are dropped. */
export function translateReasonParams(language: Language, code: string, params?: Record<string, string>): Record<string, string> | undefined {
const spec = CODE_LIST_PARAMS[code];
if (!spec || !params || typeof params[spec.param] !== "string") return params;
let details: Record<string, string> = {};
try { details = JSON.parse(params.reason_details || "{}") as Record<string, string>; } catch { /* plain codes only */ }
const items = params[spec.param].split(",").map((r) => r.trim()).filter(Boolean).map((item) => {
const key = `${spec.prefix}.${item}`;
const text = translate(language, key, details[item] !== undefined ? { detail: details[item] } : {});
if (text === key) return item;
// A placeholder without a value goes away together with the quotes/colon around it.
return text.replace(/\s*[„“"'(]?\{\w+\}[“”"')]?/g, "").replace(/[:\s]+$/, "").replace(/ {2,}/g, " ").trim();
});
return { ...params, [spec.param]: items.join(" · ") };
}
// Looks up `${prefix}.${key}`, falling back to the raw key for values the catalog does not know.
export function labelFor(t: (key: string) => string, prefix: string, key: string): string {
const full = `${prefix}.${key}`;
+2 -2
View File
@@ -1,6 +1,6 @@
// Manually bumped whenever a notable change ships — mirrors the dated
// entries already kept in the project README/CHANGELOG.md, no build
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
export const APP_VERSION = "1.27.2";
export const APP_CODENAME = "Buzzard";
export const APP_VERSION = "1.29.0";
export const APP_CODENAME = "Caracara";
export const APP_MAINTAINER = "Julian Boldt";