Compare commits

..
6 Commits
Author SHA1 Message Date
Julian Boldt a3274f64ae Release v1.24.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
- **Alarme für Gateways, Ressourcen und Dienste:** Neue Ereignisse in Einstellungen › Benachrichtigungen.
  **Gateway gestört** (standardmäßig **an**) meldet ein Gateway, das OPNsense als „down“ führt oder dessen Paketverlust (ab 20 %) bzw.
  Latenz (ab 500 ms) über der Schwelle liegt; bewusst abgeschaltete und nicht überwachte Gateways bleiben still. **Ressourcen-Schwelle**
  (standardmäßig aus) prüft Festplatte (ab 90 %), Arbeitsspeicher (ab 95 %), Swap (ab 50 %) und CPU-Last (ab 2 pro Thread), **Dienst ausgefallen**
  (standardmäßig aus) eine einstellbare Liste (Unbound, Dnsmasq, DHCP, NTP, OpenVPN, WireGuard, IPsec …) und meldet nur Dienste, die zuvor
  liefen. Alle drei stützen sich auf die Minutenmessung und lösen erst aus, wenn die letzten 3 (Ressourcen: 5) Messungen übereinstimmen, und
  enden erst, wenn alle wieder in Ordnung sind: Einzelspitzen und flackernde Verbindungen schicken nichts. Der Gateway-Status (Verlust, Latenz,
  Abweichung) steht außerdem auf der Host-Seite, als Kachel auf dem Dashboard und als Prometheus-Metriken (`opnexus_host_gateway_*`,
  `opnexus_host_swap_used_percent`, `opnexus_host_service_running`). Die Messung speichert dafür zusätzlich Swap, CPU-Threads, Gateways
  und Dienste in der vorhandenen Messreihe (keine neue Tabelle).
- **Weitere Benachrichtigungskanäle:** ntfy, Gotify, Telegram, Microsoft Teams (Workflows-/Incoming-Webhook) und Matrix, beliebig viele
  je Typ, mit „Testen“-Knopf pro Kanal. Zugangsdaten (Tokens, die Teams-Adresse) liegen wie das SMTP-Passwort in der Geheimnis-Ablage, nie
  in der Datenbank oder in Antworten, und tauchen nicht in Fehlermeldungen oder Logs auf. Webhook und neue Kanäle prüfen die Zieladresse:
  nur http(s), keine Zugangsdaten in der Adresse, keine lokalen/Link-Local-/Metadaten-Adressen; Weiterleitungen werden nicht verfolgt.
  Betrifft die bestehende Webhook-Adresse: `localhost`, Loopback, 169.254.x.x und Zugangsdaten in der Adresse (`https://user:pass@…`)
  werden jetzt abgelehnt. Eine solche gespeicherte Adresse wird nicht mehr beliefert (nur ein Log-Eintrag), und die
  Benachrichtigungs-Einstellungen lassen sich erst wieder speichern, wenn sie korrigiert oder geleert ist (die Meldung nennt den Grund).
  Neue Einstellungen kommen mit Migration 0020.

- **Bestand übernehmen für Aliase und NAT:** Auf den Seiten Aliase und NAT (Port Forward, 1:1, Outbound) gibt es jetzt **Von Firewall übernehmen**
  wie bei den Regeln. Der Dialog zeigt, was von Hand in OPNsense angelegt wurde, einzeln zum Übernehmen oder mit „Alle übernehmbaren“ in einem Schritt;
  verschachtelte Aliase werden in der richtigen Reihenfolge übernommen. Die Übernahme schreibt nichts auf die Firewall: Die Vorschau zeigt direkt
  danach „keine Änderung“, der nächste Rollout aktualisiert dasselbe Objekt (kein Duplikat). Nicht übernommen werden OPNsense-eigene Aliase,
  Typen, die OPNexus nicht kennt (z. B. URL, GeoIP), Aliase mit Verweis auf nicht übernommene Aliase sowie NAT-Regeln, die das Modell verfälschen würden
  (z. B. „Kein Redirect“, verknüpfte Filterregel, mehrere Schnittstellen, „Kein NAT“); die Oberfläche nennt den Grund je Objekt.
  Outbound-NAT lässt sich nur übernehmen, wenn der Modus der Firewall Hybrid oder Manuell ist. Einstellungen, die OPNexus nicht verwaltet (Protokollierung,
  Pool-Optionen, Tags, Kategorien), bleiben unverändert und werden angezeigt.

- **Images für ARM:** Alle Release-Images (backend, frontend, proxy, updater) gibt es jetzt für `linux/amd64` und `linux/arm64`
  (Raspberry Pi 4/5, ARM-Server, Apple-Silicon-Docker).
2026-10-21 01:57:30 +02:00
Julian Boldt 9ba6357193 Release v1.23.11
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Updates per Klick prüfen jetzt ebenfalls die Signatur:** Der Updater-Container enthält `cosign` (feste Version, Prüfsumme beim Bau
  geprüft) und verifiziert die neuen Images vor dem Wechsel, wenn `cosign.pub` im Projektverzeichnis liegt. Das Image wird dadurch größer
  (rund 266 statt 123 MB).
2026-10-20 15:46:34 +02:00
Julian Boldt 23d3a00ae1 Release v1.23.10
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
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
  Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
  installiert ist. Anleitung: Handbuch, „Images prüfen“.
2026-10-20 15:38:35 +02:00
Julian Boldt 4379a17b15 Release v1.23.9
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
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
  statt „baut aus dem Quellcode“.
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
2026-10-20 15:16:51 +02:00
Julian Boldt 983e05a816 Release v1.23.8
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
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
  die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
  `docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
2026-10-20 14:52:10 +02:00
Julian Boldt dd6a64d953 Release v1.23.7
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
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
2026-10-20 14:43:39 +02:00
65 changed files with 4552 additions and 84 deletions

No files matched your search

+61
View File
@@ -31,6 +31,67 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.24.0] "Merlin" — 2026-10-21
- **Alarme für Gateways, Ressourcen und Dienste:** Neue Ereignisse in Einstellungen › Benachrichtigungen.
**Gateway gestört** (standardmäßig **an**) meldet ein Gateway, das OPNsense als „down“ führt oder dessen Paketverlust (ab 20 %) bzw.
Latenz (ab 500 ms) über der Schwelle liegt; bewusst abgeschaltete und nicht überwachte Gateways bleiben still. **Ressourcen-Schwelle**
(standardmäßig aus) prüft Festplatte (ab 90 %), Arbeitsspeicher (ab 95 %), Swap (ab 50 %) und CPU-Last (ab 2 pro Thread), **Dienst ausgefallen**
(standardmäßig aus) eine einstellbare Liste (Unbound, Dnsmasq, DHCP, NTP, OpenVPN, WireGuard, IPsec …) und meldet nur Dienste, die zuvor
liefen. Alle drei stützen sich auf die Minutenmessung und lösen erst aus, wenn die letzten 3 (Ressourcen: 5) Messungen übereinstimmen, und
enden erst, wenn alle wieder in Ordnung sind: Einzelspitzen und flackernde Verbindungen schicken nichts. Der Gateway-Status (Verlust, Latenz,
Abweichung) steht außerdem auf der Host-Seite, als Kachel auf dem Dashboard und als Prometheus-Metriken (`opnexus_host_gateway_*`,
`opnexus_host_swap_used_percent`, `opnexus_host_service_running`). Die Messung speichert dafür zusätzlich Swap, CPU-Threads, Gateways
und Dienste in der vorhandenen Messreihe (keine neue Tabelle).
- **Weitere Benachrichtigungskanäle:** ntfy, Gotify, Telegram, Microsoft Teams (Workflows-/Incoming-Webhook) und Matrix, beliebig viele
je Typ, mit „Testen“-Knopf pro Kanal. Zugangsdaten (Tokens, die Teams-Adresse) liegen wie das SMTP-Passwort in der Geheimnis-Ablage, nie
in der Datenbank oder in Antworten, und tauchen nicht in Fehlermeldungen oder Logs auf. Webhook und neue Kanäle prüfen die Zieladresse:
nur http(s), keine Zugangsdaten in der Adresse, keine lokalen/Link-Local-/Metadaten-Adressen; Weiterleitungen werden nicht verfolgt.
Betrifft die bestehende Webhook-Adresse: `localhost`, Loopback, 169.254.x.x und Zugangsdaten in der Adresse (`https://user:pass@…`)
werden jetzt abgelehnt. Eine solche gespeicherte Adresse wird nicht mehr beliefert (nur ein Log-Eintrag), und die
Benachrichtigungs-Einstellungen lassen sich erst wieder speichern, wenn sie korrigiert oder geleert ist (die Meldung nennt den Grund).
Neue Einstellungen kommen mit Migration 0020.
- **Bestand übernehmen für Aliase und NAT:** Auf den Seiten Aliase und NAT (Port Forward, 1:1, Outbound) gibt es jetzt **Von Firewall übernehmen**
wie bei den Regeln. Der Dialog zeigt, was von Hand in OPNsense angelegt wurde, einzeln zum Übernehmen oder mit „Alle übernehmbaren“ in einem Schritt;
verschachtelte Aliase werden in der richtigen Reihenfolge übernommen. Die Übernahme schreibt nichts auf die Firewall: Die Vorschau zeigt direkt
danach „keine Änderung“, der nächste Rollout aktualisiert dasselbe Objekt (kein Duplikat). Nicht übernommen werden OPNsense-eigene Aliase,
Typen, die OPNexus nicht kennt (z. B. URL, GeoIP), Aliase mit Verweis auf nicht übernommene Aliase sowie NAT-Regeln, die das Modell verfälschen würden
(z. B. „Kein Redirect“, verknüpfte Filterregel, mehrere Schnittstellen, „Kein NAT“); die Oberfläche nennt den Grund je Objekt.
Outbound-NAT lässt sich nur übernehmen, wenn der Modus der Firewall Hybrid oder Manuell ist. Einstellungen, die OPNexus nicht verwaltet (Protokollierung,
Pool-Optionen, Tags, Kategorien), bleiben unverändert und werden angezeigt.
- **Images für ARM:** Alle Release-Images (backend, frontend, proxy, updater) gibt es jetzt für `linux/amd64` und `linux/arm64`
(Raspberry Pi 4/5, ARM-Server, Apple-Silicon-Docker).
## [1.23.11] "Harrier" — 2026-10-20
- **Updates per Klick prüfen jetzt ebenfalls die Signatur:** Der Updater-Container enthält `cosign` (feste Version, Prüfsumme beim Bau
geprüft) und verifiziert die neuen Images vor dem Wechsel, wenn `cosign.pub` im Projektverzeichnis liegt. Das Image wird dadurch größer
(rund 266 statt 123 MB).
## [1.23.10] "Harrier" — 2026-10-20
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
installiert ist. Anleitung: Handbuch, „Images prüfen“.
## [1.23.9] "Harrier" — 2026-10-20
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
statt „baut aus dem Quellcode“.
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
## [1.23.8] "Harrier" — 2026-10-20
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
`docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
## [1.23.7] "Harrier" — 2026-10-20
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
## [1.23.6] "Harrier" — 2026-10-20
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
+5 -4
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.23.6` 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.24.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -16,8 +16,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Sichere Änderungen** — jede Änderung läuft *Prüfen → Ausrollen → Bestätigen*; bleibt die Bestätigung im Zeitfenster aus, rollt OPNexus zurück.
- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume.
- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt.
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog, Snapshots und Diagnose (Ping, Traceroute, Port-Prüfung, DNS), ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln lassen sich übernehmen.
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog, Snapshots und Diagnose (Ping, Traceroute, Port-Prüfung, DNS), ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln, Aliase und NAT-Regeln lassen sich übernehmen.
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
- **Alarme** — Gateway-/WAN-Ausfall (down, Paketverlust, Latenz), Schwellen für Platte, Speicher, Swap und CPU sowie ausgefallene Dienste; über mehrere Messungen entprellt, damit Einzelspitzen und flackernde Verbindungen schweigen. Zustellung per E-Mail, Webhook, ntfy, Gotify, Telegram, Microsoft Teams oder Matrix, jedes Ereignis einzeln schaltbar.
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
@@ -58,11 +59,11 @@ Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassis
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls).
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
**Fertige Images** (amd64 und arm64) liegen in der Registry dieses Gitea. In der `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.23.6
OPNEXUS_VERSION=1.24.0
```
```bash
+5 -4
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.23.6` 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.24.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -16,8 +16,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Safe changes** — every change goes *preview → deploy → confirm*; without your confirmation inside the time window OPNexus rolls it back.
- **Hierarchical policies** — pre/post rulebases with inheritance across group trees.
- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out.
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog, snapshots and diagnostics (ping, traceroute, port check, DNS), a comparison of cluster nodes, with a diff preview, a backup before every change and a guard against cutting your own connection; rules created by hand on a firewall can be adopted.
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog, snapshots and diagnostics (ping, traceroute, port check, DNS), a comparison of cluster nodes, with a diff preview, a backup before every change and a guard against cutting your own connection; rules, aliases and NAT rules created by hand on a firewall can be adopted.
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
- **Alerts** — gateway/WAN outage (down, packet loss, latency), disk/memory/swap/CPU thresholds and stopped services, debounced over several measurements so spikes and flapping links stay quiet; delivered by email, webhook, ntfy, Gotify, Telegram, Microsoft Teams or Matrix, all switchable per event.
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
@@ -60,11 +61,11 @@ After the first sign-in a short **setup assistant** opens on its own: set your o
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls) (German).
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
**Prebuilt images** (amd64 and arm64) are published to this Gitea's registry. In `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.23.6
OPNEXUS_VERSION=1.24.0
```
```bash
+3
View File
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
# keine Instanz- oder Nutzerdaten.
update_check_url: str = ""
update_check_interval_hours: int = 12
# Registry der Release-Images (OPNEXUS_IMAGE_REPO). Leer = OPNexus wird lokal aus dem
# Quellcode gebaut; dann gibt es keine Updates per Klick (nur der Hinweis in den Einstellungen).
opnexus_image_repo: str = ""
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
+8
View File
@@ -116,6 +116,9 @@ class ErrorCode(str, Enum):
NETWORK_FIELD_INVALID = "NETWORK_FIELD_INVALID"
NETWORK_ITEM_NOT_FOUND = "NETWORK_ITEM_NOT_FOUND"
RULE_NOT_ADOPTABLE = "RULE_NOT_ADOPTABLE"
OBJECT_NOT_ADOPTABLE = "OBJECT_NOT_ADOPTABLE"
ADOPT_OBJECT_NOT_FOUND = "ADOPT_OBJECT_NOT_FOUND"
OUTBOUND_MODE_BLOCKS_ADOPT = "OUTBOUND_MODE_BLOCKS_ADOPT"
REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0"
RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN"
ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL"
@@ -162,6 +165,11 @@ class ErrorCode(str, Enum):
VARIABLES_MISSING = "VARIABLES_MISSING"
VARIABLE_INVALID = "VARIABLE_INVALID"
OVERLAY_INVALID = "OVERLAY_INVALID"
NOTIFICATION_CHANNEL_INVALID = "NOTIFICATION_CHANNEL_INVALID"
NOTIFICATION_CHANNEL_NOT_FOUND = "NOTIFICATION_CHANNEL_NOT_FOUND"
NOTIFICATION_CHANNEL_NAME_TAKEN = "NOTIFICATION_CHANNEL_NAME_TAKEN"
NOTIFICATION_CHANNEL_TEST_FAILED = "NOTIFICATION_CHANNEL_TEST_FAILED"
NOTIFICATION_WEBHOOK_URL_INVALID = "NOTIFICATION_WEBHOOK_URL_INVALID"
OVERLAY_SUBNET_LOCKED = "OVERLAY_SUBNET_LOCKED"
OVERLAY_NAME_TAKEN = "OVERLAY_NAME_TAKEN"
OVERLAY_PEER_NOT_FOUND = "OVERLAY_PEER_NOT_FOUND"
+2
View File
@@ -46,6 +46,7 @@ from app.version import APP_VERSION
from app.routers import dashboard as dashboard_router
from app.routers import cluster as cluster_router
from app.routers import services as services_router
from app.routers import gateways as gateways_router
from app.routers import resource_samples as resource_samples_router
from app.services import resource_monitor_service
from app.routers import scheduled_maintenance as scheduled_maintenance_router
@@ -510,6 +511,7 @@ app.include_router(restore_router.router, prefix="/api")
app.include_router(dashboard_router.router, prefix="/api")
app.include_router(cluster_router.router, prefix="/api")
app.include_router(services_router.router, prefix="/api")
app.include_router(gateways_router.router, prefix="/api")
app.include_router(network_router.router, prefix="/api")
app.include_router(snapshots_router.router, prefix="/api")
app.include_router(network_drift_router.router, prefix="/api")
+48 -1
View File
@@ -6,7 +6,7 @@ OPNsense API secrets stay off the database entirely.
import uuid
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, UniqueConstraint
from sqlalchemy import JSON, Boolean, DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -17,6 +17,13 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
DEFAULT_SERVICE_WATCHLIST = (
"unbound", "dnsmasq", "dhcpd", "dhcpd6", "kea-dhcp4", "kea-dhcp6", "ntpd", "openvpn", "wireguard", "strongswan",
)
CHANNEL_TYPES = ("ntfy", "gotify", "telegram", "teams", "matrix")
class NotificationSettings(Base):
"""Single row, id fixed at 1 — see notification_settings_service.get_settings
for the get-or-create helper. The only admin-editable operational config
@@ -55,6 +62,28 @@ class NotificationSettings(Base):
# Off by default: switching it on for an existing fleet reports every long-standing finding at once.
compliance_alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Monitoring alerts (1.24). The gateway alert is on by default (a WAN outage is the one thing every
# operator wants to hear about); resource thresholds and service checks are off, like compliance alerts:
# switching them on for an existing fleet may report long-standing conditions all at once.
gateway_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
resource_threshold_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
service_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Gateway: OPNsense' own verdict (down / loss / delay) decides "offline"; these two add an
# OPNexus threshold on the measured values (0 = off).
gateway_loss_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=20, server_default="20")
gateway_delay_threshold_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=500, server_default="500")
# Resources (0 = that threshold is off). Load is per CPU thread.
disk_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=90, server_default="90")
memory_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=95, server_default="95")
swap_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=50, server_default="50")
load_per_cpu_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=2.0, server_default="2.0")
# Debouncing: consecutive 60 s resource samples that must agree before a problem opens or closes.
alert_confirm_samples: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3")
resource_confirm_samples: Mapped[int] = mapped_column(Integer, nullable=False, default=5, server_default="5")
# OPNsense service names that are reported when they stop (the API cannot tell a crash from a stop on purpose).
# NULL = the default list (an old row never got one).
service_watchlist: Mapped[list | None] = mapped_column(JSON, nullable=True, default=lambda: list(DEFAULT_SERVICE_WATCHLIST))
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@@ -85,3 +114,21 @@ class NotificationProblem(Base):
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class NotificationChannel(Base):
"""An additional delivery channel (ntfy, Gotify, Telegram, Microsoft Teams, Matrix) next to the SMTP
and generic-webhook settings above. Several channels of one type are allowed. ``config`` holds only
non-secret parameters (server URL, topic, chat id, room id); the credential — token, or the whole
webhook URL for Teams — lives in notification_secret_store under ``channel_<id>``, never here."""
__tablename__ = "notification_channels"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), unique=True)
channel_type: Mapped[str] = mapped_column(String(16))
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
+72
View File
@@ -0,0 +1,72 @@
"""The three "take over what exists on the firewall" endpoints that aliases, port forwards, 1:1 NAT and
outbound NAT share (services/object_adopt_service.py has the logic and the evidence):
GET <prefix>/unmanaged?host_id=… candidates found live on the host, with reasons for the refused ones
POST <prefix>/adopt take over one object {host_id, uuid}; returns the new central object
POST <prefix>/adopt-all take over everything adoptable {host_id}; returns adopted + skipped
Like rule adoption this only creates central records; nothing is written to the firewall."""
import uuid
from typing import Callable
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.schemas.adopt import AdoptAllRequest, AdoptAllResult, AdoptObjectRequest, UnmanagedObject
from app.services import object_adopt_service
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
from app.services.policy_service import policy_lock
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
def add_adopt_routes(router: APIRouter, *, kind: str, model, load_options: Callable, to_read: Callable, read_model) -> None:
@router.get("/unmanaged", response_model=list[UnmanagedObject])
async def list_unmanaged(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
try:
return await object_adopt_service.list_unmanaged(db, kind, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
@router.post("/adopt", response_model=read_model, status_code=201)
async def adopt(payload: AdoptObjectRequest, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, payload.host_id)
try:
created = await object_adopt_service.adopt(db, kind, host, payload.uuid)
except object_adopt_service.NotFoundOnHost as exc:
raise api_error(404, ErrorCode.ADOPT_OBJECT_NOT_FOUND) from exc
except object_adopt_service.NotAdoptable as exc:
raise api_error(409, ErrorCode.OBJECT_NOT_ADOPTABLE, reasons=", ".join(exc.reasons)) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
# The new object sits in the session without its relationships loaded; db.get() would return it as-is.
created = (await db.execute(
select(model).where(model.id == created.id).options(*load_options())
.execution_options(populate_existing=True)
)).scalar_one()
await hydrate_hierarchy_targets(db, [created])
return to_read(created, created.targets, created.deployments)
@router.post("/adopt-all", response_model=AdoptAllResult)
async def adopt_all(payload: AdoptAllRequest, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, payload.host_id)
try:
result = await object_adopt_service.adopt_all(db, kind, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
return result
+4
View File
@@ -17,6 +17,7 @@ from app.schemas.alias import DeploymentResult, AliasCreate, AliasRead, AliasEdi
from app.services.alias_deploy_service import confirm_alias, deploy_alias, undeploy_alias, revert_alias_deployment
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/aliases", tags=["aliases"])
@@ -70,6 +71,9 @@ def _to_read(alias: FirewallAlias, hosts: list[OpnsenseHost], deployments: list[
)
add_adopt_routes(router, kind="alias", model=FirewallAlias, load_options=_load_options, to_read=_to_read, read_model=AliasRead)
@router.get("", response_model=list[AliasRead])
async def list_aliases(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FirewallAlias).options(*_load_options()))
+25
View File
@@ -18,6 +18,8 @@ locks are deliberately NOT included here (no fleet-wide list endpoint
exists for those, only per-host, and it's a rarely-triggered flow) —
a documented v1 gap, not an oversight.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -46,7 +48,10 @@ from app.models.rule_deployment import RuleDeployment
from app.models.wireguard_tunnel import WireguardTunnel
from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment
from app.schemas.dashboard import ActivityItem, DashboardHost, DashboardSummary, PendingAction
from app.models.resource_sample import HostResourceSample
from app.services import gateway_health_service
from app.services.host_status_service import get_statuses
from app.services.monitor_checks import STALE_AFTER
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -202,14 +207,34 @@ async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
return pending_actions
async def _gateways_by_host(db: AsyncSession, hosts) -> dict:
"""Gateway state from each host's newest resource sample; hosts without a fresh sample are left out."""
result: dict = {}
now = datetime.now(timezone.utc)
for host in hosts:
row = (
await db.execute(
select(HostResourceSample.sampled_at, HostResourceSample.raw["gateways"].as_json())
.where(HostResourceSample.host_id == host.id)
.order_by(HostResourceSample.sampled_at.desc())
.limit(1)
)
).first()
if row is not None and row[1] is not None and now - row[0] <= STALE_AFTER:
result[host.id] = gateway_health_service.from_compact(row[1])
return result
@router.get("", response_model=DashboardSummary)
async def summary(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
statuses = await get_statuses(hosts)
gateways = await _gateways_by_host(db, hosts)
host_summaries = [
DashboardHost(
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
gateways=gateways.get(host.id),
)
for host in hosts
]
+25
View File
@@ -0,0 +1,25 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.schemas.gateway import GatewayHealth
from app.services import gateway_health_service
from app.services.opnsense_client import OpnsenseAPIError
router = APIRouter(prefix="/hosts/{host_id}/gateways", tags=["host-gateways"])
@router.get("", response_model=list[GatewayHealth])
async def list_gateways(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Live gateway state (loss, delay, deviation, OPNsense' verdict) of one firewall."""
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await gateway_health_service.get_gateway_health(host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
+112 -2
View File
@@ -1,21 +1,26 @@
import uuid
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.database import get_db
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.models.notification import NotificationProblem
from app.models.notification import NotificationChannel, NotificationProblem
from app.schemas.notification import (
NotificationChannelCreate,
NotificationChannelRead,
NotificationChannelUpdate,
NotificationProblemRead,
NotificationSettingsRead,
NotificationSettingsUpdate,
TestNotificationRequest,
)
from app.services import notification_delivery, notification_settings_service
from app.services import notification_channels, notification_delivery, notification_secret_store, notification_settings_service, url_guard
router = APIRouter(prefix="/notifications", tags=["notifications"])
@@ -43,6 +48,11 @@ async def read_settings(db: AsyncSession = Depends(get_db), user: AdminUser = De
@router.put("/settings", response_model=NotificationSettingsRead)
async def update_settings(payload: NotificationSettingsUpdate, db: AsyncSession = Depends(get_db)):
if payload.webhook_url:
try:
payload.webhook_url = url_guard.validate_url(payload.webhook_url)
except ValueError as exc:
raise api_error(422, ErrorCode.NOTIFICATION_WEBHOOK_URL_INVALID, reason=str(exc)) from None
return await notification_settings_service.update_settings(db, payload)
@@ -79,3 +89,103 @@ async def test_notification(payload: TestNotificationRequest, db: AsyncSession =
except notification_delivery.NotificationDeliveryError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
return {"success": True}
# ── Additional channels (ntfy, Gotify, Telegram, Teams, Matrix) ────────────────
def _channel_read(row: NotificationChannel, user: AdminUser) -> NotificationChannelRead:
read = NotificationChannelRead.model_validate(row)
# An ntfy topic or a chat id is as good as a password for whoever knows it: only admins see the settings.
return read if user.role == "admin" else read.model_copy(update={"config": {}})
async def _get_channel(db: AsyncSession, channel_id: uuid.UUID) -> NotificationChannel:
row = await db.get(NotificationChannel, channel_id)
if row is None:
raise api_error(404, ErrorCode.NOTIFICATION_CHANNEL_NOT_FOUND)
return row
def _invalid(exc: ValueError):
return api_error(422, ErrorCode.NOTIFICATION_CHANNEL_INVALID, reason=str(exc))
@router.get("/channels", response_model=list[NotificationChannelRead])
async def list_channels(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
rows = await db.scalars(select(NotificationChannel).order_by(NotificationChannel.name))
return [_channel_read(r, user) for r in rows.all()]
@router.post("/channels", response_model=NotificationChannelRead, status_code=201)
async def create_channel(payload: NotificationChannelCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
try:
config = notification_channels.clean_config(payload.channel_type, payload.config)
secret = notification_channels.clean_secret(payload.channel_type, payload.secret) if payload.secret else None
except ValueError as exc:
raise _invalid(exc) from None
row = NotificationChannel(
name=payload.name.strip(), channel_type=payload.channel_type, enabled=payload.enabled, config=config,
secret_set=secret is not None,
)
row.id = uuid.uuid4()
if secret is None and notification_channels.missing_requirements(row) == "Zugangsdaten fehlen":
raise _invalid(ValueError("Zugangsdaten fehlen"))
db.add(row)
try:
await db.flush()
except IntegrityError:
await db.rollback()
raise api_error(409, ErrorCode.NOTIFICATION_CHANNEL_NAME_TAKEN) from None
if secret is not None:
notification_secret_store.save_secret(notification_channels.secret_name(row.id), secret)
await db.commit()
return _channel_read(row, user)
@router.put("/channels/{channel_id}", response_model=NotificationChannelRead)
async def update_channel(
channel_id: uuid.UUID, payload: NotificationChannelUpdate,
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
):
row = await _get_channel(db, channel_id)
try:
if payload.config is not None:
row.config = notification_channels.clean_config(row.channel_type, payload.config)
new_secret = notification_channels.clean_secret(row.channel_type, payload.secret) if payload.secret else None
except ValueError as exc:
raise _invalid(exc) from None
if payload.name is not None:
row.name = payload.name.strip()
if payload.enabled is not None:
row.enabled = payload.enabled
if payload.clear_secret:
notification_secret_store.delete_secret(notification_channels.secret_name(row.id))
row.secret_set = False
elif new_secret is not None:
notification_secret_store.save_secret(notification_channels.secret_name(row.id), new_secret)
row.secret_set = True
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise api_error(409, ErrorCode.NOTIFICATION_CHANNEL_NAME_TAKEN) from None
return _channel_read(row, user)
@router.delete("/channels/{channel_id}", status_code=204)
async def delete_channel(channel_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
row = await _get_channel(db, channel_id)
notification_secret_store.delete_secret(notification_channels.secret_name(row.id))
await db.delete(row)
await db.commit()
@router.post("/channels/{channel_id}/test")
async def test_channel(channel_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
row = await _get_channel(db, channel_id)
try:
await notification_channels.send_test(row)
except notification_delivery.NotificationDeliveryError as exc:
# The text names the channel and the HTTP status; it never contains a URL or a token.
raise api_error(502, ErrorCode.NOTIFICATION_CHANNEL_TEST_FAILED, reason=str(exc)) from None
return {"success": True}
+4
View File
@@ -20,6 +20,7 @@ from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.one_to_one_deploy_service import confirm_one_to_one, deploy_one_to_one, undeploy_one_to_one, revert_deployment
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/one-to-one", tags=["one-to-one"])
@@ -79,6 +80,9 @@ def _to_read(rule: OneToOneRule, hosts: list[OpnsenseHost], deployments: list[On
)
add_adopt_routes(router, kind="one_to_one", model=OneToOneRule, load_options=_load_options, to_read=_to_read, read_model=OneToOneRead)
@router.get("", response_model=list[OneToOneRead])
async def list_one_to_one_rules(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(OneToOneRule).options(*_load_options()))
+4
View File
@@ -21,6 +21,7 @@ from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.outbound_nat_deploy_service import confirm_outbound_nat, deploy_outbound_nat, undeploy_outbound_nat, revert_deployment
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/outbound-nat", tags=["outbound-nat"])
@@ -82,6 +83,9 @@ def _to_read(rule: OutboundNatRule, hosts: list[OpnsenseHost], deployments: list
)
add_adopt_routes(router, kind="outbound_nat", model=OutboundNatRule, load_options=_load_options, to_read=_to_read, read_model=OutboundNatRead)
@router.get("", response_model=list[OutboundNatRead])
async def list_outbound_nat_rules(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(OutboundNatRule).options(*_load_options()))
+4
View File
@@ -20,6 +20,7 @@ from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.port_forward_deploy_service import confirm_port_forward, deploy_port_forward, undeploy_port_forward, revert_deployment
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/port-forward", tags=["port-forward"])
@@ -80,6 +81,9 @@ def _to_read(rule: PortForwardRule, hosts: list[OpnsenseHost], deployments: list
)
add_adopt_routes(router, kind="port_forward", model=PortForwardRule, load_options=_load_options, to_read=_to_read, read_model=PortForwardRead)
@router.get("", response_model=list[PortForwardRead])
async def list_port_forward_rules(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(PortForwardRule).options(*_load_options()))
+3
View File
@@ -104,6 +104,9 @@ async def updater_status(db: AsyncSession = Depends(get_db), user: AdminUser = D
updater = await updater_client.status()
except updater_client.UpdaterUnavailable as exc:
result["reason"] = str(exc)
# Without the updater the install mode is still known from the image registry setting; the UI uses it
# to not offer enabling the updater on an install that builds from source (it could not update anyway).
result["mode"] = "release" if settings.opnexus_image_repo.strip() else "local"
return result
except updater_client.UpdaterRejected:
result["reason"] = "unreachable"
+48
View File
@@ -0,0 +1,48 @@
import uuid
from pydantic import BaseModel, Field
class AdoptObjectRequest(BaseModel):
host_id: uuid.UUID
uuid: str = Field(min_length=1, max_length=64) # the object's UUID on the firewall
class AdoptAllRequest(BaseModel):
host_id: uuid.UUID
class UnmanagedObject(BaseModel):
"""An alias / NAT rule found on a host that OPNexus does not manage yet (see object_adopt_service.py)."""
uuid: str
title: str
enabled: bool
adoptable: bool # can be adopted on its own right now
# Adoptable in the same "adopt all" run although not on its own (an alias whose referenced aliases come first).
bulk_adoptable: bool
# Codes such as "unsupported_type" or "no_target"; the UI turns them into text.
reasons: list[str]
reason_details: dict[str, str] = Field(default_factory=dict) # code -> extra text (alias names, type, ...)
# Settings OPNexus does not manage that stay untouched on the firewall (log, pool options, tags, ...).
kept: list[str] = Field(default_factory=list)
# Facts for display, depend on the object type.
info: dict[str, str] = Field(default_factory=dict)
class AdoptedObject(BaseModel):
uuid: str
title: str
id: uuid.UUID
class SkippedObject(BaseModel):
uuid: str
title: str
reasons: list[str]
reason_details: dict[str, str] = Field(default_factory=dict)
class AdoptAllResult(BaseModel):
adopted: list[AdoptedObject]
skipped: list[SkippedObject]
+4
View File
@@ -3,6 +3,8 @@ from datetime import datetime
from pydantic import BaseModel
from app.schemas.gateway import GatewayHealth
class DashboardHost(BaseModel):
id: uuid.UUID
@@ -10,6 +12,8 @@ class DashboardHost(BaseModel):
status: str
carp_status: str | None
cluster_name: str | None
# From the last resource sample (no live call); None when there is no sample from the last 3 minutes.
gateways: list[GatewayHealth] | None = None
class PendingAction(BaseModel):
+19
View File
@@ -0,0 +1,19 @@
from typing import Literal
from pydantic import BaseModel
# online: all fine · degraded: OPNsense flags loss and/or latency above the gateway's own limits ·
# offline: down · forced_down: switched off by the admin on purpose · unknown: nothing measured
# (monitoring disabled) or the verdict is still pending.
GatewayState = Literal["online", "degraded", "offline", "forced_down", "unknown"]
class GatewayHealth(BaseModel):
name: str
address: str | None = None
status: str # OPNsense' raw verdict: none | down | force_down | delay | loss | delay+loss | …
state: GatewayState
loss: float | None = None # percent
delay: float | None = None # milliseconds
stddev: float | None = None # milliseconds
monitor: str | None = None # the monitored address
+101 -1
View File
@@ -1,10 +1,12 @@
import re
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.config import settings
from app.models.notification import DEFAULT_SERVICE_WATCHLIST
SmtpSecurity = Literal["none", "starttls", "ssl"]
ProblemType = Literal[
@@ -14,7 +16,9 @@ ProblemType = Literal[
"backup_stale",
"backup_offsite_failed",
"compliance_finding",
"gateway_down", "resource_threshold", "service_down",
]
ChannelType = Literal["ntfy", "gotify", "telegram", "teams", "matrix"]
class NotificationSettingsRead(BaseModel):
@@ -45,10 +49,27 @@ class NotificationSettingsRead(BaseModel):
scheduled_maintenance_failed_enabled: bool = True
backup_failed_enabled: bool = True
compliance_alerts_enabled: bool = False
gateway_down_enabled: bool = True
resource_threshold_enabled: bool = False
service_down_enabled: bool = False
gateway_loss_threshold_pct: int = 20
gateway_delay_threshold_ms: int = 500
disk_threshold_pct: int = 90
memory_threshold_pct: int = 95
swap_threshold_pct: int = 50
load_per_cpu_threshold: float = 2.0
alert_confirm_samples: int = 3
resource_confirm_samples: int = 5
service_watchlist: list[str] = Field(default_factory=lambda: list(DEFAULT_SERVICE_WATCHLIST))
poll_interval_seconds: int
updated_at: datetime
@field_validator("service_watchlist", mode="before")
@classmethod
def _default_watchlist(cls, value):
return list(DEFAULT_SERVICE_WATCHLIST) if value is None else value
class NotificationSettingsUpdate(BaseModel):
"""Partial update — everything optional, only provided fields change.
@@ -84,6 +105,33 @@ class NotificationSettingsUpdate(BaseModel):
scheduled_maintenance_failed_enabled: bool | None = None
backup_failed_enabled: bool | None = None
compliance_alerts_enabled: bool | None = None
gateway_down_enabled: bool | None = None
resource_threshold_enabled: bool | None = None
service_down_enabled: bool | None = None
# 0 switches that single threshold off.
gateway_loss_threshold_pct: int | None = Field(default=None, ge=0, le=100)
gateway_delay_threshold_ms: int | None = Field(default=None, ge=0, le=60000)
disk_threshold_pct: int | None = Field(default=None, ge=0, le=100)
memory_threshold_pct: int | None = Field(default=None, ge=0, le=100)
swap_threshold_pct: int | None = Field(default=None, ge=0, le=100)
load_per_cpu_threshold: float | None = Field(default=None, ge=0, le=1000)
alert_confirm_samples: int | None = Field(default=None, ge=1, le=60)
resource_confirm_samples: int | None = Field(default=None, ge=1, le=60)
service_watchlist: list[str] | None = Field(default=None, max_length=100)
@field_validator("service_watchlist")
@classmethod
def _clean_watchlist(cls, value):
if value is None:
return None
names = []
for raw in value:
name = str(raw).strip()
if not re.fullmatch(r"[A-Za-z0-9._+-]{1,64}", name):
raise ValueError("Dienstnamen bestehen aus Buchstaben, Ziffern und . _ + -")
if name not in names:
names.append(name)
return names
poll_interval_seconds: int | None = Field(
default=None, ge=settings.notification_poll_min_interval_seconds
@@ -106,3 +154,55 @@ class NotificationProblemRead(BaseModel):
class TestNotificationRequest(BaseModel):
channel: Literal["webhook", "email"]
class NotificationChannelRead(BaseModel):
"""Never carries the credential; ``config`` is withheld from non-admins (an ntfy topic is a password)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
channel_type: ChannelType
enabled: bool
config: dict
secret_set: bool
created_at: datetime
updated_at: datetime
def _channel_name(value: str | None) -> str | None:
if value is None:
return None
if not value.strip():
raise ValueError("Name darf nicht leer sein")
return value.strip()
class NotificationChannelCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
channel_type: ChannelType
enabled: bool = True
config: dict = Field(default_factory=dict)
# Token, or for Teams the webhook address. Write-only.
secret: str | None = Field(default=None, max_length=2100)
@field_validator("name")
@classmethod
def _clean_name(cls, value):
return _channel_name(value)
class NotificationChannelUpdate(BaseModel):
"""Partial: omitted fields stay. ``secret`` replaces the credential, ``clear_secret`` removes it."""
name: str | None = Field(default=None, min_length=1, max_length=100)
enabled: bool | None = None
config: dict | None = None
secret: str | None = Field(default=None, max_length=2100)
clear_secret: bool = False
@field_validator("name")
@classmethod
def _clean_name(cls, value):
return _channel_name(value)
+27
View File
@@ -0,0 +1,27 @@
"""Small pieces shared by every "take over what was made by hand on a firewall" feature
(rule_adopt_service.py for filter rules, object_adopt_service.py for aliases and NAT)."""
class NotAdoptable(Exception):
"""The object exists on the host but cannot be described without distortion.
`reasons` are machine-readable codes (the UI translates them); `details` maps a code to
extra text the translation can show (e.g. the alias names a reference points to)."""
def __init__(self, reasons: list[str], details: dict[str, str] | None = None):
super().__init__(", ".join(reasons))
self.reasons = reasons
self.details = details or {}
class NotFoundOnHost(Exception):
"""The UUID is not (or no longer) present in the host's live configuration."""
def on(value) -> bool:
"""OPNsense booleans arrive as "1"/"0" strings, sometimes as real booleans."""
return value in ("1", 1, True)
def clean(value) -> str:
return "" if value is None else str(value).strip()
@@ -0,0 +1,97 @@
"""Gateway health of a firewall (dpinger numbers, as OPNsense reports them).
``get_gateway_health(host)`` is the one reusable entry point: a live read of
/routes/gateway/status, one :class:`GatewayHealth` per gateway. The notification
engine, the host page and the metrics read the SAME numbers from the periodic
resource sample (``compact`` / ``from_compact``), so a scrape or a page view never
fans out to the firewalls; the 1.25 health gate can call ``get_gateway_health``
directly. Parsing is lenient on purpose: "~" means "nothing measured", never zero.
"""
import re
from app.models.host import OpnsenseHost
from app.schemas.gateway import GatewayHealth, GatewayState
from app.services import opnsense_client as client
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
_DEGRADED = {"delay", "loss", "delay+loss"}
def _number(value) -> float | None:
if value is None:
return None
match = _NUMBER.search(str(value))
return float(match.group()) if match else None
def _state(status: str, measured: bool) -> GatewayState:
if status == "down":
return "offline"
if status == "force_down":
return "forced_down"
if status in _DEGRADED:
return "degraded"
if status == "none":
# "none" is also what a gateway without monitoring reports; only numbers make it "online".
return "online" if measured else "unknown"
return "unknown"
def parse_gateway_status(payload) -> list[GatewayHealth]:
items = payload.get("items") if isinstance(payload, dict) else None
result: list[GatewayHealth] = []
for item in items or []:
if not isinstance(item, dict) or not item.get("name"):
continue
status = str(item.get("status") or "").lower()
loss, delay, stddev = _number(item.get("loss")), _number(item.get("delay")), _number(item.get("stddev"))
if loss is not None and loss >= 100:
delay = stddev = None # dpinger prints 0.0 ms when nothing answers: no round trip, not a fast one
measured = loss is not None or delay is not None
address = item.get("address")
monitor = item.get("monitor")
result.append(GatewayHealth(
name=str(item["name"]),
address=None if address in (None, "", "~") else str(address),
status=status or "unknown",
state=_state(status, measured),
loss=loss, delay=delay, stddev=stddev,
monitor=None if monitor in (None, "", "~") else str(monitor),
))
return result
async def get_gateway_health(host: OpnsenseHost) -> list[GatewayHealth]:
"""Live gateway state of ``host``. Raises OpnsenseAPIError if the firewall cannot be asked."""
return parse_gateway_status(await client.get_gateway_status(host))
def compact(gateways: list[GatewayHealth]) -> list[dict]:
"""What the resource sampler stores per sample (small, no addresses)."""
return [
{"name": g.name, "status": g.status, "state": g.state, "loss": g.loss, "delay": g.delay, "stddev": g.stddev}
for g in gateways
]
def from_compact(rows) -> list[GatewayHealth]:
return [
GatewayHealth(
name=r["name"], status=r.get("status") or "unknown", state=r.get("state") or "unknown",
loss=r.get("loss"), delay=r.get("delay"), stddev=r.get("stddev"),
)
for r in rows or [] if isinstance(r, dict) and r.get("name")
]
def is_bad(gateway: GatewayHealth, loss_threshold_pct: float = 0, delay_threshold_ms: float = 0) -> bool:
"""Alarm-worthy: down, or (when the thresholds are on) measured loss / latency beyond them.
A gateway forced down by the admin and one that is not measured are not alarms."""
if gateway.state == "offline":
return True
if loss_threshold_pct and gateway.loss is not None and gateway.loss >= loss_threshold_pct:
return True
if delay_threshold_ms and gateway.delay is not None and gateway.delay >= delay_threshold_ms:
return True
return False
+27 -3
View File
@@ -5,7 +5,7 @@ fan out live calls to every firewall (an offline host would stall it past the
scraper's timeout and a 15 s scrape interval would hammer the fleet). The
inputs are what OPNexus' own pollers already store:
* host reachability and resources — the 60 s resource sampler (a host that
* host reachability, resources, gateway health and service state — the 60 s resource sampler (a host that
produced no sample for ``STALE_AFTER`` is reported down),
* CARP role — the latest observed transition (carp_events),
* open problems — the notification engine's problem table,
@@ -19,11 +19,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.carp_event import CarpEvent
from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost
from app.models.notification import NotificationProblem
from app.models.notification import DEFAULT_SERVICE_WATCHLIST, NotificationProblem
from app.models.resource_sample import HostResourceSample
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.routers.dashboard import collect_pending_actions
from app.services import compliance_service
from app.services import compliance_service, gateway_health_service, notification_settings_service
from app.version import APP_VERSION
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
@@ -99,6 +99,14 @@ async def render(db: AsyncSession) -> str:
out.family("opnexus_host_memory_used_percent", "Memory in use, percent (last sample).")
out.family("opnexus_host_load1", "1-minute load average (last sample).")
out.family("opnexus_host_disk_used_percent", "Fullest disk, percent (last sample).")
out.family("opnexus_host_swap_used_percent", "Swap in use, percent (last sample; absent without swap).")
out.family("opnexus_host_gateway_up", "1 if the gateway is not down (online, degraded or not monitored), 0 if OPNsense reports it down.")
out.family("opnexus_host_gateway_loss_percent", "Packet loss to the gateway monitor address, percent (last sample).")
out.family("opnexus_host_gateway_delay_seconds", "Round-trip time to the gateway monitor address (last sample).")
out.family("opnexus_host_gateway_stddev_seconds", "Round-trip time deviation to the gateway monitor address (last sample).")
out.family("opnexus_host_service_running", "1 if a watched service (Settings > Notifications) runs, 0 if it is stopped (last sample).")
configured = (await notification_settings_service.get_settings(db)).service_watchlist
watchlist = set(DEFAULT_SERVICE_WATCHLIST if configured is None else configured)
out.family("opnexus_host_carp_master", "1 if the host's last observed CARP role is MASTER, 0 if BACKUP; absent if unknown.")
for host in hosts:
labels = {"host": host.name, "cluster": host.cluster_name or ""}
@@ -115,6 +123,22 @@ async def render(db: AsyncSession) -> str:
):
if value is not None:
out.sample(name, value, **labels)
if sample is not None:
raw = sample.raw or {}
if raw.get("swap_used_pct") is not None:
out.sample("opnexus_host_swap_used_percent", raw["swap_used_pct"], **labels)
for gw in gateway_health_service.from_compact(raw.get("gateways")):
gw_labels = {**labels, "gateway": gw.name}
out.sample("opnexus_host_gateway_up", 0 if gw.state == "offline" else 1, **gw_labels)
for name, value, factor in (
("opnexus_host_gateway_loss_percent", gw.loss, 1), ("opnexus_host_gateway_delay_seconds", gw.delay, 0.001),
("opnexus_host_gateway_stddev_seconds", gw.stddev, 0.001),
):
if value is not None:
out.sample(name, round(value * factor, 6), **gw_labels)
for key, running in sorted((raw.get("services") or {}).items()):
if key.split("/")[0] in watchlist:
out.sample("opnexus_host_service_running", running, **labels, service=key.split("/")[0], instance=key.split("/", 1)[1] if "/" in key else None)
if carp_state.get(host.id) in ("MASTER", "BACKUP"):
out.sample("opnexus_host_carp_master", 1 if carp_state[host.id] == "MASTER" else 0, **labels)
+234
View File
@@ -0,0 +1,234 @@
"""Monitoring alerts (1.24): gateway down, resource thresholds, stopped services.
Same contract as notification_checks.py (each check returns {dedup_key: context} of what is
bad right now), with one addition: they never judge a single reading. The inputs are the
60-second samples the resource sampler stores (resource_monitor_service, HostResourceSample),
and a problem needs the last N samples to agree before it opens AND before it closes again:
* all N bad -> problem,
* all N good -> no problem,
* anything in between -> keep the current state (open stays open, closed stays closed).
So a single spike never alerts, a flapping link does not open and close the alert every few
minutes, and a sample that could not be read ("unknown") neither raises nor clears anything.
A host without a fresh sample is not judged at all (check_host_offline reports it); its open
problems are kept.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.notification import DEFAULT_SERVICE_WATCHLIST, NotificationProblem
from app.models.resource_sample import HostResourceSample
from app.services import gateway_health_service, notification_settings_service
from app.services.notification_dedup import Problems
# The sampler runs every 60 s; three missed rounds mean the host is not reachable (same limit as metrics_service).
STALE_AFTER = timedelta(seconds=180)
# How far back a service must have been seen running before its absence is reported (samples, ~2 h at 60 s).
SERVICE_EVIDENCE_SAMPLES = 120
_DISK_TYPES = {"ufs", "zfs"}
def verdict(flags: list[bool | None], confirm: int, is_open: bool) -> bool:
"""flags: newest first; True = bad, False = good, None = unknown."""
window = flags[:confirm]
if len(window) >= confirm:
if all(f is True for f in window):
return True
if all(f is False for f in window):
return False
return is_open
async def _enabled_hosts(db: AsyncSession) -> list[OpnsenseHost]:
return list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)))).all())
async def _open_keys(db: AsyncSession, problem_type: str) -> set[str]:
rows = await db.scalars(
select(NotificationProblem.dedup_key).where(
NotificationProblem.problem_type == problem_type, NotificationProblem.status == "open"
)
)
return set(rows.all())
async def _recent(db: AsyncSession, host_id, limit: int, *raw_keys: str) -> list[dict]:
"""The newest ``limit`` samples of a host, newest first, with just the JSON keys asked for."""
columns = [HostResourceSample.sampled_at, HostResourceSample.mem_used_pct, HostResourceSample.load_avg_1m]
columns += [HostResourceSample.raw[key].as_json() for key in raw_keys]
rows = (
await db.execute(
select(*columns).where(HostResourceSample.host_id == host_id).order_by(HostResourceSample.sampled_at.desc()).limit(limit)
)
).all()
return [
{"sampled_at": r[0], "mem_used_pct": r[1], "load_avg_1m": r[2], **{k: r[3 + i] for i, k in enumerate(raw_keys)}}
for r in rows
]
def _fresh(samples: list[dict]) -> bool:
return bool(samples) and datetime.now(timezone.utc) - samples[0]["sampled_at"] <= STALE_AFTER
def _keys_of_host(open_keys: set[str], host: OpnsenseHost) -> set[str]:
prefix = f"{host.id}:"
return {k[len(prefix):] for k in open_keys if k.startswith(prefix)}
# ── Gateways ────────────────────────────────────────────────────────────────
def _gateway_reason(g) -> str:
if g.state == "offline":
return "Gateway offline"
parts = []
if g.loss is not None:
parts.append(f"Paketverlust {g.loss:g} %")
if g.delay is not None:
parts.append(f"Latenz {g.delay:g} ms")
return ", ".join(parts) or g.status
async def check_gateway_down(db: AsyncSession) -> dict[str, dict]:
cfg = await notification_settings_service.get_settings(db)
confirm = max(1, cfg.alert_confirm_samples)
loss_limit, delay_limit = cfg.gateway_loss_threshold_pct, cfg.gateway_delay_threshold_ms
open_keys = await _open_keys(db, "gateway_down")
problems = Problems()
for host in await _enabled_hosts(db):
samples = await _recent(db, host.id, confirm, "gateways")
if not _fresh(samples):
problems.preserve_prefixes.add(f"{host.id}:")
continue
parsed = [
None if s["gateways"] is None else {g.name: g for g in gateway_health_service.from_compact(s["gateways"])}
for s in samples
]
names = {name for p in parsed if p for name in p} | _keys_of_host(open_keys, host)
for name in sorted(names):
flags: list[bool | None] = []
for p in parsed:
if p is None:
flags.append(None)
else:
g = p.get(name) # a gateway that no longer exists is "fine": its problem resolves
flags.append(False if g is None else gateway_health_service.is_bad(g, loss_limit, delay_limit))
key = f"{host.id}:{name}"
if not verdict(flags, confirm, key in open_keys):
continue
latest = next((p[name] for p in parsed if p and name in p), None)
problems[key] = {
"host_id": str(host.id), "host_name": host.name, "gateway": name,
"grund": _gateway_reason(latest) if latest else "keine aktuellen Messwerte",
"status": latest.status if latest else None,
"paketverlust_prozent": latest.loss if latest else None,
"latenz_ms": latest.delay if latest else None,
}
return problems
# ── Resources ───────────────────────────────────────────────────────────────
def disk_groups(devices) -> dict[str, tuple[str, float]]:
"""{group: (label, used %)} — one entry per ZFS pool / UFS device, the fullest dataset of a pool wins."""
groups: dict[str, tuple[str, float]] = {}
for dev in devices or []:
if not isinstance(dev, dict) or dev.get("type") not in _DISK_TYPES:
continue
try:
pct = float(dev["used_pct"])
except (KeyError, TypeError, ValueError):
continue
device = str(dev.get("device") or dev.get("mountpoint") or "?")
group = device.split("/")[0] if dev["type"] == "zfs" else device
label = group if dev["type"] == "zfs" else str(dev.get("mountpoint") or device)
if group not in groups or pct > groups[group][1]:
groups[group] = (label, pct)
return groups
async def check_resource_threshold(db: AsyncSession) -> dict[str, dict]:
cfg = await notification_settings_service.get_settings(db)
confirm = max(1, cfg.resource_confirm_samples)
open_keys = await _open_keys(db, "resource_threshold")
problems = Problems()
def over(value, limit) -> bool | None:
if not limit or value is None:
return None if value is None else False
return value >= limit
for host in await _enabled_hosts(db):
samples = await _recent(db, host.id, confirm, "disk", "swap_used_pct", "cpu_threads")
if not _fresh(samples):
problems.preserve_prefixes.add(f"{host.id}:")
continue
base = {"host_id": str(host.id), "host_name": host.name}
was_open = _keys_of_host(open_keys, host)
# Disk: one problem per pool/device.
disk = [None if s["disk"] is None else disk_groups(s["disk"]) for s in samples]
group_names = {g for d in disk if d for g in d} | {k[5:] for k in was_open if k.startswith("disk:")}
for group in sorted(group_names):
flags = [None if d is None else (over(d[group][1], cfg.disk_threshold_pct) if group in d else False) for d in disk]
if verdict(flags, confirm, f"disk:{group}" in was_open):
label, pct = next((d[group] for d in disk if d and group in d), (group, None))
problems[f"{host.id}:disk:{group}"] = {
**base, "ressource": f"Festplatte {label}", "wert_prozent": pct, "schwelle_prozent": cfg.disk_threshold_pct,
}
def simple(key: str, label: str, flags: list, value, threshold, unit: str) -> None:
if verdict(flags, confirm, key in was_open):
problems[f"{host.id}:{key}"] = {**base, "ressource": label, "wert": f"{value}{unit}", "schwelle": f"{threshold}{unit}"}
latest = samples[0]
simple("memory", "Arbeitsspeicher", [over(s["mem_used_pct"], cfg.memory_threshold_pct) for s in samples],
latest["mem_used_pct"], cfg.memory_threshold_pct, " %")
simple("swap", "Swap", [over(s["swap_used_pct"], cfg.swap_threshold_pct) for s in samples],
latest["swap_used_pct"], cfg.swap_threshold_pct, " %")
def per_cpu(s):
if s["load_avg_1m"] is None or not s["cpu_threads"]:
return None # thread count unknown (older firmware): the load cannot be judged
return s["load_avg_1m"] / s["cpu_threads"]
simple("cpu", "CPU-Last (pro Thread)", [over(per_cpu(s), cfg.load_per_cpu_threshold) for s in samples],
None if per_cpu(latest) is None else round(per_cpu(latest), 2), cfg.load_per_cpu_threshold, "")
return problems
# ── Services ────────────────────────────────────────────────────────────────
async def check_service_down(db: AsyncSession) -> dict[str, dict]:
cfg = await notification_settings_service.get_settings(db)
confirm = max(1, cfg.alert_confirm_samples)
watch = set(cfg.service_watchlist if cfg.service_watchlist is not None else DEFAULT_SERVICE_WATCHLIST)
open_keys = await _open_keys(db, "service_down")
problems = Problems()
for host in await _enabled_hosts(db):
samples = await _recent(db, host.id, confirm + SERVICE_EVIDENCE_SAMPLES, "services")
if not _fresh(samples):
problems.preserve_prefixes.add(f"{host.id}:")
continue
listings = [s["services"] for s in samples]
was_open = _keys_of_host(open_keys, host)
candidates = {k for k in (listings[0] or {}) if k.split("/")[0] in watch} | was_open
for svc in sorted(candidates):
if svc.split("/")[0] not in watch:
continue # taken off the watchlist: the problem resolves
flags = [None if sv is None else (sv[svc] == 0 if svc in sv else False) for sv in listings]
bad = verdict(flags, confirm, svc in was_open)
# Only a service that was seen running before counts: one that never ran (enabled but idle,
# e.g. DHCPv6 without a prefix) or was stopped on purpose long ago is not a failure.
if bad and svc not in was_open and not any(sv and sv.get(svc) == 1 for sv in listings[confirm:]):
bad = False
if bad:
problems[f"{host.id}:{svc}"] = {
"host_id": str(host.id), "host_name": host.name, "dienst": svc.split("/")[0],
"hinweis": "Dienst läuft nicht mehr; absichtlich gestoppt? Dann aus der Überwachungsliste nehmen.",
}
return problems
@@ -0,0 +1,250 @@
"""Extra notification channels: ntfy, Gotify, Telegram, Microsoft Teams, Matrix.
They sit beside SMTP and the generic webhook (notification_delivery.py) and reuse its message
text. Each channel is a row in ``notification_channels`` (see models/notification.py); the
credential never touches the database: it is stored through notification_secret_store as
``channel_<id>`` (mode 0600 file) and read only at send time. Where the credential is part of the
URL (a Telegram bot token, the whole Teams webhook URL) the URL is built per request, and the
HTTP request log is muted while it is in flight (``url_guard.muted``), so neither the logs nor an
error message ever contain it.
API formats, checked against the vendors' documentation:
* ntfy — JSON publish: POST <server>/ with {"topic", "title", "message", "priority", "tags"}
(docs.ntfy.sh/publish, "Publish as JSON"); optional ``Authorization: Bearer <token>``.
* Gotify — POST <server>/message, header ``X-Gotify-Key: <app token>`` (keeps the token out of
the URL), JSON {"title", "message", "priority"} (gotify.net/docs/pushmsg).
* Telegram — POST <api>/bot<token>/sendMessage, JSON {"chat_id", "text"}; text limit 4096
characters; answers {"ok": true, ...} (core.telegram.org/bots/api#sendmessage).
* Teams — POST to the (Workflows / incoming) webhook URL with a message envelope carrying an
Adaptive Card (learn.microsoft.com ... add-incoming-webhook); the URL is the secret.
* Matrix — PUT <homeserver>/_matrix/client/v3/rooms/<roomId>/send/m.room.message/<txnId> with
``Authorization: Bearer <access token>``, JSON {"msgtype": "m.text", "body"}; a fresh
transaction id per message (Matrix client-server API, "Sending events to a room").
"""
import re
import uuid
from urllib.parse import quote
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.notification import CHANNEL_TYPES, NotificationChannel, NotificationProblem
from app.services import notification_delivery, notification_secret_store, url_guard
from app.services.notification_delivery import NotificationDeliveryError
TELEGRAM_LIMIT = 4096
TEAMS_LIMIT = 20000
_NTFY_TOPIC = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_MATRIX_ROOM = re.compile(r"^![^\s:]+:[^\s/]+$")
# type -> (required config keys, secret required?)
_SPEC = {
"ntfy": ({"topic"}, False),
"gotify": ({"server_url"}, True),
"telegram": ({"chat_id"}, True),
"teams": (set(), True),
"matrix": ({"homeserver_url", "room_id"}, True),
}
assert set(_SPEC) == set(CHANNEL_TYPES)
def secret_name(channel_id) -> str:
return f"channel_{channel_id}"
def clean_config(channel_type: str, config: dict) -> dict:
"""Validates and normalises the non-secret settings of a channel. Raises ValueError (German reason)."""
if channel_type not in _SPEC:
raise ValueError("Unbekannter Kanaltyp")
config = dict(config or {})
out: dict = {}
def url(key: str, default: str | None = None) -> str | None:
raw = str(config.get(key) or "").strip() or default
if raw is None:
return None
return url_guard.validate_url(raw).rstrip("/")
def integer(key: str, low: int, high: int, default: int) -> int:
value = config.get(key)
if value in (None, ""):
return default
try:
number = int(value)
except (TypeError, ValueError):
raise ValueError(f"{key}: ganze Zahl erwartet") from None
if not low <= number <= high:
raise ValueError(f"{key}: Wert zwischen {low} und {high} erwartet")
return number
if channel_type == "ntfy":
out["server_url"] = url("server_url", "https://ntfy.sh")
topic = str(config.get("topic") or "").strip()
if not _NTFY_TOPIC.match(topic):
raise ValueError("Thema: 1–64 Zeichen aus Buchstaben, Ziffern, _ und -")
out["topic"] = topic
out["priority"] = integer("priority", 1, 5, 4)
elif channel_type == "gotify":
out["server_url"] = url("server_url")
if not out["server_url"]:
raise ValueError("Serveradresse fehlt")
out["priority"] = integer("priority", 0, 10, 5)
elif channel_type == "telegram":
chat_id = str(config.get("chat_id") or "").strip()
if not re.match(r"^(-?\d{1,20}|@[A-Za-z0-9_]{4,64})$", chat_id):
raise ValueError("Chat-ID: Zahl (z. B. -1001234567890) oder @Kanalname")
out["chat_id"] = chat_id
out["api_base"] = url("api_base", "https://api.telegram.org")
elif channel_type == "matrix":
out["homeserver_url"] = url("homeserver_url")
if not out["homeserver_url"]:
raise ValueError("Homeserver-Adresse fehlt")
room = str(config.get("room_id") or "").strip()
if not _MATRIX_ROOM.match(room):
raise ValueError("Raum-ID: Form !abc123:example.org (nicht der Alias #raum:…)")
out["room_id"] = room
return out
def clean_secret(channel_type: str, secret: str) -> str:
value = (secret or "").strip()
if not value:
raise ValueError("Zugangsdaten fehlen")
if channel_type == "teams":
value = url_guard.validate_url(value, max_length=2000)
if not value.startswith("https://"):
raise ValueError("Die Teams-Webhook-Adresse muss mit https:// beginnen")
elif any(c.isspace() for c in value) or len(value) > 500:
raise ValueError("Token: keine Leerzeichen, höchstens 500 Zeichen")
return value
def missing_requirements(channel: NotificationChannel) -> str | None:
required, needs_secret = _SPEC[channel.channel_type]
if any(not channel.config.get(k) for k in required):
return "Konfiguration unvollständig"
if needs_secret and not channel.secret_set:
return "Zugangsdaten fehlen"
return None
# ── Message formatting (one function per channel, pure — unit-testable) ───────────
def _parts(problem: NotificationProblem, transition: str) -> tuple[str, str]:
"""(title, details) — the title repeated at the top of render_message's body is dropped."""
subject, body = notification_delivery.render_message(problem, transition)
details = body.split("\n", 2)[2] if body.count("\n") >= 2 else ""
return subject, details.strip()
def _clip(text: str, limit: int) -> str:
return text if len(text) <= limit else text[: limit - 1] + "…"
def format_ntfy(config: dict, problem, transition: str) -> dict:
title, details = _parts(problem, transition)
resolved = transition == "resolved"
return {
"topic": config["topic"], "title": title, "message": details or title,
"priority": 3 if resolved else int(config.get("priority", 4)),
"tags": ["white_check_mark"] if resolved else ["warning"],
}
def format_gotify(config: dict, problem, transition: str) -> dict:
title, details = _parts(problem, transition)
return {"title": title, "message": details or title, "priority": 1 if transition == "resolved" else int(config.get("priority", 5))}
def format_telegram(config: dict, problem, transition: str) -> dict:
title, details = _parts(problem, transition)
return {
"chat_id": config["chat_id"], "text": _clip(f"{title}\n\n{details}".strip(), TELEGRAM_LIMIT),
"disable_web_page_preview": True,
}
def format_teams(config: dict, problem, transition: str) -> dict:
title, details = _parts(problem, transition)
body: list[dict] = [{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium", "wrap": True}]
if details:
body.append({"type": "TextBlock", "text": _clip(details, TEAMS_LIMIT), "wrap": True})
return {
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard", "version": "1.4", "body": body,
},
}],
}
def format_matrix(config: dict, problem, transition: str) -> dict:
title, details = _parts(problem, transition)
return {"msgtype": "m.text", "body": f"{title}\n\n{details}".strip()}
# ── Sending ───────────────────────────────────────────────────────────────────────
def _request_for(channel: NotificationChannel, secret: str, problem, transition: str) -> tuple[str, str, dict, dict, str | None]:
"""(method, url, headers, json body, expected "ok" check key)."""
cfg, kind = channel.config, channel.channel_type
if kind == "ntfy":
headers = {"Authorization": f"Bearer {secret}"} if secret else {}
return "POST", cfg["server_url"].rstrip("/") + "/", headers, format_ntfy(cfg, problem, transition), None
if kind == "gotify":
return "POST", cfg["server_url"].rstrip("/") + "/message", {"X-Gotify-Key": secret}, format_gotify(cfg, problem, transition), None
if kind == "telegram":
return "POST", f"{cfg['api_base'].rstrip('/')}/bot{secret}/sendMessage", {}, format_telegram(cfg, problem, transition), "ok"
if kind == "teams":
return "POST", secret, {}, format_teams(cfg, problem, transition), None
room = quote(cfg["room_id"], safe="")
url = f"{cfg['homeserver_url'].rstrip('/')}/_matrix/client/v3/rooms/{room}/send/m.room.message/{uuid.uuid4().hex}"
return "PUT", url, {"Authorization": f"Bearer {secret}"}, format_matrix(cfg, problem, transition), None
async def send(channel: NotificationChannel, problem: NotificationProblem, transition: str) -> None:
problem_missing = missing_requirements(channel)
if problem_missing:
raise NotificationDeliveryError(f"Kanal „{channel.name}“: {problem_missing}")
secret = notification_secret_store.read_secret(secret_name(channel.id)) or ""
if channel.secret_set and not secret:
raise NotificationDeliveryError(f"Kanal „{channel.name}“: Zugangsdaten nicht lesbar – bitte neu setzen")
method, url, headers, payload, ok_key = _request_for(channel, secret, problem, transition)
label = f"Kanal „{channel.name}“ ({channel.channel_type})"
with url_guard.muted():
try:
url_guard.validate_url(url, max_length=2100)
await url_guard.ensure_resolved_safe(url)
except ValueError as exc:
raise NotificationDeliveryError(f"{label}: {exc}") from None
try:
async with httpx.AsyncClient(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
resp = await client.request(method, url, json=payload, headers=headers)
resp.raise_for_status()
if ok_key and not (resp.json() or {}).get(ok_key):
raise NotificationDeliveryError(f"{label}: Zustellung abgelehnt")
except httpx.HTTPError as exc:
# str(exc) names the full URL, which for Telegram/Teams is the credential itself.
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
raise NotificationDeliveryError(f"{label}: Zustellung fehlgeschlagen ({reason})") from None
except ValueError:
raise NotificationDeliveryError(f"{label}: Antwort nicht lesbar") from None
async def send_test(channel: NotificationChannel) -> None:
fake = NotificationProblem(
problem_type="test", dedup_key="test",
context={"message": "Dies ist eine Testbenachrichtigung von OPNexus."},
)
await send(channel, fake, "opened")
async def enabled_channels(db: AsyncSession) -> list[NotificationChannel]:
rows = await db.scalars(select(NotificationChannel).where(NotificationChannel.enabled.is_(True)).order_by(NotificationChannel.name))
return list(rows.all())
+15 -4
View File
@@ -16,7 +16,7 @@ import httpx
from app.config import settings
from app.models.notification import NotificationProblem, NotificationSettings
from app.services import notification_secret_store
from app.services import notification_secret_store, url_guard
class NotificationDeliveryError(Exception):
@@ -37,6 +37,10 @@ _LABELS = {
"backup_stale": "Kein aktuelles Backup",
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
"compliance_finding": "Sicherheitsbefund",
"gateway_down": "Gateway gestört",
"resource_threshold": "Ressourcen-Schwelle überschritten",
"service_down": "Dienst ausgefallen",
"test": "Testbenachrichtigung",
}
@@ -84,9 +88,16 @@ async def send_webhook(settings_row: NotificationSettings, problem: Notification
headers["X-OPNexus-Signature"] = f"sha256={signature}"
try:
async with httpx.AsyncClient(timeout=settings.opnsense_timeout) as client:
resp = await client.post(settings_row.webhook_url, content=raw, headers=headers)
resp.raise_for_status()
url_guard.validate_url(settings_row.webhook_url)
await url_guard.ensure_resolved_safe(settings_row.webhook_url)
except ValueError as exc:
raise NotificationDeliveryError(f"Webhook-Adresse nicht erlaubt: {exc}") from None
try:
# follow_redirects stays off: a redirect must not move the request to an address the check above never saw.
async with httpx.AsyncClient(timeout=settings.opnsense_timeout, follow_redirects=False) as client:
with url_guard.muted():
resp = await client.post(settings_row.webhook_url, content=raw, headers=headers)
resp.raise_for_status()
except httpx.HTTPError as exc:
# str(exc) names the full URL, which often is the credential itself
# (token in path/query) — keep it out of messages and logs.
+18 -5
View File
@@ -8,8 +8,8 @@ from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import NotificationProblem, NotificationSettings
from app.services import notification_checks, notification_dedup, notification_delivery
from app.models.notification import NotificationChannel, NotificationProblem, NotificationSettings
from app.services import monitor_checks, notification_channels, notification_checks, notification_dedup, notification_delivery
from app.services import notification_settings_service
logger = logging.getLogger(__name__)
@@ -34,6 +34,9 @@ _CHECKS = [
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
("compliance_finding", "compliance_alerts_enabled", notification_checks.check_compliance),
("gateway_down", "gateway_down_enabled", monitor_checks.check_gateway_down),
("resource_threshold", "resource_threshold_enabled", monitor_checks.check_resource_threshold),
("service_down", "service_down_enabled", monitor_checks.check_service_down),
]
# Event-like problems: when they drop out of the window nothing was "fixed", so no
@@ -41,7 +44,9 @@ _CHECKS = [
_NO_RESOLVED_NOTICE = {"scheduled_maintenance_failed"}
async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None:
async def _deliver(
settings_row: NotificationSettings, problem: NotificationProblem, transition: str, channels: list[NotificationChannel] = (),
) -> None:
"""Best-effort, not an audit trail: attempted once per transition and
never retried, even on failure — avoids retry-storming a broken webhook
URL or SMTP relay every poll cycle."""
@@ -60,6 +65,13 @@ async def _deliver(settings_row: NotificationSettings, problem: NotificationProb
except notification_delivery.NotificationDeliveryError:
logger.exception("Email delivery failed for %s/%s", problem.problem_type, problem.dedup_key)
for channel in channels:
try:
await notification_channels.send(channel, problem, transition)
except notification_delivery.NotificationDeliveryError:
# The error text names the channel and the HTTP status, never the URL or token.
logger.exception("Channel %s (%s) delivery failed for %s/%s", channel.name, channel.channel_type, problem.problem_type, problem.dedup_key)
if transition == "resolved":
problem.resolved_notified_at = _now()
else:
@@ -69,6 +81,7 @@ async def _deliver(settings_row: NotificationSettings, problem: NotificationProb
async def run_notification_checks(db: AsyncSession) -> dict:
settings_row = await notification_settings_service.get_settings(db)
result = {"opened": 0, "resolved": 0}
channels = await notification_channels.enabled_channels(db)
for problem_type, enabled_flag, check_fn in _CHECKS:
if not getattr(settings_row, enabled_flag):
@@ -85,9 +98,9 @@ async def run_notification_checks(db: AsyncSession) -> dict:
opened, resolved = await notification_dedup.diff_and_update(db, problem_type, current)
for problem in opened:
await _deliver(settings_row, problem, "opened")
await _deliver(settings_row, problem, "opened", channels)
for problem in resolved:
await _deliver(settings_row, problem, "resolved")
await _deliver(settings_row, problem, "resolved", channels)
result["opened"] += len(opened)
result["resolved"] += len(resolved)
@@ -26,6 +26,10 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled",
"gateway_loss_threshold_pct", "gateway_delay_threshold_ms", "disk_threshold_pct", "memory_threshold_pct",
"swap_threshold_pct", "load_per_cpu_threshold", "alert_confirm_samples", "resource_confirm_samples",
"service_watchlist",
"poll_interval_seconds",
]
for field in simple_fields:
@@ -0,0 +1,545 @@
"""Takes over aliases, port forwards, 1:1 NAT and outbound NAT rules that were created by hand on a
firewall, so they can be edited, versioned and deployed from OPNexus like any other object. This is
the generalisation of rule_adopt_service.py (filter rules keep their own module: their model has
more special cases and the code is relied upon).
Adoption itself writes NOTHING to the firewall. It records the object as it is right now (read fresh
from the host at adoption time, never from what the browser sent) together with its OPNsense UUID
(`remote_uuid` = the deployment's opnsense_*_uuid), and stores the payload OPNexus would send for it as
the deployment's last_payload. So the preview directly afterwards says "unchanged", a later deploy
updates the same object via setItem/setRule (no duplicate), and the normal preview → push →
confirm/auto-revert flow applies from the first change on.
Only objects OPNexus can describe without distortion are adopted. Anything the model cannot express
is refused with machine-readable reasons (the UI translates them) instead of being imported in a way
that would silently change its meaning on the next edit. Fields OPNexus does not manage (log flag of a
port forward, pool options, tags, categories, ...) are never sent, and OPNsense's set*/setItem only
overwrites the posted fields (ApiMutableModelControllerBase::setBase -> setNodes), so they stay as
they are; the candidate list shows them as `kept`.
Evidence (OPNsense core master, src/opnsense/mvc/app/…, read 2026-10-20, and live against OPNsense
26.7 on test-fw-3 on the same day with hand-made objects of every case below):
* Aliases — controllers/OPNsense/Firewall/Api/AliasController.php::searchItemAction -> searchBase
("aliases.alias"), model models/OPNsense/Firewall/Alias.xml. Row keys used: uuid, name, type
("host", "network", "port", "networkgroup", "mac", "asn", plus the unsupported "url", "urltable",
"urljson", "geoip", "dynipv6host", "authgroup", and the system types "internal"/"external"),
enabled "1"/"0", content (entries separated by "\n", live-verified; for "authgroup" the controller
rewrites it to group names), description, proto (only meaningful for asn/geoip). The built-in rows
are bogons, bogonsv6, sshlockout, virusprot (type external) and one __<if>_network per interface
(type internal); they are OPNsense's own and never adoptable. A "networkgroup" lists alias NAMES;
host/network/port aliases may also contain alias names (nesting).
* Port forward — controllers/OPNsense/Firewall/Api/DNatController.php::searchRuleAction merges the
configured rules (getFlatNodes: dotted keys "source.network", "source.port", "source.not",
"destination.network", "destination.port", "destination.not", plus descr, disabled, interface,
ipprotocol, protocol, target, local-port, natreflection, nordr, pass, log, poolopts, tag, tagged,
nosync, categories, sequence) with synthetic automatic rows (uuid "automatic_*"/"lockout_*",
is_automatic true) that setRule/delRule cannot target. models/OPNsense/Firewall/DNat.xml: interface is
Multiple=Y (comma separated), ipprotocol may be blank/inet/inet6/inet46, protocol blank = any,
`nordr` ("no redirect") requires an empty target/local-port (live: "Target must be empty when No
RDR is set"), `pass` ("" manual / "pass" / "rule") links an associated filter rule, natreflection is
""/purenat/disable. OPNexus sends pass "" and not "0", so a rule using one of those would change
meaning on its first edit and is refused.
* 1:1 NAT — OneToOneController::searchRuleAction (flat keys), model Filter.xml onetoone.rule:
enabled, log, interface, type (binat/nat), source_net/source_not, destination_net/destination_not,
external, natreflection ""/enable/disable, description. All of these are managed by OPNexus.
* Outbound NAT — SourceNatController::searchRuleAction returns the configured rules ONLY when the
global mode (filter.settings.nat.snat_mode) is hybrid or advanced; in automatic/disabled mode the
manual rules do not show up at all, so adopting is refused up front (outbound_nat_mode.py) instead
of presenting an empty list. Model Filter.xml snatrules.rule: `nonat` rules ("do not NAT") are not
expressible and refused; staticnatport, poolopts, log, tag, tagged, nosync, endpoint-independent
are left alone.
"""
import re
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, get_args
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.models.alias import FirewallAlias
from app.models.alias_deployment import AliasDeployment
from app.models.host import OpnsenseHost
from app.models.one_to_one import OneToOneRule
from app.models.one_to_one_deployment import OneToOneDeployment
from app.models.outbound_nat import OutboundNatRule
from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.port_forward import PortForwardRule
from app.models.port_forward_deployment import PortForwardDeployment
from app.schemas.alias import AliasType
from app.services import alias_deploy_service, alias_policy_service
from app.services import one_to_one_deploy_service, one_to_one_policy_service
from app.services import opnsense_client, outbound_nat_deploy_service, outbound_nat_policy_service
from app.services import port_forward_deploy_service, port_forward_policy_service
from app.services.adopt_common import NotAdoptable, NotFoundOnHost, clean, on
from app.services.nat_live_service import split_rows
from app.services.outbound_nat_mode import host_mode
ADOPT_NOTE = "Von Firewall übernommen"
ADOPT_MESSAGE = "Von der Firewall übernommen"
ALIAS_NAME = re.compile(r"^[A-Za-z0-9_]+$") # schemas/alias.py AliasCreate.name
ALIAS_TYPES = frozenset(get_args(AliasType))
ALIAS_CONTENT_MAX = 8000 # schemas/alias.py AliasCreate.content
# OPNsense's own aliases that are not user objects (Firewall/Alias.php: bogons & co. are `external`
# rows created by the system, __<if>_network are `internal`).
SYSTEM_ALIAS_NAMES = frozenset({"bogons", "bogonsv6", "sshlockout", "virusprot"})
@dataclass
class Entry:
row: dict
reasons: list[str] = field(default_factory=list)
details: dict[str, str] = field(default_factory=dict)
bulk: bool = False # not adoptable alone yet, but adoptable in the same run (its references come first)
@property
def uuid(self) -> str:
return clean(self.row.get("uuid"))
@dataclass(frozen=True)
class Kind:
key: str
model: type
deployment: type
fk: str # deployment column pointing at the central object
uuid_attr: str # deployment column holding the OPNsense UUID (= remote_uuid)
fetch: Callable[[OpnsenseHost], Awaitable[list[dict]]]
check: Callable[[dict, dict], tuple[list[str], dict[str, str]]]
title: Callable[[dict], str]
info: Callable[[dict], dict[str, str]]
build: Callable[[dict], dict]
payload: Callable[[Any], dict]
revision: Callable
kept: Callable[[dict], list[str]] = lambda row: []
context: Callable[[AsyncSession, list[dict]], Awaitable[dict]] | None = None
refs: Callable[[dict], set[str]] | None = None
name: Callable[[dict], str] | None = None
# ── shared helpers ───────────────────────────────────────────────────────
def _single_interface(row: dict) -> bool:
interface = clean(row.get("interface"))
return bool(interface) and "," not in interface
def _too_long(kind_model: type, values: dict) -> list[str]:
"""Columns are bounded; a live value that does not fit would fail (or be cut) on the first edit."""
columns = kind_model.__table__.columns
return [name for name, value in values.items()
if name in columns and getattr(columns[name].type, "length", None)
and isinstance(value, str) and len(value) > columns[name].type.length]
def _port(value) -> str | None:
return clean(value) or None
def _kept(row: dict, keys: tuple[str, ...]) -> list[str]:
return [key for key in keys if clean(row.get(key)) not in ("", "0")]
def _fetch_nat(search) -> Callable[[OpnsenseHost], Awaitable[list[dict]]]:
async def fetch(host: OpnsenseHost) -> list[dict]:
data = await search(host)
# split_rows drops automatic_*/lockout_* and is_automatic rows: they are OPNsense's own
return [r for r in split_rows(data)["rules"] if clean(r.get("uuid"))]
return fetch
# ── aliases ──────────────────────────────────────────────────────────────
def _alias_content(row: dict) -> str:
# same normalisation as AliasCreate.clean_content
return "\n".join(line.strip() for line in str(row.get("content") or "").splitlines() if line.strip())
def _alias_is_system(row: dict) -> bool:
name = clean(row.get("name"))
return clean(row.get("type")) == "internal" or name.startswith("__") or name in SYSTEM_ALIAS_NAMES
async def _fetch_aliases(host: OpnsenseHost) -> list[dict]:
data = await opnsense_client.search_aliases(host)
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict) and clean(r.get("uuid")) and clean(r.get("name"))]
async def _alias_context(db: AsyncSession, rows: list[dict]) -> dict:
return {"central_names": set((await db.scalars(select(FirewallAlias.name))).all())}
def _alias_refs(row: dict) -> set[str]:
return {line.lstrip("!").strip() for line in _alias_content(row).splitlines()} - {""}
def _alias_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
if _alias_is_system(row):
return ["system"], {}
reasons: list[str] = []
details: dict[str, str] = {}
name, type_ = clean(row.get("name")), clean(row.get("type"))
if type_ not in ALIAS_TYPES:
reasons.append("unsupported_type")
details["unsupported_type"] = clean(row.get("%type")) or type_
if not ALIAS_NAME.fullmatch(name) or len(name) > 64:
reasons.append("name_invalid")
if name in ctx["central_names"]:
reasons.append("name_exists")
if len(_alias_content(row)) > ALIAS_CONTENT_MAX:
reasons.append("content_too_large")
if type_ == "asn" and clean(row.get("proto")):
reasons.append("proto_set") # OPNexus has no field for it: a deploy elsewhere would drop the restriction
if _too_long(FirewallAlias, {"description": clean(row.get("description"))}):
reasons.append("value_too_long")
return reasons, details
def _alias_info(row: dict) -> dict[str, str]:
lines = _alias_content(row).splitlines()
return {
"type": clean(row.get("type")),
"type_label": clean(row.get("%type")) or clean(row.get("type")),
"description": clean(row.get("description")),
"content": "\n".join(lines[:6]) + ("\n…" if len(lines) > 6 else ""),
"entries": str(len(lines)),
}
def _alias_build(row: dict) -> dict:
return {
"name": clean(row.get("name")),
"type": clean(row.get("type")),
"content": _alias_content(row),
"description": clean(row.get("description")),
"enabled": on(row.get("enabled")),
}
# ── port forward ─────────────────────────────────────────────────────────
def _pf_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
reasons = []
if not _single_interface(row):
reasons.append("interface_not_single")
if clean(row.get("ipprotocol")) not in ("inet", "inet6"):
reasons.append("ipprotocol_unsupported") # blank / inet46 have no counterpart in OPNexus
if not clean(row.get("protocol")):
reasons.append("protocol_any")
if on(row.get("nordr")):
reasons.append("no_redirect")
elif not clean(row.get("target")):
reasons.append("no_target")
if clean(row.get("pass")):
reasons.append("filter_rule_linked") # OPNexus sends pass "" (manual) and would cut the link
if on(row.get("source.not")):
reasons.append("negated_source")
if on(row.get("destination.not")):
reasons.append("negated_destination")
if clean(row.get("natreflection")) not in ("", "purenat", "disable"):
reasons.append("natreflection_unsupported")
if not clean(row.get("descr")):
reasons.append("no_description")
if _too_long(PortForwardRule, _pf_build(row)):
reasons.append("value_too_long")
return reasons, {}
def _pf_build(row: dict) -> dict:
return {
"description": clean(row.get("descr")),
"interface": clean(row.get("interface")),
"ipprotocol": clean(row.get("ipprotocol")),
"protocol": clean(row.get("protocol")),
"source_net": clean(row.get("source.network")) or "any",
"source_port": _port(row.get("source.port")),
"destination_net": clean(row.get("destination.network")) or "any",
"destination_port": _port(row.get("destination.port")),
"target": clean(row.get("target")),
"local_port": _port(row.get("local-port")),
"natreflection": clean(row.get("natreflection")),
"enabled": not on(row.get("disabled")),
}
def _pf_info(row: dict) -> dict[str, str]:
return {
"interface_label": clean(row.get("%interface")) or clean(row.get("interface")),
"protocol": clean(row.get("%protocol")) or clean(row.get("protocol")) or "any",
"source": clean(row.get("source.network")) or "any",
"source_port": clean(row.get("source.port")),
"destination": clean(row.get("destination.network")) or "any",
"destination_port": clean(row.get("destination.port")),
"target": clean(row.get("target")),
"target_port": clean(row.get("local-port")),
}
# ── 1:1 NAT ──────────────────────────────────────────────────────────────
def _o2o_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
reasons = []
if not _single_interface(row):
reasons.append("interface_not_single")
if clean(row.get("type")) not in ("binat", "nat"):
reasons.append("type_unsupported")
if clean(row.get("natreflection")) not in ("", "enable", "disable"):
reasons.append("natreflection_unsupported")
if not clean(row.get("source_net")) or not clean(row.get("external")):
reasons.append("incomplete")
if not clean(row.get("description")):
reasons.append("no_description")
if _too_long(OneToOneRule, _o2o_build(row)):
reasons.append("value_too_long")
return reasons, {}
def _o2o_build(row: dict) -> dict:
return {
"description": clean(row.get("description")),
"interface": clean(row.get("interface")),
"source_net": clean(row.get("source_net")),
"destination_net": clean(row.get("destination_net")) or "any",
"source_not": on(row.get("source_not")),
"destination_not": on(row.get("destination_not")),
"enabled": on(row.get("enabled")),
"type": clean(row.get("type")),
"external": clean(row.get("external")),
"natreflection": clean(row.get("natreflection")),
"log": on(row.get("log")),
}
def _o2o_info(row: dict) -> dict[str, str]:
return {
"interface_label": clean(row.get("%interface")) or clean(row.get("interface")),
"type": clean(row.get("type")),
"source": ("! " if on(row.get("source_not")) else "") + clean(row.get("source_net")),
"destination": ("! " if on(row.get("destination_not")) else "") + (clean(row.get("destination_net")) or "any"),
"external": clean(row.get("external")),
}
# ── outbound NAT ─────────────────────────────────────────────────────────
async def _fetch_outbound(host: OpnsenseHost) -> list[dict]:
# In automatic/disabled mode OPNsense does not list manual rules at all (see module docstring):
# say so instead of showing an empty list.
state = await host_mode(host)
if not state["can_deploy"]:
raise api_error(409, ErrorCode.OUTBOUND_MODE_BLOCKS_ADOPT, host=host.name, mode=state["mode"])
return await _fetch_nat(opnsense_client.search_outbound_nat_rules)(host)
def _snat_check(row: dict, ctx: dict) -> tuple[list[str], dict[str, str]]:
reasons = []
if not _single_interface(row):
reasons.append("interface_not_single")
if on(row.get("nonat")):
reasons.append("no_nat_rule")
if clean(row.get("ipprotocol")) not in ("inet", "inet6"):
reasons.append("ipprotocol_unsupported")
if not clean(row.get("protocol")):
reasons.append("protocol_missing")
if not clean(row.get("description")):
reasons.append("no_description")
if _too_long(OutboundNatRule, _snat_build(row)):
reasons.append("value_too_long")
return reasons, {}
def _snat_build(row: dict) -> dict:
return {
"description": clean(row.get("description")),
"interface": clean(row.get("interface")),
"source_net": clean(row.get("source_net")) or "any",
"destination_net": clean(row.get("destination_net")) or "any",
"source_not": on(row.get("source_not")),
"destination_not": on(row.get("destination_not")),
"enabled": on(row.get("enabled")),
"ipprotocol": clean(row.get("ipprotocol")),
"protocol": clean(row.get("protocol")),
"source_port": _port(row.get("source_port")),
"destination_port": _port(row.get("destination_port")),
"target": clean(row.get("target")),
"target_port": _port(row.get("target_port")),
}
def _snat_info(row: dict) -> dict[str, str]:
return {
"interface_label": clean(row.get("%interface")) or clean(row.get("interface")),
"protocol": clean(row.get("protocol")) or "any",
"source": ("! " if on(row.get("source_not")) else "") + (clean(row.get("source_net")) or "any"),
"source_port": clean(row.get("source_port")),
"destination": ("! " if on(row.get("destination_not")) else "") + (clean(row.get("destination_net")) or "any"),
"destination_port": clean(row.get("destination_port")),
"target": clean(row.get("target")),
"target_port": clean(row.get("target_port")),
}
KINDS: dict[str, Kind] = {
"alias": Kind(
key="alias", model=FirewallAlias, deployment=AliasDeployment, fk="alias_id",
uuid_attr="opnsense_alias_uuid", fetch=_fetch_aliases, check=_alias_check,
title=lambda row: clean(row.get("name")), info=_alias_info, build=_alias_build,
payload=alias_deploy_service._build_payload, revision=alias_policy_service.revision,
context=_alias_context, refs=_alias_refs, name=lambda row: clean(row.get("name")),
),
"port_forward": Kind(
key="port_forward", model=PortForwardRule, deployment=PortForwardDeployment, fk="port_forward_id",
uuid_attr="opnsense_port_forward_uuid", fetch=_fetch_nat(opnsense_client.search_port_forward_rules),
check=_pf_check, title=lambda row: clean(row.get("descr")), info=_pf_info, build=_pf_build,
payload=port_forward_deploy_service._build_payload, revision=port_forward_policy_service.revision,
kept=lambda row: _kept(row, ("log", "poolopts", "tag", "tagged", "nosync", "categories")),
),
"one_to_one": Kind(
key="one_to_one", model=OneToOneRule, deployment=OneToOneDeployment, fk="one_to_one_id",
uuid_attr="opnsense_one_to_one_uuid", fetch=_fetch_nat(opnsense_client.search_one_to_one_rules),
check=_o2o_check, title=lambda row: clean(row.get("description")), info=_o2o_info, build=_o2o_build,
payload=one_to_one_deploy_service._build_payload, revision=one_to_one_policy_service.revision,
kept=lambda row: _kept(row, ("categories",)),
),
"outbound_nat": Kind(
key="outbound_nat", model=OutboundNatRule, deployment=OutboundNatDeployment, fk="outbound_nat_id",
uuid_attr="opnsense_outbound_nat_uuid", fetch=_fetch_outbound,
check=_snat_check, title=lambda row: clean(row.get("description")), info=_snat_info, build=_snat_build,
payload=outbound_nat_deploy_service._build_payload, revision=outbound_nat_policy_service.revision,
kept=lambda row: _kept(row, ("log", "staticnatport", "poolopts", "poolopts_sourcehashkey", "tag", "tagged",
"nosync", "endpoint-independent", "categories")),
),
}
# ── engine ───────────────────────────────────────────────────────────────
async def _managed_uuids(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> set[str]:
column = getattr(kind.deployment, kind.uuid_attr)
rows = await db.scalars(select(column).where(kind.deployment.host_id == host.id, column.is_not(None)))
return set(rows.all())
def _resolve_references(kind: Kind, entries: list[Entry], rows: list[dict], managed: set[str], ctx: dict) -> list[Entry]:
"""Aliases may refer to other aliases by name. A reference to a live alias OPNexus does not manage
(and that is not OPNsense's own) would be dangling in the central model, so the referring alias
waits until its target is managed. Returns the entries in an order in which they can be adopted
in ONE run (dependencies first); entries whose references can never be satisfied (target is a
url alias, a user-defined external alias, ...) keep the reason `references_unmanaged_alias`."""
live = {kind.name(r): r for r in rows}
available = set(ctx["central_names"])
available |= {n for n, r in live.items() if _alias_is_system(r) or clean(r.get("uuid")) in managed}
candidates = [e for e in entries if not e.reasons]
for entry in candidates:
missing = {ref for ref in kind.refs(entry.row) if ref in live and ref not in available}
if missing:
entry.reasons.append("references_unmanaged_alias")
entry.details["references_unmanaged_alias"] = ", ".join(sorted(missing))
order: list[Entry] = []
ready = set(available)
progress = True
while progress:
progress = False
for entry in candidates:
if entry in order:
continue
if all(ref not in live or ref in ready for ref in kind.refs(entry.row)):
order.append(entry)
ready.add(kind.name(entry.row))
progress = True
for entry in order:
entry.bulk = "references_unmanaged_alias" in entry.reasons
return order
async def _plan(db: AsyncSession, kind: Kind, host: OpnsenseHost) -> tuple[list[Entry], list[Entry]]:
"""(all unmanaged entries with their reasons, the ones adoptable in one run in a safe order)."""
rows = await kind.fetch(host)
managed = await _managed_uuids(db, kind, host)
ctx = await kind.context(db, rows) if kind.context else {}
entries = []
for row in rows:
if clean(row.get("uuid")) in managed:
continue
reasons, details = kind.check(row, ctx)
entries.append(Entry(row, list(reasons), dict(details)))
if kind.refs:
order = _resolve_references(kind, entries, rows, managed, ctx)
else:
order = [e for e in entries if not e.reasons]
return entries, order
def _describe(kind: Kind, entry: Entry) -> dict:
return {
"uuid": entry.uuid,
"title": kind.title(entry.row),
"enabled": on(entry.row.get("enabled")) if "disabled" not in entry.row else not on(entry.row.get("disabled")),
"adoptable": not entry.reasons,
"bulk_adoptable": not entry.reasons or entry.bulk,
"reasons": entry.reasons,
"reason_details": entry.details,
"kept": kind.kept(entry.row),
"info": kind.info(entry.row),
}
async def list_unmanaged(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> list[dict]:
kind = KINDS[kind_key]
entries, _ = await _plan(db, kind, host)
return [_describe(kind, e) for e in entries]
def _create(db: AsyncSession, kind: Kind, host: OpnsenseHost, entry: Entry):
obj = kind.model(**kind.build(entry.row), targets=[host], groups=[])
db.add(obj)
return obj
async def _record(db: AsyncSession, kind: Kind, host: OpnsenseHost, obj, remote_uuid: str) -> None:
await db.flush()
kind.revision(db, obj, ADOPT_NOTE)
db.add(kind.deployment(**{
kind.fk: obj.id, "host_id": host.id, "status": "success", "message": ADOPT_MESSAGE,
kind.uuid_attr: remote_uuid, "last_payload": kind.payload(obj), "previous_payload": None,
}))
await db.flush()
async def adopt(db: AsyncSession, kind_key: str, host: OpnsenseHost, remote_uuid: str):
kind = KINDS[kind_key]
entries, _ = await _plan(db, kind, host)
entry = next((e for e in entries if e.uuid == remote_uuid), None)
if entry is None:
# either gone from the host or already managed
if remote_uuid in await _managed_uuids(db, kind, host):
raise NotAdoptable(["already_managed"])
raise NotFoundOnHost(remote_uuid)
if entry.reasons:
raise NotAdoptable(entry.reasons, entry.details)
obj = _create(db, kind, host, entry)
await _record(db, kind, host, obj, remote_uuid)
return obj
async def adopt_all(db: AsyncSession, kind_key: str, host: OpnsenseHost) -> dict:
"""Adopts everything adoptable in one transaction (the caller commits), nested aliases in
dependency order. System objects are not reported; everything else that stays behind is, with reasons."""
kind = KINDS[kind_key]
entries, order = await _plan(db, kind, host)
adopted = []
for entry in order:
obj = _create(db, kind, host, entry)
await _record(db, kind, host, obj, entry.uuid)
adopted.append({"uuid": entry.uuid, "title": kind.title(entry.row), "id": obj.id})
done = {e.uuid for e in order}
skipped = [
{"uuid": e.uuid, "title": kind.title(e.row), "reasons": e.reasons, "reason_details": e.details}
for e in entries if e.uuid not in done and e.reasons != ["system"]
]
return {"adopted": adopted, "skipped": skipped}
+28
View File
@@ -145,6 +145,34 @@ async def get_interface_statistics(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/diagnostics/interface/getInterfaceStatistics")
# Swap and CPU count, for the resource-threshold alert (1.24). Verified against core source
# (Diagnostics/Api/SystemController::systemSwapAction -> configd "system show swapinfo") and live
# (read-only GET, test-fw-3, OPNsense 26.7):
# systemSwap -> {"swap": [{"device": "/dev/da0p3", "total": "8388608", "used": "0"}]} (KiB, strings;
# an empty list when the firewall has no swap)
# cpu_usage/getCPUType -> ["QEMU Virtual CPU version 2.5+ (6 cores, 6 threads)"]
async def get_system_swap(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/diagnostics/system/systemSwap")
async def get_cpu_type(host: OpnsenseHost) -> Any:
return await _request(host, "GET", "/diagnostics/cpu_usage/getCPUType")
# Gateway health (read-only). Routes\Api\GatewayController::statusAction runs configd "interface gateways status"
# -> scripts/routes/gateway_status.php, which merges the configured gateways with dpinger's live numbers
# (src/etc/inc/plugins.inc.d/dpinger.inc, dpinger_status()). Live-verified on test-fw-3 (26.7):
# {"items": [{"name": "WAN_GW", "address": "203.0.113.1", "status": "none", "loss": "~", "delay": "~",
# "stddev": "~", "monitor": "~", "status_translated": "Online"}, ...], "status": "ok"}
# "status" is the machine-readable verdict: none (fine), down, force_down (switched off by the admin), delay,
# loss, delay+loss (thresholds of the gateway itself), anything else = pending. loss/delay/stddev are "12.3 %" /
# "4.5 ms" strings, or "~" while nothing is measured (e.g. monitoring disabled on that gateway). "status" is
# "failed" with no items when the firewall has no gateways. searchGateway carries the same numbers but with a
# translated status text ("Online"), so it is not used here.
async def get_gateway_status(host: OpnsenseHost) -> dict:
return await _request(host, "GET", "/routes/gateway/status")
async def toggle_carp_maintenance(host: OpnsenseHost) -> dict:
"""Toggle persistent CARP maintenance mode.
@@ -5,7 +5,9 @@ poll_all_hosts. v1 deliberately has no admin-editable poll interval (see
config.py) — just a fixed constant, to keep this feature's first cut
small.
"""
import asyncio
import logging
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
@@ -13,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.resource_sample import HostResourceSample
from app.services import gateway_health_service
from app.services import opnsense_client as client
from app.services.opnsense_client import OpnsenseAPIError
@@ -55,11 +58,59 @@ def _disk_used_pct(disk: dict) -> float | None:
return None
def _swap_used_pct(swap) -> float | None:
"""systemSwap: {"swap": [{"device", "total", "used"}]} in KiB; None when there is no swap at all."""
try:
rows = swap.get("swap") or []
total = sum(int(r["total"]) for r in rows)
used = sum(int(r["used"]) for r in rows)
except (AttributeError, KeyError, TypeError, ValueError):
return None
return round(used / total * 100, 1) if total > 0 else None
_CPU_THREADS = re.compile(r"(\d+)\s+threads?", re.IGNORECASE)
_CPU_CORES = re.compile(r"(\d+)\s+cores?", re.IGNORECASE)
def _cpu_threads(cpu_type) -> int | None:
"""getCPUType: ["<model> (6 cores, 6 threads)"]."""
text = " ".join(str(x) for x in cpu_type) if isinstance(cpu_type, list) else str(cpu_type or "")
match = _CPU_THREADS.search(text) or _CPU_CORES.search(text)
return int(match.group(1)) if match else None
def _services_compact(listing) -> dict[str, int]:
"""{"unbound": 1, "openvpn/<uuid>": 0}: 1 = running. Only services an admin can stop (not 'locked')."""
result: dict[str, int] = {}
for row in (listing.get("rows") if isinstance(listing, dict) else None) or []:
if not isinstance(row, dict) or not row.get("name") or str(row.get("locked")) in ("1", "True", "true"):
continue
name, id_ = str(row["name"]), str(row.get("id") or row["name"])
result[name if id_ == name else f"{name}/{id_}"] = 1 if str(row.get("running")) in ("1", "True", "true") else 0
return result
async def _optional(operation):
"""The extra readings must never cost the base sample (older firmware, an endpoint that is missing)."""
try:
return await operation
except Exception as exc:
logger.debug("Optionale Messung fehlgeschlagen: %s", type(exc).__name__)
return None
async def sample_host(db: AsyncSession, host: OpnsenseHost) -> None:
resources = await client.get_system_resources(host)
system_time = await client.get_system_time(host)
disk = await client.get_system_disk(host)
interfaces = await client.get_interface_statistics(host)
swap, cpu_type, gateways, services = await asyncio.gather(
_optional(client.get_system_swap(host)),
_optional(client.get_cpu_type(host)),
_optional(gateway_health_service.get_gateway_health(host)),
_optional(client.search_services(host)),
)
db.add(
HostResourceSample(
host_id=host.id,
@@ -67,7 +118,14 @@ async def sample_host(db: AsyncSession, host: OpnsenseHost) -> None:
mem_used_pct=_mem_used_pct(resources),
load_avg_1m=_load_avg_1m(system_time),
disk_used_pct=_disk_used_pct(disk),
raw={"memory": resources.get("memory"), "disk": disk.get("devices"), "interfaces": interfaces.get("statistics")},
raw={
"memory": resources.get("memory"), "disk": disk.get("devices"), "interfaces": interfaces.get("statistics"),
# None = not readable this round (the alert checks treat that as "unknown", not as "fine").
"swap_used_pct": _swap_used_pct(swap) if swap is not None else None,
"cpu_threads": _cpu_threads(cpu_type) if cpu_type is not None else None,
"gateways": gateway_health_service.compact(gateways) if gateways is not None else None,
"services": _services_compact(services) if services is not None else None,
},
)
)
+4 -13
View File
@@ -20,26 +20,17 @@ from app.models.host import OpnsenseHost
from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment
from app.services import opnsense_client
from app.services.adopt_common import NotAdoptable, NotFoundOnHost, clean, on # noqa: F401 (NotAdoptable is re-exported for routers/rules.py)
from app.services.policy_service import revision
from app.services.rule_deploy_service import _build_payload
class RuleNotFoundOnHost(Exception):
class RuleNotFoundOnHost(NotFoundOnHost):
pass
class NotAdoptable(Exception):
def __init__(self, reasons: list[str]):
super().__init__(", ".join(reasons))
self.reasons = reasons
def _on(value) -> bool:
return value in ("1", 1, True)
def _clean(value) -> str:
return "" if value is None else str(value).strip()
_on = on
_clean = clean
def reasons_not_adoptable(row: dict, managed_uuids: set[str]) -> list[str]:
+94
View File
@@ -0,0 +1,94 @@
"""Destination check for URLs OPNexus is told to call (notification channels, webhook).
Admins legitimately point these at servers on their own network (a self-hosted ntfy, Gotify or
Matrix), so private ranges are allowed. What is refused is what cannot be a notification target:
anything but http(s), credentials inside the URL, and addresses that only ever mean "this machine"
or "the cloud metadata service" (loopback, link-local such as 169.254.169.254, unspecified,
multicast). Redirects are never followed by the callers, so a public host cannot bounce a request
to such an address. The hostname is resolved at send time and every address is checked (a name that
does not resolve is let through; the request then fails on its own).
"""
import asyncio
import contextlib
import contextvars
import ipaddress
import logging
import socket
from urllib.parse import urlsplit
# httpx logs every request line ("HTTP Request: POST https://api.telegram.org/bot<token>/sendMessage"). For
# targets whose URL is the credential that line must not reach the logs: callers wrap the request in muted().
_muted = contextvars.ContextVar("url_guard_muted", default=False)
class _MuteFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return not _muted.get()
for _name in ("httpx", "httpcore"):
logging.getLogger(_name).addFilter(_MuteFilter())
@contextlib.contextmanager
def muted():
token = _muted.set(True)
try:
yield
finally:
_muted.reset(token)
def _forbidden(ip: ipaddress._BaseAddress) -> bool:
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
return ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast
def validate_url(url: str, *, max_length: int = 500) -> str:
"""Returns the cleaned URL or raises ValueError with a short reason (shown to the admin)."""
value = (url or "").strip()
if not value or len(value) > max_length or any(c.isspace() for c in value):
raise ValueError("Die Adresse ist leer, zu lang oder enthält Leerzeichen")
try:
parts = urlsplit(value)
host = parts.hostname
parts.port # noqa: B018 - raises ValueError for an invalid port
except ValueError:
raise ValueError("Die Adresse ist ungültig") from None
if parts.scheme not in ("http", "https") or not host:
raise ValueError("Die Adresse muss mit http:// oder https:// beginnen")
if parts.username or parts.password:
raise ValueError("Zugangsdaten gehören nicht in die Adresse")
try:
literal = ipaddress.ip_address(host)
except ValueError:
literal = None
if literal is not None and _forbidden(literal):
raise ValueError("Diese Adresse (lokal, Link-Local oder Metadaten-Dienst) ist als Ziel nicht erlaubt")
if literal is None and host.lower() in ("localhost", "localhost.localdomain"):
raise ValueError("Diese Adresse (lokal, Link-Local oder Metadaten-Dienst) ist als Ziel nicht erlaubt")
return value
async def ensure_resolved_safe(url: str) -> None:
"""Send-time check against DNS answers pointing at forbidden addresses. Raises ValueError."""
host = urlsplit(url).hostname
if not host:
raise ValueError("Die Adresse ist ungültig")
try:
ipaddress.ip_address(host)
return # a literal was already checked by validate_url
except ValueError:
pass
try:
infos = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
except OSError:
return
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0].split("%")[0])
except ValueError:
continue
if _forbidden(ip):
raise ValueError("Der Name zeigt auf eine nicht erlaubte Adresse (lokal, Link-Local oder Metadaten-Dienst)")
+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.23.6"
APP_CODENAME = "Harrier"
APP_VERSION = "1.24.0"
APP_CODENAME = "Merlin"
@@ -0,0 +1,55 @@
"""Alarme für Gateways, Ressourcen und Dienste; zusätzliche Benachrichtigungskanäle.
Revision ID: 0020
Revises: 0019
Create Date: 2026-10-21
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0020"
down_revision = "0019"
branch_labels = None
depends_on = None
_COLUMNS = [
("gateway_down_enabled", "boolean NOT NULL DEFAULT true"),
("resource_threshold_enabled", "boolean NOT NULL DEFAULT false"),
("service_down_enabled", "boolean NOT NULL DEFAULT false"),
("gateway_loss_threshold_pct", "integer NOT NULL DEFAULT 20"),
("gateway_delay_threshold_ms", "integer NOT NULL DEFAULT 500"),
("disk_threshold_pct", "integer NOT NULL DEFAULT 90"),
("memory_threshold_pct", "integer NOT NULL DEFAULT 95"),
("swap_threshold_pct", "integer NOT NULL DEFAULT 50"),
("load_per_cpu_threshold", "double precision NOT NULL DEFAULT 2.0"),
("alert_confirm_samples", "integer NOT NULL DEFAULT 3"),
("resource_confirm_samples", "integer NOT NULL DEFAULT 5"),
("service_watchlist", "json"),
]
def upgrade() -> None:
for name, ddl in _COLUMNS:
op.execute(f"ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS {name} {ddl}")
if sa.inspect(op.get_bind()).has_table("notification_channels"):
return
op.create_table(
"notification_channels",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("channel_type", sa.String(length=16), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("secret_set", sa.Boolean(), server_default="false", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS notification_channels")
for name, _ddl in reversed(_COLUMNS):
op.execute(f"ALTER TABLE notification_settings DROP COLUMN IF EXISTS {name}")
+50
View File
@@ -206,3 +206,53 @@ async def test_fresh_database_needs_no_backup(scratch, monkeypatch):
await migrate.run() # würde ohne Backup-Ziel scheitern, wenn eins verlangt würde
from pathlib import Path
assert not Path(settings.migration_backup_dir).exists()
async def _alembic(engine, action: str, revision: str) -> None:
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: migrate._run_alembic(sync_conn, action, revision))
async def test_0020_upgrade_keeps_existing_settings_and_downgrade_is_clean(scratch):
"""1.23 -> 1.24 with a real settings row: the new alert columns get their defaults, the stored webhook
and flags stay, the channel table appears; a downgrade removes exactly that again and re-upgrading works."""
await _alembic(scratch, "upgrade", "0019")
async with scratch.begin() as conn:
# Every NOT NULL column of the 0019 table gets a value, so the row is what a real 1.23 install has.
required = (await conn.execute(text(
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'notification_settings' "
"AND is_nullable = 'NO' AND column_default IS NULL"
))).all()
by_type = {"boolean": "true", "integer": "300", "json": "'[]'", "timestamp with time zone": "now()"}
values = {name: by_type.get(kind, "'x'") for name, kind in required}
values.update({"id": "1", "webhook_enabled": "true", "webhook_url": "'https://hooks.example/x'"})
await conn.execute(text(
f"INSERT INTO notification_settings ({', '.join(values)}) VALUES ({', '.join(values.values())})"
))
await _alembic(scratch, "upgrade", "head")
async with scratch.connect() as conn:
row = (await conn.execute(text(
"SELECT webhook_enabled, webhook_url, gateway_down_enabled, resource_threshold_enabled, service_down_enabled, "
"disk_threshold_pct, load_per_cpu_threshold, alert_confirm_samples, resource_confirm_samples, service_watchlist "
"FROM notification_settings"
))).one()
assert tuple(row) == (True, "https://hooks.example/x", True, False, False, 90, 2.0, 3, 5, None)
await conn.execute(text(
"INSERT INTO notification_channels (id, name, channel_type, config, created_at, updated_at) "
"VALUES (gen_random_uuid(), 'n', 'ntfy', '{\"topic\": \"t\"}', now(), now())"
))
await conn.commit()
assert await _schema_diff(scratch) == []
await _alembic(scratch, "downgrade", "0019")
async with scratch.connect() as conn:
assert (await conn.execute(text("SELECT to_regclass('notification_channels')"))).scalar() is None
columns = set((await conn.execute(text(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'notification_settings'"
))).scalars().all())
assert "gateway_down_enabled" not in columns and "service_watchlist" not in columns
assert (await conn.execute(text("SELECT webhook_url FROM notification_settings"))).scalar() == "https://hooks.example/x"
await _alembic(scratch, "upgrade", "head")
assert await _version(scratch) == _head()
assert await _schema_diff(scratch) == []
+717
View File
@@ -0,0 +1,717 @@
"""Monitoring alerts (1.24): gateway health, resource thresholds, stopped services, extra channels."""
import logging
import uuid
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.notification import NotificationChannel, NotificationProblem, NotificationSettings
from app.models.resource_sample import HostResourceSample
from app.services import (
gateway_health_service, monitor_checks, notification_channels, notification_secret_store, resource_monitor_service,
url_guard,
)
from app.services.notification_delivery import NotificationDeliveryError
from app.services.notification_service import run_notification_checks
from tests.test_notifications import _create_host
from tests.test_resource_monitor import _mock_resource_endpoints, _mock_vip
# What routes/gateway/status returns on a real 26.7 firewall (test-fw-3, live), plus a degraded and a dead gateway.
STATUS_PAYLOAD = {"status": "ok", "items": [
{"name": "WAN_GW", "address": "203.0.113.1", "status": "none", "loss": "0.0 %", "delay": "1.9 ms", "stddev": "0.3 ms", "monitor": "203.0.113.1"},
{"name": "LTE_GW", "address": "192.0.2.9", "status": "delay+loss", "loss": "12.5 %", "delay": "310.2 ms", "stddev": "40.0 ms", "monitor": "192.0.2.9"},
{"name": "DOWN_GW", "address": "192.0.2.1", "status": "down", "loss": "100.0 %", "delay": "~", "stddev": "~", "monitor": "192.0.2.1"},
{"name": "WAN_DHCP6", "address": "~", "status": "none", "loss": "~", "delay": "~", "stddev": "~", "monitor": "~"},
{"name": "OFF_GW", "address": "192.0.2.5", "status": "force_down", "loss": "~", "delay": "~", "stddev": "~", "monitor": "~"},
]}
# ── Gateway parsing ──────────────────────────────────────────────────────────
def test_parse_gateway_status_maps_states_and_numbers():
by_name = {g.name: g for g in gateway_health_service.parse_gateway_status(STATUS_PAYLOAD)}
assert by_name["WAN_GW"].state == "online" and by_name["WAN_GW"].loss == 0.0 and by_name["WAN_GW"].delay == 1.9
assert by_name["WAN_GW"].stddev == 0.3
assert by_name["LTE_GW"].state == "degraded" and by_name["LTE_GW"].loss == 12.5
assert by_name["DOWN_GW"].state == "offline" and by_name["DOWN_GW"].delay is None
# nothing measured -> unknown, never "online" and never 0 ms
assert by_name["WAN_DHCP6"].state == "unknown" and by_name["WAN_DHCP6"].loss is None and by_name["WAN_DHCP6"].address is None
assert by_name["OFF_GW"].state == "forced_down"
def test_dead_gateway_has_no_round_trip_time():
# dpinger reports "100.0 %" loss together with "0.0 ms" when nothing answers: 0 ms must not look fast
payload = {"items": [{"name": "GW", "status": "down", "loss": "100.0 %", "delay": "0.0 ms", "stddev": "0.0 ms", "monitor": "192.0.2.1"}]}
gw = gateway_health_service.parse_gateway_status(payload)[0]
assert gw.state == "offline" and gw.loss == 100.0 and gw.delay is None and gw.stddev is None
def test_parse_gateway_status_tolerates_failed_answer():
assert gateway_health_service.parse_gateway_status({"items": [], "status": "failed"}) == []
assert gateway_health_service.parse_gateway_status(None) == []
def test_is_bad_thresholds():
by_name = {g.name: g for g in gateway_health_service.parse_gateway_status(STATUS_PAYLOAD)}
assert gateway_health_service.is_bad(by_name["DOWN_GW"])
assert not gateway_health_service.is_bad(by_name["OFF_GW"]) # switched off on purpose
assert not gateway_health_service.is_bad(by_name["WAN_DHCP6"])
assert not gateway_health_service.is_bad(by_name["LTE_GW"]) # degraded by OPNsense' limits only, own thresholds off
assert gateway_health_service.is_bad(by_name["LTE_GW"], loss_threshold_pct=10)
assert gateway_health_service.is_bad(by_name["LTE_GW"], delay_threshold_ms=300)
assert not gateway_health_service.is_bad(by_name["WAN_GW"], loss_threshold_pct=10, delay_threshold_ms=300)
@pytest.mark.asyncio
async def test_get_gateway_health_reads_live(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(200, json=STATUS_PAYLOAD)
resp = await client.get(f"/api/hosts/{host['id']}/gateways")
assert resp.status_code == 200, resp.text
rows = {g["name"]: g for g in resp.json()}
assert rows["DOWN_GW"]["state"] == "offline" and rows["WAN_GW"]["delay"] == 1.9
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(500)
assert (await client.get(f"/api/hosts/{host['id']}/gateways")).status_code == 502
# ── Sampler stores the extra readings ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_sampler_stores_gateways_services_swap_and_cpu(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock)
respx_mock.get("https://fw1.test/api/diagnostics/system/systemSwap").respond(200, json={"swap": [{"device": "/dev/da0p3", "total": "8388608", "used": "2097152"}]})
respx_mock.get("https://fw1.test/api/diagnostics/cpu_usage/getCPUType").respond(200, json=["QEMU Virtual CPU version 2.5+ (6 cores, 6 threads)"])
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(200, json=STATUS_PAYLOAD)
respx_mock.get("https://fw1.test/api/core/service/search").respond(200, json={"rows": [
{"id": "unbound", "name": "unbound", "locked": 0, "running": 1},
{"id": "dhcpd6", "name": "dhcpd6", "locked": 0, "running": 0},
{"id": "pf", "name": "pf", "locked": 1, "running": 1},
{"id": "3f2a", "name": "openvpn", "locked": 0, "running": 0},
]})
async with SessionLocal() as db:
await resource_monitor_service.poll_all_hosts(db)
raw = (await db.scalars(select(HostResourceSample))).one().raw
assert raw["swap_used_pct"] == 25.0 and raw["cpu_threads"] == 6
assert {g["name"]: g["state"] for g in raw["gateways"]}["DOWN_GW"] == "offline"
assert raw["services"] == {"unbound": 1, "dhcpd6": 0, "openvpn/3f2a": 0} # locked services are not recorded
@pytest.mark.asyncio
async def test_sampler_survives_missing_optional_endpoints(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock) # nothing for swap/cpu/gateways/services -> unmocked routes fail
async with SessionLocal() as db:
result = await resource_monitor_service.poll_all_hosts(db)
row = (await db.scalars(select(HostResourceSample))).one()
assert result["sampled"] == 1 and row.mem_used_pct == 25.0
assert row.raw["gateways"] is None and row.raw["services"] is None and row.raw["swap_used_pct"] is None
# ── Debouncing ───────────────────────────────────────────────────────────────
def test_verdict_needs_agreement_and_keeps_state_otherwise():
v = monitor_checks.verdict
assert v([True, True, True], 3, False) is True
assert v([True, True, False], 3, False) is False # no single spike / short blip
assert v([True, True], 3, False) is False # not enough history yet
assert v([False, False, False], 3, True) is False # needs the full window to close
assert v([False, True, False], 3, True) is True # flapping keeps an open alert open
assert v([True, None, True], 3, False) is False # unreadable sample is no evidence
assert v([None, None, None], 3, True) is True # ... and does not clear either
assert v([True], 1, False) is True
async def _add_samples(host_id, rows: list[dict], *, age_minutes: int = 0) -> None:
"""rows: newest first; each {mem, load, disk, raw...}. One sample per minute, the newest ``age_minutes`` old."""
now = datetime.now(timezone.utc)
async with SessionLocal() as db:
for i, row in enumerate(rows):
raw = {k: v for k, v in row.items() if k not in ("mem", "load")}
db.add(HostResourceSample(
host_id=host_id, sampled_at=now - timedelta(minutes=age_minutes + i),
mem_used_pct=row.get("mem"), load_avg_1m=row.get("load"), disk_used_pct=None, raw=raw,
))
await db.commit()
def _gw(state="online", loss=0.0, delay=2.0, name="WAN_GW", status=None):
return {"name": name, "status": status or {"offline": "down", "online": "none"}.get(state, state), "state": state,
"loss": loss, "delay": delay, "stddev": 0.1}
async def _set(**fields):
async with SessionLocal() as db:
row = await db.get(NotificationSettings, 1) or NotificationSettings(id=1)
for k, v in fields.items():
setattr(row, k, v)
db.add(row)
await db.commit()
_ALL_FLAGS = [
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled", "revert_failed_enabled", "maintenance_unknown_enabled",
"carp_health_enabled", "nat_drift_enabled", "network_drift_enabled", "carp_auto_revert_failed_enabled",
"scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
"gateway_down_enabled", "resource_threshold_enabled", "service_down_enabled",
]
async def _only(*enabled: str):
"""Run run_notification_checks with just these checks on (the mocked firewalls are unreachable, which would
otherwise also open host_offline problems)."""
await _set(**{flag: flag in enabled for flag in _ALL_FLAGS})
async def _open_count(db, problem_type: str | None = None) -> int:
query = select(NotificationProblem).where(NotificationProblem.status == "open")
if problem_type:
query = query.where(NotificationProblem.problem_type == problem_type)
return len((await db.scalars(query)).all())
async def _gateway_problems():
async with SessionLocal() as db:
return await monitor_checks.check_gateway_down(db)
@pytest.mark.asyncio
async def test_gateway_down_needs_consecutive_samples(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [{"gateways": [_gw("offline", loss=100.0, delay=None)]}, {"gateways": [_gw("offline", 100.0, None)]}, {"gateways": [_gw()]}])
assert dict(await _gateway_problems()) == {} # only two bad samples in a row
await _add_samples(hid, [{"gateways": [_gw("offline", 100.0, None)]}] * 3, age_minutes=-3) # three newer bad ones
problems = await _gateway_problems()
assert list(problems) == [f"{hid}:WAN_GW"]
ctx = problems[f"{hid}:WAN_GW"]
assert ctx["host_name"] == "fw1" and ctx["gateway"] == "WAN_GW" and ctx["grund"] == "Gateway offline"
@pytest.mark.asyncio
async def test_gateway_alert_resolves_only_after_full_recovery_and_ignores_unknown(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
bad, good = {"gateways": [_gw("offline", 100.0, None)]}, {"gateways": [_gw()]}
await _only("gateway_down_enabled")
await _add_samples(hid, [bad, bad, bad, good, good])
async with SessionLocal() as db:
await run_notification_checks(db)
assert await _open_count(db) == 1
# one good sample after the outage, then a relapse: still open (flapping link must not re-notify)
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [bad, good, bad, bad, good])
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"]
# a sample that could not read the gateways does not clear it either
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [{"gateways": None}] * 3)
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"]
# three good ones close it
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [good, good, good])
assert dict(await _gateway_problems()) == {}
@pytest.mark.asyncio
async def test_gateway_thresholds_and_forced_down(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
lossy = {"gateways": [_gw("degraded", loss=35.0, delay=20.0, status="loss"), _gw("forced_down", None, None, name="OFF", status="force_down")]}
await _add_samples(hid, [lossy] * 3)
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"] # 35 % >= 20 %; the switched-off gateway is not reported
await _set(gateway_loss_threshold_pct=0)
assert dict(await _gateway_problems()) == {} # own loss threshold off; OPNsense says "degraded", not "down"
@pytest.mark.asyncio
async def test_stale_samples_are_not_judged_and_keep_open_problems(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
bad = {"gateways": [_gw("offline", 100.0, None)]}
await _only("gateway_down_enabled")
await _add_samples(hid, [bad] * 3)
async with SessionLocal() as db:
await run_notification_checks(db)
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [{"gateways": [_gw()]}] * 3, age_minutes=30) # the host stopped answering 30 min ago
async with SessionLocal() as db:
result = await run_notification_checks(db)
assert result["resolved"] == 0
assert await _open_count(db) == 1
@pytest.mark.asyncio
async def test_gateway_alert_is_delivered_once_and_resolution_follows(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _only("gateway_down_enabled")
await _set(webhook_enabled=True, webhook_url="https://hooks.test/n")
hook = respx_mock.post("https://hooks.test/n").mock(return_value=httpx.Response(200))
bad, good = {"gateways": [_gw("offline", 100.0, None)]}, {"gateways": [_gw()]}
await _add_samples(hid, [bad] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 1
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 0
assert hook.call_count == 1 and b"Gateway" in hook.calls.last.request.content
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [good] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 1
assert hook.call_count == 2 and b"behoben" in hook.calls.last.request.content
@pytest.mark.asyncio
async def test_gateway_check_is_on_by_default_and_the_others_are_off(client):
data = (await client.get("/api/notifications/settings")).json()
assert data["gateway_down_enabled"] is True
assert data["resource_threshold_enabled"] is False and data["service_down_enabled"] is False
assert data["service_watchlist"][:2] == ["unbound", "dnsmasq"]
# ── Resource thresholds ──────────────────────────────────────────────────────────
DISK_OK = [{"device": "zroot/ROOT/default", "type": "zfs", "used_pct": 40, "mountpoint": "/"}, {"device": "devfs", "type": "devfs", "used_pct": 100, "mountpoint": "/dev"}]
DISK_FULL = [{"device": "zroot/ROOT/default", "type": "zfs", "used_pct": 93, "mountpoint": "/"},
{"device": "zroot/var/log", "type": "zfs", "used_pct": 91, "mountpoint": "/var/log"},
{"device": "devfs", "type": "devfs", "used_pct": 100, "mountpoint": "/dev"}]
async def _resource_problems():
async with SessionLocal() as db:
return await monitor_checks.check_resource_threshold(db)
@pytest.mark.asyncio
async def test_disk_threshold_one_alert_per_pool_and_ignores_devfs(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [{"disk": DISK_FULL, "mem": 10, "load": 0.1}] * 5)
problems = await _resource_problems()
assert list(problems) == [f"{hid}:disk:zroot"]
assert problems[f"{hid}:disk:zroot"]["wert_prozent"] == 93 and problems[f"{hid}:disk:zroot"]["schwelle_prozent"] == 90
@pytest.mark.asyncio
async def test_memory_spike_does_not_alert_but_sustained_use_does(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
calm = {"disk": DISK_OK, "mem": 40, "load": 0.1}
await _add_samples(hid, [{"disk": DISK_OK, "mem": 99, "load": 0.1}, calm, calm, calm, calm])
assert dict(await _resource_problems()) == {}
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [{"disk": DISK_OK, "mem": 96, "load": 0.1}] * 5)
assert list(await _resource_problems()) == [f"{hid}:memory"]
await _set(memory_threshold_pct=0) # 0 = off
assert dict(await _resource_problems()) == {}
@pytest.mark.asyncio
async def test_swap_and_cpu_per_thread(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
# load 6 on 2 threads = 3 per thread (>= 2.0); load 6 on 8 threads would be fine
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 6.0, "swap_used_pct": 70.0, "cpu_threads": 2}] * 5)
assert sorted(await _resource_problems()) == sorted([f"{hid}:cpu", f"{hid}:swap"])
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 6.0, "swap_used_pct": None, "cpu_threads": 8}] * 5)
assert dict(await _resource_problems()) == {}
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
# unknown thread count: the load cannot be judged, so it is not reported
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 50.0, "swap_used_pct": None, "cpu_threads": None}] * 5)
assert dict(await _resource_problems()) == {}
# ── Services ─────────────────────────────────────────────────────────────────────
async def _service_problems():
async with SessionLocal() as db:
return await monitor_checks.check_service_down(db)
@pytest.mark.asyncio
async def test_service_down_only_after_it_was_seen_running(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
up = {"services": {"unbound": 1, "dhcpd6": 0, "cron": 0}}
down = {"services": {"unbound": 0, "dhcpd6": 0, "cron": 0}}
await _add_samples(hid, [down, down, down, up, up])
problems = await _service_problems()
# unbound stopped; dhcpd6 never ran; cron is not on the watchlist
assert list(problems) == [f"{hid}:unbound"]
assert problems[f"{hid}:unbound"]["dienst"] == "unbound"
@pytest.mark.asyncio
async def test_service_that_never_ran_or_stopped_long_ago_is_not_reported(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [{"services": {"unbound": 0}}] * 6)
assert dict(await _service_problems()) == {}
@pytest.mark.asyncio
async def test_service_alert_resolves_when_it_runs_again_or_leaves_the_watchlist(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
up, down = {"services": {"unbound": 1}}, {"services": {"unbound": 0}}
await _only("service_down_enabled")
await _add_samples(hid, [down, down, down, up])
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 1
await _set(service_watchlist=["ntpd"])
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 1
await _set(service_watchlist=["unbound"])
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 1 # reopens (the row is reused)
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
await _add_samples(hid, [up, up, up, down])
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 1
@pytest.mark.asyncio
async def test_service_watchlist_validation(client):
ok = await client.put("/api/notifications/settings", json={"service_watchlist": [" unbound ", "openvpn", "unbound"]})
assert ok.status_code == 200 and ok.json()["service_watchlist"] == ["unbound", "openvpn"]
assert (await client.put("/api/notifications/settings", json={"service_watchlist": ["bad name!"]})).status_code == 422
assert (await client.put("/api/notifications/settings", json={"disk_threshold_pct": 101})).status_code == 422
assert (await client.put("/api/notifications/settings", json={"alert_confirm_samples": 0})).status_code == 422
# ── Metrics and dashboard ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_metrics_and_dashboard_show_gateways_and_services(client):
host = await _create_host(client, "fw1", "https://fw1.test", cluster_name=None)
hid = uuid.UUID(host["id"])
await _add_samples(hid, [{"disk": DISK_OK, "mem": 10, "load": 0.1, "swap_used_pct": 5.0,
"gateways": [_gw(), _gw("offline", 100.0, None, name="DOWN_GW")],
"services": {"unbound": 1, "openvpn/3f2a": 0, "cron": 0}}])
text = (await client.get("/api/metrics")).text
assert 'opnexus_host_gateway_up{host="fw1",cluster="",gateway="WAN_GW"} 1' in text
assert 'opnexus_host_gateway_up{host="fw1",cluster="",gateway="DOWN_GW"} 0' in text
assert 'opnexus_host_gateway_delay_seconds{host="fw1",cluster="",gateway="WAN_GW"} 0.002' in text
assert 'opnexus_host_gateway_loss_percent{host="fw1",cluster="",gateway="DOWN_GW"} 100' in text
assert 'opnexus_host_swap_used_percent{host="fw1",cluster=""} 5' in text
assert 'opnexus_host_service_running{host="fw1",cluster="",service="unbound"} 1' in text
assert 'service="openvpn",instance="3f2a"} 0' in text
assert 'service="cron"' not in text # not on the watchlist
summary = (await client.get("/api/dashboard")).json()
gateways = {g["name"]: g for g in summary["hosts"][0]["gateways"]}
assert gateways["DOWN_GW"]["state"] == "offline"
@pytest.mark.asyncio
async def test_dashboard_has_no_gateways_without_a_fresh_sample(client):
host = await _create_host(client, "fw1", "https://fw1.test")
await _add_samples(uuid.UUID(host["id"]), [{"gateways": [_gw()]}], age_minutes=20)
assert (await client.get("/api/dashboard")).json()["hosts"][0]["gateways"] is None
# ── URL guard ────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("url", [
"http://169.254.169.254/latest/meta-data", "http://127.0.0.1:8000/x", "http://localhost/x", "http://[::1]/x",
"http://0.0.0.0/x", "http://[::ffff:169.254.169.254]/x", "ftp://ntfy.example/x", "file:///etc/passwd",
"https://user:[email protected]/", "https://", "not a url", "http://224.0.0.1/x", "http://[fe80::1]/x",
])
def test_url_guard_refuses(url):
with pytest.raises(ValueError):
url_guard.validate_url(url)
@pytest.mark.parametrize("url", ["https://ntfy.sh", "http://10.20.30.5:8080/path", "https://gotify.lan.example/", "http://192.168.1.10"])
def test_url_guard_allows_private_and_public_targets(url):
assert url_guard.validate_url(url) == url
@pytest.mark.asyncio
async def test_url_guard_refuses_names_resolving_to_metadata_address(monkeypatch):
async def fake(self, host, port, **kw):
return [(2, 1, 6, "", ("169.254.169.254", 0))]
monkeypatch.setattr("asyncio.base_events.BaseEventLoop.getaddrinfo", fake)
with pytest.raises(ValueError):
await url_guard.ensure_resolved_safe("https://innocent.example/hook")
@pytest.mark.asyncio
async def test_webhook_url_is_validated_when_saved_and_sent(client, respx_mock):
resp = await client.put("/api/notifications/settings", json={"webhook_url": "http://169.254.169.254/x"})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "NOTIFICATION_WEBHOOK_URL_INVALID"
row = NotificationSettings(webhook_url="http://127.0.0.1:9/x")
from app.services import notification_delivery
with pytest.raises(NotificationDeliveryError):
await notification_delivery.send_webhook(row, NotificationProblem(problem_type="test", dedup_key="t", context={}), "opened")
# ── Channels: formats ────────────────────────────────────────────────────────────
PROBLEM = NotificationProblem(problem_type="gateway_down", dedup_key="k", context={"host_name": "fw1", "gateway": "WAN_GW", "grund": "Gateway offline"})
def _channel(kind: str, config: dict, secret: str | None, name: str = "c1") -> NotificationChannel:
ch = NotificationChannel(id=uuid.uuid4(), name=name, channel_type=kind, enabled=True, config=config, secret_set=secret is not None)
if secret is not None:
notification_secret_store.save_secret(notification_channels.secret_name(ch.id), secret)
return ch
@pytest.mark.asyncio
async def test_ntfy_posts_json_to_root_with_bearer(respx_mock):
ch = _channel("ntfy", {"server_url": "https://ntfy.test", "topic": "alerts", "priority": 5}, "tk_secret")
route = respx_mock.post("https://ntfy.test/").mock(return_value=httpx.Response(200, json={"id": "x"}))
await notification_channels.send(ch, PROBLEM, "opened")
req = route.calls.last.request
assert req.headers["authorization"] == "Bearer tk_secret"
body = httpx.Response(200, content=req.content).json()
assert body["topic"] == "alerts" and body["priority"] == 5 and body["tags"] == ["warning"]
assert body["title"] == "OPNexus: Gateway gestört" and "gateway: WAN_GW" in body["message"]
await notification_channels.send(ch, PROBLEM, "resolved")
resolved = httpx.Response(200, content=route.calls.last.request.content).json()
assert resolved["priority"] == 3 and resolved["title"].endswith("behoben") and resolved["tags"] == ["white_check_mark"]
@pytest.mark.asyncio
async def test_ntfy_without_token_sends_no_auth_header(respx_mock):
ch = _channel("ntfy", {"server_url": "https://ntfy.test", "topic": "alerts"}, None)
route = respx_mock.post("https://ntfy.test/").mock(return_value=httpx.Response(200))
await notification_channels.send(ch, PROBLEM, "opened")
assert "authorization" not in route.calls.last.request.headers
@pytest.mark.asyncio
async def test_gotify_uses_key_header_not_query(respx_mock):
ch = _channel("gotify", {"server_url": "https://gotify.test/", "priority": 8}, "AppTok123")
route = respx_mock.post("https://gotify.test/message").mock(return_value=httpx.Response(200, json={"id": 1}))
await notification_channels.send(ch, PROBLEM, "opened")
req = route.calls.last.request
assert req.headers["x-gotify-key"] == "AppTok123" and "AppTok123" not in str(req.url)
body = httpx.Response(200, content=req.content).json()
assert body["priority"] == 8 and body["title"] == "OPNexus: Gateway gestört"
@pytest.mark.asyncio
async def test_telegram_sendmessage_and_ok_flag(respx_mock):
ch = _channel("telegram", {"chat_id": "-1001234", "api_base": "https://api.telegram.test"}, "123456:ABC-token")
route = respx_mock.post("https://api.telegram.test/bot123456:ABC-token/sendMessage").mock(
return_value=httpx.Response(200, json={"ok": True, "result": {}}))
await notification_channels.send(ch, PROBLEM, "opened")
body = httpx.Response(200, content=route.calls.last.request.content).json()
assert body["chat_id"] == "-1001234" and body["text"].startswith("OPNexus: Gateway gestört\n\n") and len(body["text"]) <= 4096
route.mock(return_value=httpx.Response(200, json={"ok": False, "description": "chat not found"}))
with pytest.raises(NotificationDeliveryError):
await notification_channels.send(ch, PROBLEM, "opened")
def test_telegram_text_is_clipped_to_the_api_limit():
big = NotificationProblem(problem_type="gateway_down", dedup_key="k", context={"x": "y" * 9000})
text = notification_channels.format_telegram({"chat_id": "1"}, big, "opened")["text"]
assert len(text) == 4096 and text.endswith("…")
@pytest.mark.asyncio
async def test_teams_adaptive_card_envelope(respx_mock):
url = "https://prod-12.westeurope.logic.azure.test/workflows/abc/triggers/manual/paths/invoke?sig=SECRETSIG"
ch = _channel("teams", {}, url)
route = respx_mock.post(url.split("?")[0]).mock(return_value=httpx.Response(202))
await notification_channels.send(ch, PROBLEM, "opened")
body = httpx.Response(200, content=route.calls.last.request.content).json()
assert body["type"] == "message"
att = body["attachments"][0]
assert att["contentType"] == "application/vnd.microsoft.card.adaptive" and att["content"]["type"] == "AdaptiveCard"
assert att["content"]["body"][0]["text"] == "OPNexus: Gateway gestört"
assert "sig=SECRETSIG" in str(route.calls.last.request.url)
@pytest.mark.asyncio
async def test_matrix_put_with_transaction_id_and_encoded_room(respx_mock):
ch = _channel("matrix", {"homeserver_url": "https://matrix.test", "room_id": "!abc123:matrix.test"}, "syt_token")
route = respx_mock.put(url__regex=r"^https://matrix\.test/_matrix/client/v3/rooms/%21abc123%3Amatrix\.test/send/m\.room\.message/[0-9a-f]{32}$").mock(
return_value=httpx.Response(200, json={"event_id": "$e"}))
await notification_channels.send(ch, PROBLEM, "opened")
await notification_channels.send(ch, PROBLEM, "opened")
first, second = (c.request for c in route.calls)
assert first.headers["authorization"] == "Bearer syt_token"
assert first.url.path != second.url.path # a new transaction id per message
body = httpx.Response(200, content=first.content).json()
assert body["msgtype"] == "m.text" and body["body"].startswith("OPNexus: Gateway gestört")
@pytest.mark.asyncio
async def test_failures_and_logs_never_contain_the_credential(respx_mock, caplog):
token = "123456:SECRET-BOT-TOKEN"
ch = _channel("telegram", {"chat_id": "42", "api_base": "https://api.telegram.test"}, token)
respx_mock.post(f"https://api.telegram.test/bot{token}/sendMessage").mock(return_value=httpx.Response(401, json={"ok": False}))
with caplog.at_level(logging.DEBUG):
with pytest.raises(NotificationDeliveryError) as exc_info:
await notification_channels.send(ch, PROBLEM, "opened")
respx_mock.post(f"https://api.telegram.test/bot{token}/sendMessage").mock(side_effect=httpx.ConnectError("boom"))
with pytest.raises(NotificationDeliveryError) as exc_info2:
await notification_channels.send(ch, PROBLEM, "opened")
assert "HTTP 401" in str(exc_info.value) and "ConnectError" in str(exc_info2.value)
for text in (str(exc_info.value), str(exc_info2.value), caplog.text):
assert "SECRET-BOT-TOKEN" not in text
assert exc_info.value.__cause__ is None and exc_info.value.__suppress_context__
@pytest.mark.asyncio
async def test_missing_or_unreadable_secret_fails_closed(respx_mock):
ch = _channel("gotify", {"server_url": "https://gotify.test"}, "tok")
notification_secret_store.delete_secret(notification_channels.secret_name(ch.id))
with pytest.raises(NotificationDeliveryError, match="nicht lesbar"):
await notification_channels.send(ch, PROBLEM, "opened")
ch.secret_set = False
with pytest.raises(NotificationDeliveryError, match="fehlen"):
await notification_channels.send(ch, PROBLEM, "opened")
@pytest.mark.asyncio
async def test_redirects_are_not_followed(respx_mock):
ch = _channel("gotify", {"server_url": "https://gotify.test"}, "tok")
respx_mock.post("https://gotify.test/message").mock(return_value=httpx.Response(302, headers={"location": "http://169.254.169.254/"}))
with pytest.raises(NotificationDeliveryError, match="HTTP 302"):
await notification_channels.send(ch, PROBLEM, "opened")
# ── Channels: API ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_channel_crud_keeps_the_secret_out_of_database_and_responses(client, respx_mock):
resp = await client.post("/api/notifications/channels", json={
"name": "Telegram Ops", "channel_type": "telegram", "config": {"chat_id": "-100123"}, "secret": "111:TOKEN-VALUE",
})
assert resp.status_code == 201, resp.text
created = resp.json()
assert created["secret_set"] is True and "TOKEN-VALUE" not in resp.text
assert created["config"] == {"chat_id": "-100123", "api_base": "https://api.telegram.org"}
cid = created["id"]
assert notification_secret_store.read_secret(f"channel_{cid}") == "111:TOKEN-VALUE"
async with SessionLocal() as db:
row = await db.get(NotificationChannel, uuid.UUID(cid))
assert "TOKEN-VALUE" not in str(row.config) and "TOKEN-VALUE" not in repr(row.__dict__)
assert "TOKEN-VALUE" not in (await client.get("/api/notifications/channels")).text
# test button
route = respx_mock.post("https://api.telegram.org/bot111:TOKEN-VALUE/sendMessage").mock(return_value=httpx.Response(200, json={"ok": True}))
assert (await client.post(f"/api/notifications/channels/{cid}/test")).json() == {"success": True}
assert b"Testbenachrichtigung" in route.calls.last.request.content
route.mock(return_value=httpx.Response(500))
failed = await client.post(f"/api/notifications/channels/{cid}/test")
assert failed.status_code == 502 and failed.json()["detail"]["code"] == "NOTIFICATION_CHANNEL_TEST_FAILED"
assert "TOKEN-VALUE" not in failed.text
# update: rename, disable, rotate secret, clear secret
upd = await client.put(f"/api/notifications/channels/{cid}", json={"name": "Telegram", "enabled": False, "secret": "222:NEW"})
assert upd.status_code == 200 and upd.json()["enabled"] is False
assert notification_secret_store.read_secret(f"channel_{cid}") == "222:NEW"
assert (await client.put(f"/api/notifications/channels/{cid}", json={"clear_secret": True})).json()["secret_set"] is False
assert notification_secret_store.read_secret(f"channel_{cid}") is None
assert (await client.delete(f"/api/notifications/channels/{cid}")).status_code == 204
assert (await client.get("/api/notifications/channels")).json() == []
assert (await client.delete(f"/api/notifications/channels/{cid}")).status_code == 404
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [
{"name": "a", "channel_type": "ntfy", "config": {"topic": "bad topic!"}},
{"name": "a", "channel_type": "ntfy", "config": {"topic": "ok", "server_url": "http://169.254.169.254"}},
{"name": "a", "channel_type": "ntfy", "config": {"topic": "ok", "priority": 9}},
{"name": "a", "channel_type": "gotify", "config": {"server_url": "https://g.test"}}, # token missing
{"name": "a", "channel_type": "gotify", "config": {}, "secret": "t"}, # server missing
{"name": "a", "channel_type": "telegram", "config": {"chat_id": "abc"}, "secret": "t"},
{"name": "a", "channel_type": "teams", "config": {}, "secret": "http://insecure.test/hook"},
{"name": "a", "channel_type": "teams", "config": {}},
{"name": "a", "channel_type": "matrix", "config": {"homeserver_url": "https://m.test", "room_id": "#alias:m.test"}, "secret": "t"},
{"name": "a", "channel_type": "carrier-pigeon", "config": {}},
{"name": " ", "channel_type": "ntfy", "config": {"topic": "ok"}}, # name only blanks
])
async def test_channel_validation(client, payload):
resp = await client.post("/api/notifications/channels", json=payload)
assert resp.status_code == 422, resp.text
@pytest.mark.asyncio
async def test_channel_names_are_unique_and_ntfy_needs_no_secret(client):
body = {"name": "Phone", "channel_type": "ntfy", "config": {"topic": "opnexus-alerts"}}
first = await client.post("/api/notifications/channels", json=body)
assert first.status_code == 201 and first.json()["secret_set"] is False
assert first.json()["config"]["server_url"] == "https://ntfy.sh" and first.json()["config"]["priority"] == 4
dup = await client.post("/api/notifications/channels", json=body)
assert dup.status_code == 409 and dup.json()["detail"]["code"] == "NOTIFICATION_CHANNEL_NAME_TAKEN"
@pytest.mark.asyncio
async def test_operator_cannot_change_channels_and_does_not_see_their_settings(client):
from tests.test_operator_role import PW, _operator
created = await client.post("/api/notifications/channels", json={"name": "Phone", "channel_type": "ntfy", "config": {"topic": "secret-topic"}})
op = await _operator(client, "chan-op")
try:
for method, path, body in [
("POST", "/api/notifications/channels", {"name": "x", "channel_type": "ntfy", "config": {"topic": "t"}}),
("PUT", f"/api/notifications/channels/{created.json()['id']}", {"enabled": False}),
("DELETE", f"/api/notifications/channels/{created.json()['id']}", None),
("POST", f"/api/notifications/channels/{created.json()['id']}/test", {}),
]:
resp = await op.request(method, path, json=body) if body is not None else await op.request(method, path)
assert resp.status_code == 403, (method, path, resp.text)
listing = await op.get("/api/notifications/channels")
assert listing.status_code == 200 and listing.json()[0]["config"] == {} and "secret-topic" not in listing.text
finally:
await op.aclose()
@pytest.mark.asyncio
async def test_alerts_reach_every_enabled_channel_and_one_failure_does_not_block_the_rest(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
async with SessionLocal() as db:
for kind, cfg, secret, name in [
("gotify", {"server_url": "https://gotify.test"}, "g-tok", "A gotify"),
("ntfy", {"server_url": "https://ntfy.test", "topic": "t"}, None, "B ntfy"),
]:
ch = _channel(kind, cfg, secret, name)
db.add(ch)
off = _channel("ntfy", {"server_url": "https://off.test", "topic": "t"}, None, "C disabled")
off.enabled = False
db.add(off)
await db.commit()
await _only("gateway_down_enabled")
gotify = respx_mock.post("https://gotify.test/message").mock(return_value=httpx.Response(500))
ntfy = respx_mock.post("https://ntfy.test/").mock(return_value=httpx.Response(200))
await _add_samples(hid, [{"gateways": [_gw("offline", 100.0, None)]}] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 1
assert gotify.call_count == 1 and ntfy.call_count == 1
assert "off.test" not in str([c.request.url for c in respx_mock.calls])
+452
View File
@@ -0,0 +1,452 @@
"""Taking over aliases, port forwards, 1:1 NAT and outbound NAT that were created by hand on a firewall.
The live rows below have the shape recorded from OPNsense 26.7 (test-fw-3, 2026-10-20), see the module
docstring of app/services/object_adopt_service.py."""
import json
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from tests.helpers import deploy_approved
from tests.test_api_tokens import _login
BASE = "https://fw-adopt-obj.test"
API = f"{BASE}/api/firewall"
async def _host(client, respx_mock, name="fw-obj"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
r = await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})
assert r.status_code == 201, r.text
return r.json()["id"]
def _search(respx_mock, path, rows):
return respx_mock.get(f"{API}/{path}").mock(return_value=httpx.Response(200, json={"rows": rows, "total": len(rows)}))
def _mode(respx_mock, mode="hybrid"):
respx_mock.get(f"{API}/source_nat/get").mock(return_value=httpx.Response(
200, json={"filter": {"general": {"snat_mode": {m: {"selected": 1 if m == mode else 0} for m in
("automatic", "hybrid", "advanced", "disabled")}}}}))
def _writes(respx_mock):
return [c for c in respx_mock.calls if c.request.method != "GET"]
# ── live rows (shapes captured from OPNsense 26.7) ──────────────────────
def alias_row(name, type_="host", content="10.9.9.1\n10.9.9.2", **over):
row = {"uuid": f"uuid-{name}", "enabled": "1", "name": name, "type": type_, "%type": type_.title(),
"content": content, "description": f"{name} desc", "proto": "", "interface": "", "counters": "0",
"updatefreq": "", "categories": ""}
row.update(over)
return row
SYSTEM_ALIASES = [
alias_row("bogons", "external", "", description="bogon networks (internal)"),
alias_row("sshlockout", "external", "", description="abuse lockout table (internal)"),
alias_row("__lan_network", "internal", "", description="lan net"),
]
def pf_row(**over):
row = {"uuid": "pf-1", "ipprotocol": "inet", "protocol": "tcp", "%protocol": "TCP", "disabled": "0", "nordr": "0",
"interface": "wan", "%interface": "WAN", "source.network": "any", "source.port": "", "source.not": "0",
"destination.network": "wanip", "destination.port": "8443", "destination.not": "0", "target": "192.168.1.50",
"local-port": "443", "natreflection": "", "descr": "Web", "pass": "", "log": "0", "poolopts": "", "tag": "",
"tagged": "", "nosync": "0", "categories": "", "is_automatic": False}
row.update(over)
return row
def o2o_row(**over):
row = {"uuid": "o2o-1", "enabled": "1", "interface": "wan", "%interface": "WAN", "type": "binat",
"source_net": "192.168.1.10", "source_not": "0", "destination_net": "any", "destination_not": "0",
"external": "203.0.113.201", "natreflection": "", "log": "0", "description": "Mail 1:1", "categories": ""}
row.update(over)
return row
def snat_row(**over):
row = {"uuid": "snat-1", "enabled": "1", "interface": "wan", "%interface": "WAN", "ipprotocol": "inet",
"protocol": "any", "source_net": "192.168.1.0/24", "source_not": "0", "source_port": "",
"destination_net": "any", "destination_not": "0", "destination_port": "", "target": "", "target_port": "",
"nonat": "0", "nosync": "0", "staticnatport": "0", "log": "0", "tag": "", "tagged": "",
"description": "LAN out", "categories": ""}
row.update(over)
return row
AUTOMATIC_NAT = [
{"uuid": "lockout_0", "interface": "lan", "descr": "Anti-Lockout Rule", "is_automatic": True},
{"uuid": "automatic_wan", "interface": "wan", "description": "Auto created rule", "is_automatic": True},
]
# kind -> (url prefix, search path, row factory, id field of the preview)
NAT_KINDS = {
"port-forward": ("d_nat/searchRule", pf_row, "Web"),
"one-to-one": ("one_to_one/searchRule", o2o_row, "Mail 1:1"),
"outbound-nat": ("source_nat/searchRule", snat_row, "LAN out"),
}
# ── aliases ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_alias_candidates_mark_system_unsupported_and_nested(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "alias/searchItem", SYSTEM_ALIASES + [
alias_row("ok_hosts"),
alias_row("ok_nets", "network", "10.0.0.0/8\n192.168.0.0/16"),
alias_row("ok_ports", "port", "8080\n9000:9010"),
alias_row("ok_group", "networkgroup", "ok_nets\n__lan_network"),
alias_row("a_url", "url", "https://example.org/list.txt", updatefreq="1"),
alias_row("geo", "geoip", "DE", proto="IPv4"),
alias_row("uses_url", "host", "a_url"),
alias_row("uses_hosts", "host", "ok_hosts\n10.9.9.3"),
alias_row("asn_v4", "asn", "64496", proto="IPv4"),
alias_row("bad name", "host", "10.1.1.1"),
alias_row("off", "host", "10.9.9.9", enabled="0"),
])
resp = await client.get(f"/api/aliases/unmanaged?host_id={host_id}")
assert resp.status_code == 200, resp.text
by = {r["title"]: r for r in resp.json()}
for name in ("bogons", "sshlockout", "__lan_network"):
assert by[name]["reasons"] == ["system"] and not by[name]["adoptable"]
for name in ("ok_hosts", "ok_nets", "ok_ports", "off"):
assert by[name]["adoptable"] is True and by[name]["reasons"] == []
assert by["off"]["enabled"] is False
# __lan_network is OPNsense's own and fine; ok_nets is a live alias that is not managed yet
assert by["ok_group"]["reasons"] == ["references_unmanaged_alias"] and by["ok_group"]["bulk_adoptable"] is True
assert by["ok_group"]["reason_details"]["references_unmanaged_alias"] == "ok_nets"
assert by["a_url"]["reasons"] == ["unsupported_type"] and by["geo"]["reasons"] == ["unsupported_type"]
assert by["a_url"]["reason_details"]["unsupported_type"]
assert by["uses_url"]["reasons"] == ["references_unmanaged_alias"]
assert by["uses_url"]["reason_details"]["references_unmanaged_alias"] == "a_url"
assert by["uses_url"]["bulk_adoptable"] is False # its target can never be adopted
assert by["asn_v4"]["reasons"] == ["proto_set"]
assert by["bad name"]["reasons"] == ["name_invalid"]
@pytest.mark.asyncio
async def test_nested_alias_waits_for_its_target_but_bulk_orders_them(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "alias/searchItem", [
alias_row("outer", "host", "inner\n10.0.0.9"),
alias_row("inner", "host", "base"),
alias_row("base", "host", "10.0.0.1"),
])
by = {r["title"]: r for r in (await client.get(f"/api/aliases/unmanaged?host_id={host_id}")).json()}
assert by["base"]["adoptable"] is True
for name, target in (("inner", "base"), ("outer", "inner")):
assert by[name]["adoptable"] is False and by[name]["bulk_adoptable"] is True
assert by[name]["reason_details"]["references_unmanaged_alias"] == target
# alone it is refused ...
refused = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-outer"})
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "OBJECT_NOT_ADOPTABLE"
assert "references_unmanaged_alias" in refused.json()["detail"]["params"]["reasons"]
# ... after its target is managed it works
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-base"})).status_code == 201
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-inner"})).status_code == 201
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-outer"})).status_code == 201
@pytest.mark.asyncio
async def test_adopt_alias_records_it_without_writing_and_preview_is_unchanged(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "alias/searchItem", SYSTEM_ALIASES + [
alias_row("web_servers", "host", "10.0.0.1\n 10.0.0.2 \n\nexample.org", description="Web tier"),
])
resp = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-web_servers"})
assert resp.status_code == 201, resp.text
alias = resp.json()
assert (alias["name"], alias["type"], alias["description"], alias["enabled"]) == ("web_servers", "host", "Web tier", True)
assert alias["content"] == "10.0.0.1\n10.0.0.2\nexample.org"
assert alias["target_host_ids"] == [host_id]
assert [d["status"] for d in alias["deployments"]] == ["success"]
assert alias["workflow_state"] == "synced"
assert not any("/firewall/" in str(c.request.url) for c in _writes(respx_mock))
preview = (await client.get(f"/api/aliases/{alias['id']}/preview")).json()
assert [(h["action"], h["changes"]) for h in preview["hosts"]] == [("unchanged", [])]
assert preview["can_deploy"] is False
# no longer offered; adopting again is refused
assert [r["title"] for r in (await client.get(f"/api/aliases/unmanaged?host_id={host_id}")).json()
if r["reasons"] != ["system"]] == []
again = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-web_servers"})
assert again.status_code == 409 and "already_managed" in again.json()["detail"]["params"]["reasons"]
# audit: the deployment record (kind alias, remote_uuid) is written with the adoption
async with SessionLocal() as db:
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "alias"))).all()
assert any(e.entity_name == "web_servers" and e.details["remote_uuid"] == "uuid-web_servers"
and e.message == "Von der Firewall übernommen" for e in events)
@pytest.mark.asyncio
async def test_edit_after_alias_adopt_updates_the_same_remote_object(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "alias/searchItem", [alias_row("web_servers", "host", "10.0.0.1")])
alias = (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-web_servers"})).json()
edit = {"name": "web_servers", "type": "host", "content": "10.0.0.1\n10.0.0.2", "description": "web_servers desc",
"enabled": True, "target_host_ids": [host_id], "target_group_ids": [], "expected_updated_at": alias["updated_at"]}
assert (await client.put(f"/api/aliases/{alias['id']}", json=edit)).status_code == 200
add = respx_mock.post(f"{API}/alias/addItem").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
setter = respx_mock.post(f"{API}/alias/setItem/uuid-web_servers").mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(f"{API}/alias/reconfigure").mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await deploy_approved(client, f"/api/aliases/{alias['id']}/deploy")
assert resp.status_code == 200, resp.text
assert setter.called and not add.called # no duplicate
assert json.loads(setter.calls.last.request.content)["alias"]["content"] == "10.0.0.1\n10.0.0.2"
confirm = await client.post(f"/api/aliases/{alias['id']}/confirm")
assert [d["status"] for d in confirm.json()["deployments"]] == ["success"]
@pytest.mark.asyncio
async def test_alias_name_that_exists_centrally_is_refused(client, respx_mock):
host_id = await _host(client, respx_mock)
assert (await client.post("/api/aliases", json={"name": "dup", "type": "host", "content": "1.1.1.1"})).status_code == 201
_search(respx_mock, "alias/searchItem", [alias_row("dup", "host", "2.2.2.2")])
row = (await client.get(f"/api/aliases/unmanaged?host_id={host_id}")).json()[0]
assert row["reasons"] == ["name_exists"]
assert (await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "uuid-dup"})).status_code == 409
@pytest.mark.asyncio
async def test_adopt_all_aliases_orders_dependencies_and_reports_skipped(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "alias/searchItem", SYSTEM_ALIASES + [
alias_row("outer", "host", "inner"),
alias_row("inner", "host", "10.0.0.1"),
alias_row("a_url", "url", "https://example.org/x"),
alias_row("uses_url", "host", "a_url"),
alias_row("plain", "port", "443"),
])
resp = await client.post("/api/aliases/adopt-all", json={"host_id": host_id})
assert resp.status_code == 200, resp.text
body = resp.json()
assert sorted(a["title"] for a in body["adopted"]) == ["inner", "outer", "plain"]
skipped = {s["title"]: s for s in body["skipped"]}
assert set(skipped) == {"a_url", "uses_url"} # system aliases are not reported
assert skipped["a_url"]["reasons"] == ["unsupported_type"]
assert skipped["uses_url"]["reasons"] == ["references_unmanaged_alias"]
aliases = {a["name"]: a for a in (await client.get("/api/aliases")).json()}
assert set(aliases) == {"inner", "outer", "plain"}
assert all([d["status"] for d in a["deployments"]] == ["success"] and a["workflow_state"] == "synced" for a in aliases.values())
# a second run finds nothing left to take over
again = (await client.post("/api/aliases/adopt-all", json={"host_id": host_id})).json()
assert again["adopted"] == [] and {s["title"] for s in again["skipped"]} == {"a_url", "uses_url"}
@pytest.mark.asyncio
async def test_adopt_unknown_alias_is_404(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "alias/searchItem", [alias_row("x")])
resp = await client.post("/api/aliases/adopt", json={"host_id": host_id, "uuid": "nope"})
assert resp.status_code == 404 and resp.json()["detail"]["code"] == "ADOPT_OBJECT_NOT_FOUND"
# ── NAT ─────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_automatic_nat_rows_are_never_offered(client, respx_mock):
host_id = await _host(client, respx_mock)
_mode(respx_mock)
for prefix, (path, factory, _) in NAT_KINDS.items():
_search(respx_mock, path, [factory(), *AUTOMATIC_NAT])
rows = (await client.get(f"/api/{prefix}/unmanaged?host_id={host_id}")).json()
assert [r["uuid"] for r in rows] == [factory()["uuid"]], prefix
assert rows[0]["adoptable"] is True
@pytest.mark.asyncio
async def test_port_forward_refusals(client, respx_mock):
host_id = await _host(client, respx_mock)
cases = {
"multi": (dict(interface="wan,lan"), "interface_not_single"),
"v46": (dict(ipprotocol="inet46"), "ipprotocol_unsupported"),
"anyproto": (dict(protocol=""), "protocol_any"),
"nordr": (dict(nordr="1", target="", **{"local-port": ""}), "no_redirect"),
"notarget": (dict(target=""), "no_target"),
"linked": (dict(**{"pass": "pass"}), "filter_rule_linked"),
"srcnot": (dict(**{"source.not": "1"}), "negated_source"),
"dstnot": (dict(**{"destination.not": "1"}), "negated_destination"),
"refl": (dict(natreflection="enable"), "natreflection_unsupported"),
"nodesc": (dict(descr=""), "no_description"),
"long": (dict(target="x" * 200), "value_too_long"),
}
_search(respx_mock, "d_nat/searchRule", [pf_row(**{"uuid": k, "descr": f"rule {k}", **over})
for k, (over, _) in cases.items()] + [pf_row(uuid="fine")])
rows = {r["uuid"]: r for r in (await client.get(f"/api/port-forward/unmanaged?host_id={host_id}")).json()}
assert rows["fine"]["adoptable"] is True
for key, (_, reason) in cases.items():
assert reason in rows[key]["reasons"] and not rows[key]["adoptable"], key
@pytest.mark.asyncio
async def test_port_forward_adopt_maps_fields_and_keeps_unmanaged_ones(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "d_nat/searchRule", [
pf_row(disabled="1", natreflection="purenat", log="1", poolopts="random", tag="tg1", **{"source.network": "10.0.0.0/8"}),
pf_row(uuid="pf-2", descr="Alias based", target="web_servers", **{"destination.port": "web_ports", "local-port": ""}),
*AUTOMATIC_NAT,
])
candidates = {r["uuid"]: r for r in (await client.get(f"/api/port-forward/unmanaged?host_id={host_id}")).json()}
assert sorted(candidates["pf-1"]["kept"]) == ["log", "poolopts", "tag"]
resp = await client.post("/api/port-forward/adopt", json={"host_id": host_id, "uuid": "pf-1"})
assert resp.status_code == 201, resp.text
rule = resp.json()
assert (rule["description"], rule["interface"], rule["ipprotocol"], rule["protocol"]) == ("Web", "wan", "inet", "tcp")
assert (rule["source_net"], rule["destination_net"], rule["destination_port"]) == ("10.0.0.0/8", "wanip", "8443")
assert (rule["target"], rule["local_port"], rule["natreflection"], rule["enabled"]) == ("192.168.1.50", "443", "purenat", False)
assert rule["workflow_state"] == "synced" and [d["status"] for d in rule["deployments"]] == ["success"]
preview = (await client.get(f"/api/port-forward/{rule['id']}/preview")).json()
assert [h["action"] for h in preview["hosts"]] == ["unchanged"] and preview["can_deploy"] is False
assert not _writes(respx_mock) or not any("/firewall/" in str(c.request.url) for c in _writes(respx_mock))
# edit -> deploy updates the same remote rule and never sends the fields it does not manage
edit = {k: rule[k] for k in ("description", "interface", "ipprotocol", "protocol", "source_net", "source_port",
"destination_net", "destination_port", "target", "local_port", "natreflection", "enabled")}
edit.update(target="192.168.1.51", target_host_ids=[host_id], target_group_ids=[], expected_updated_at=rule["updated_at"])
assert (await client.put(f"/api/port-forward/{rule['id']}", json=edit)).status_code == 200
add = respx_mock.post(f"{API}/d_nat/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
setter = respx_mock.post(f"{API}/d_nat/setRule/pf-1").mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(f"{API}/d_nat/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
assert (await deploy_approved(client, f"/api/port-forward/{rule['id']}/deploy")).status_code == 200
assert setter.called and not add.called
sent = json.loads(setter.calls.last.request.content)["rule"]
assert sent["target"] == "192.168.1.51" and sent["disabled"] == "1"
assert not {"log", "poolopts", "tag", "tagged", "nosync", "categories", "nordr"} & set(sent)
@pytest.mark.asyncio
async def test_one_to_one_adopt_and_refusals(client, respx_mock):
host_id = await _host(client, respx_mock)
_search(respx_mock, "one_to_one/searchRule", [
o2o_row(),
o2o_row(uuid="o2", description="NAT off", type="nat", enabled="0", natreflection="enable", log="1",
source_not="1", destination_net="10.0.0.0/8", destination_not="1"),
o2o_row(uuid="o3", interface="wan,lan", description="multi"),
o2o_row(uuid="o4", description=""),
o2o_row(uuid="o5", description="weird", type="other"),
o2o_row(uuid="o6", description="refl", natreflection="purenat"),
*AUTOMATIC_NAT,
])
rows = {r["uuid"]: r for r in (await client.get(f"/api/one-to-one/unmanaged?host_id={host_id}")).json()}
assert rows["o2o-1"]["adoptable"] and rows["o2"]["adoptable"]
assert rows["o3"]["reasons"] == ["interface_not_single"] and rows["o4"]["reasons"] == ["no_description"]
assert rows["o5"]["reasons"] == ["type_unsupported"] and rows["o6"]["reasons"] == ["natreflection_unsupported"]
rule = (await client.post("/api/one-to-one/adopt", json={"host_id": host_id, "uuid": "o2"})).json()
assert (rule["type"], rule["enabled"], rule["natreflection"], rule["log"], rule["source_not"], rule["destination_not"]) == (
"nat", False, "enable", True, True, True)
assert rule["destination_net"] == "10.0.0.0/8" and rule["external"] == "203.0.113.201"
preview = (await client.get(f"/api/one-to-one/{rule['id']}/preview")).json()
assert [h["action"] for h in preview["hosts"]] == ["unchanged"]
@pytest.mark.asyncio
async def test_outbound_nat_adopt_and_refusals(client, respx_mock):
host_id = await _host(client, respx_mock)
_mode(respx_mock, "hybrid")
_search(respx_mock, "source_nat/searchRule", [
snat_row(),
snat_row(uuid="s2", description="to pool", protocol="TCP", destination_port="443", target="203.0.113.205",
target_port="1024", staticnatport="1", log="1", enabled="0", destination_not="1", destination_net="10.0.0.0/8"),
snat_row(uuid="s3", description="no nat", nonat="1"),
snat_row(uuid="s4", description="v46", ipprotocol="inet46"),
snat_row(uuid="s5", description=""),
*AUTOMATIC_NAT,
])
rows = {r["uuid"]: r for r in (await client.get(f"/api/outbound-nat/unmanaged?host_id={host_id}")).json()}
assert rows["snat-1"]["adoptable"] and rows["s2"]["adoptable"]
assert sorted(rows["s2"]["kept"]) == ["log", "staticnatport"]
assert rows["s3"]["reasons"] == ["no_nat_rule"] and rows["s4"]["reasons"] == ["ipprotocol_unsupported"]
assert rows["s5"]["reasons"] == ["no_description"]
rule = (await client.post("/api/outbound-nat/adopt", json={"host_id": host_id, "uuid": "s2"})).json()
assert (rule["protocol"], rule["destination_port"], rule["target"], rule["target_port"], rule["enabled"],
rule["destination_not"]) == ("TCP", "443", "203.0.113.205", "1024", False, True)
assert rule["source_port"] is None
preview = (await client.get(f"/api/outbound-nat/{rule['id']}/preview")).json()
assert [h["action"] for h in preview["hosts"]] == ["unchanged"]
# edit -> deploy: setRule on the same uuid, nonat/log/staticnatport are not part of the payload
edit = {k: rule[k] for k in ("description", "interface", "source_net", "destination_net", "source_not", "destination_not",
"enabled", "ipprotocol", "protocol", "source_port", "destination_port", "target", "target_port")}
edit.update(enabled=True, target_host_ids=[host_id], target_group_ids=[], expected_updated_at=rule["updated_at"])
assert (await client.put(f"/api/outbound-nat/{rule['id']}", json=edit)).status_code == 200
add = respx_mock.post(f"{API}/source_nat/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "dup"}))
setter = respx_mock.post(f"{API}/source_nat/setRule/s2").mock(return_value=httpx.Response(200, json={"result": "saved"}))
respx_mock.post(f"{API}/source_nat/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
assert (await deploy_approved(client, f"/api/outbound-nat/{rule['id']}/deploy")).status_code == 200
assert setter.called and not add.called
sent = json.loads(setter.calls.last.request.content)["rule"]
assert sent["enabled"] == "1" and not {"nonat", "log", "staticnatport", "nosync", "tag"} & set(sent)
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["automatic", "disabled"])
async def test_outbound_nat_adoption_needs_a_manual_capable_mode(client, respx_mock, mode):
host_id = await _host(client, respx_mock)
_mode(respx_mock, mode)
search = _search(respx_mock, "source_nat/searchRule", [snat_row()])
for call in (client.get(f"/api/outbound-nat/unmanaged?host_id={host_id}"),
client.post("/api/outbound-nat/adopt", json={"host_id": host_id, "uuid": "snat-1"}),
client.post("/api/outbound-nat/adopt-all", json={"host_id": host_id})):
resp = await call
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "OUTBOUND_MODE_BLOCKS_ADOPT", resp.text
assert resp.json()["detail"]["params"]["mode"] == mode
assert not search.called
assert (await client.get("/api/outbound-nat")).json() == []
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", list(NAT_KINDS))
async def test_adopt_all_nat_takes_what_is_adoptable_and_is_idempotent(client, respx_mock, prefix):
host_id = await _host(client, respx_mock)
_mode(respx_mock)
path, factory, title = NAT_KINDS[prefix]
bad = {"descr": ""} if prefix == "port-forward" else {"description": ""}
_search(respx_mock, path, [factory(), factory(uuid="second", **({"descr": "Second"} if prefix == "port-forward" else {"description": "Second"})),
factory(uuid="bad", **bad), *AUTOMATIC_NAT])
result = (await client.post(f"/api/{prefix}/adopt-all", json={"host_id": host_id})).json()
assert sorted(a["title"] for a in result["adopted"]) == sorted([title, "Second"])
assert [(s["uuid"], s["reasons"]) for s in result["skipped"]] == [("bad", ["no_description"])]
listed = (await client.get(f"/api/{prefix}")).json()
assert len(listed) == 2 and all(r["workflow_state"] == "synced" for r in listed)
again = (await client.post(f"/api/{prefix}/adopt-all", json={"host_id": host_id})).json()
assert again["adopted"] == [] and len((await client.get(f"/api/{prefix}")).json()) == 2
assert not any("/firewall/" in str(c.request.url) for c in _writes(respx_mock))
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", ["aliases", *NAT_KINDS])
async def test_roles_and_unknown_host(client, respx_mock, prefix):
host_id = await _host(client, respx_mock)
_mode(respx_mock)
path = "alias/searchItem" if prefix == "aliases" else NAT_KINDS[prefix][0]
row = alias_row("a1") if prefix == "aliases" else NAT_KINDS[prefix][1]()
_search(respx_mock, path, [row])
unknown = "00000000-0000-0000-0000-000000000000"
assert (await client.get(f"/api/{prefix}/unmanaged?host_id={unknown}")).status_code == 404
assert (await client.post(f"/api/{prefix}/adopt", json={"host_id": unknown, "uuid": "x"})).status_code == 404
# read-only users cannot adopt (it creates central objects), operators can (it writes nothing to a firewall)
for role, expected in (("read_only", 403), ("operator", 201)):
user = await client.post("/api/users", json={"username": f"u-{role}-{prefix}", "password": "adopt-test-password-1", "role": role})
assert user.status_code == 201, user.text
session = await _login(f"u-{role}-{prefix}", "adopt-test-password-1")
try:
assert (await session.get(f"/api/{prefix}/unmanaged?host_id={host_id}")).status_code == 200
resp = await session.post(f"/api/{prefix}/adopt", json={"host_id": host_id, "uuid": row["uuid"]})
assert resp.status_code == expected, (role, resp.text)
finally:
await session.aclose()
+11
View File
@@ -58,6 +58,17 @@ async def test_status_without_token_is_unavailable_not_an_error(client, respx_mo
assert body["update_available"] is True # der Hinweis bleibt trotzdem
@pytest.mark.asyncio
@pytest.mark.parametrize("repo,mode", [("", "local"), ("git.example.test/opnexus", "release")])
async def test_install_mode_is_known_without_the_updater(client, respx_mock, monkeypatch, tmp_path, repo, mode):
# The panel must not tell a source-built install to enable an updater that could not update it anyway.
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
monkeypatch.setattr(settings, "opnexus_image_repo", repo)
_feed(respx_mock, _bump(minor=1))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "not_enabled" and body["mode"] == mode
@pytest.mark.asyncio
async def test_status_unreachable_updater(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA4pZWkNMkAtCGc54M2wuWy7CXwkT
3PCysv1/1IbkyJRPdM8+DFf26fBxWG9P9PLKfwavhqVYtHILxxzxKmtjeQ==
-----END PUBLIC KEY-----
+1
View File
@@ -116,6 +116,7 @@ services:
COOKIE_SECURE: ${COOKIE_SECURE:-false}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-}
OPNEXUS_IMAGE_REPO: ${OPNEXUS_IMAGE_REPO:-}
ALLOW_INSECURE_FIREWALL_URLS: ${ALLOW_INSECURE_FIREWALL_URLS:-false}
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
+102 -1
View File
@@ -74,6 +74,32 @@ Alternativ lassen sich auch aus einem Checkout die fertigen Images verwenden: in
der `.env` `OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus` und `OPNEXUS_VERSION=<version>`
setzen, dann `docker compose pull && docker compose up -d --no-build`.
### Images prüfen (Signatur)
Die Release-Images sind mit [cosign](https://github.com/sigstore/cosign) signiert (ab 1.23.9).
Damit lässt sich prüfen, dass ein Image wirklich von OPNexus stammt und seit dem Bau nicht
verändert wurde. Der öffentliche Schlüssel liegt als `cosign.pub` im Repository und im
Release-Bundle:
```bash
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
git.opnexus.dev/opnexus/opnexus-backend:<version>
```
Das Gleiche gilt für `opnexus-frontend`, `opnexus-proxy` und `opnexus-updater`. Eine
erfolgreiche Prüfung gibt die Signatur als JSON aus; bei einem fremden oder fehlenden
Schlüssel endet der Befehl mit einem Fehler. `--insecure-ignore-tlog=true` ist hier gewollt:
OPNexus lädt Signaturen nicht in das öffentliche Transparenzlog, damit die Prüfung auch in
abgeschotteten Netzen funktioniert. Die Signatur gilt dem Inhalt (Digest) des Images, nicht
dem Tag.
`opnexus update` prüft die Signaturen selbst, bevor es etwas wechselt, sobald `cosign`
installiert ist und `cosign.pub` neben der `docker-compose.yml` liegt (`cosign.pub` liegt im Bundle
bereits dabei, `cosign` selbst musst du installieren). Der Updater-Container für Updates per Klick
bringt `cosign` mit und prüft deshalb ebenfalls, solange `cosign.pub` im Projektverzeichnis liegt. Schlägt die Prüfung fehl, bricht das
Update ab und es wurde nichts verändert. Fehlt `cosign`, sagt `opnexus update` das und
macht ohne Prüfung weiter.
### Erster Start und Passwort
Ohne `ADMIN_PASSWORD` in der `.env` erzeugt OPNexus beim ersten Start ein
@@ -462,6 +488,37 @@ Beschreibung und Systemregeln. Die Oberfläche nennt den Grund je Regel.
> strikt „erster Treffer gewinnt“ aus. Die Reihenfolge ist dadurch
> sicherheitsrelevant, siehe [hierarchische Policies](#gerätegruppen-und-hierarchische-policies).
### Aliase und NAT von der Firewall übernehmen
Wie die Regeln lassen sich auch **Aliase** und **NAT-Regeln** (Port Forward, 1:1, Outbound) übernehmen, die von Hand
in OPNsense entstanden sind: auf der jeweiligen Seite **Von Firewall übernehmen**, Firewall wählen, dann entweder ein
Objekt mit **Übernehmen** oder alle auf einmal mit **Alle übernehmbaren übernehmen**. So kommt eine bestehende Firewall
ohne Abtippen unter zentrale Verwaltung.
Die Übernahme **schreibt nichts auf die Firewall**. OPNexus liest das Objekt frisch von der Firewall, legt es zentral an
(Ziel: genau diese Firewall) und merkt sich die UUID aus OPNsense. Deshalb zeigt die Vorschau direkt danach „keine Änderung“,
und der nächste Rollout nach einer Änderung aktualisiert dasselbe Objekt, statt ein zweites anzulegen. Die Übernahme steht im
Audit-Log; Operatoren dürfen übernehmen (sie legt nur zentrale Einträge an), Nur-Lese-Benutzer nicht.
Nicht übernommen wird, was das Modell verfälschen würde. Die Oberfläche nennt den Grund je Objekt:
| Objekt | Nicht übernehmbar, weil … |
|---|---|
| Aliase | OPNsense-eigen (`bogons`, `sshlockout`, `__lan_network` …); Typ unbekannt für OPNexus (URL, URL-Tabelle, GeoIP, dynamische IPv6-Hosts, OpenVPN-Gruppe, „Extern“); der Name existiert in OPNexus schon; Name mit Sonderzeichen; BGP-ASN mit IPv4/IPv6-Einschränkung; Verweis auf einen Alias, der nicht übernommen ist |
| Port Forward | mehrere oder keine Schnittstelle; IP-Version weder IPv4 noch IPv6; Protokoll „beliebig“; „Kein Redirect“; kein Ziel; verknüpfte Filterregel; invertierte Quelle oder Ziel; NAT-Reflection-Wert unbekannt; Beschreibung fehlt |
| 1:1-NAT | mehrere oder keine Schnittstelle; unbekannter Typ oder NAT-Reflection-Wert; Pflichtangaben oder Beschreibung fehlen |
| Outbound-NAT | mehrere oder keine Schnittstelle; „Kein NAT“-Regel (`nonat`); IP-Version weder IPv4 noch IPv6; Beschreibung fehlt |
Automatisch erzeugte NAT-Regeln (Anti-Lockout, „Auto created rule“) erscheinen nie in der Liste. **Verschachtelte Aliase:**
Ein Alias, der einen anderen, noch nicht übernommenen Alias enthält, wartet auf diesen; „Alle übernehmbaren übernehmen“
übernimmt beide in der richtigen Reihenfolge, einzeln zuerst den enthaltenen Alias. Verweist er auf einen Alias, der nie
übernommen werden kann (z. B. vom Typ URL), bleibt er mit diesem Grund zurück. **Outbound-NAT** lässt sich nur übernehmen,
wenn der Outbound-Modus der Firewall Hybrid oder Manuell ist; im Modus Automatisch zeigt OPNsense manuelle Regeln gar nicht an,
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.
---
## Gerätegruppen und hierarchische Policies
@@ -918,18 +975,58 @@ Einstellungen › **Benachrichtigungen**. Kanäle:
Absender und Empfängern. Zertifikat und Hostname werden geprüft; ein Mailserver
mit privater CA braucht `SMTP_CA_FILE`. Anmeldung ohne Verschlüsselung bleibt
gesperrt. Mit **testen** prüfst du jeden Kanal.
- **Weitere Kanäle** (Abschnitt „Weitere Kanäle“, beliebig viele je Typ, jeweils mit **Testen**):
| Typ | Angaben | Zugangsdaten |
|---|---|---|
| **ntfy** | Serveradresse (Standard `https://ntfy.sh`), Thema, Priorität 1–5 | Zugriffstoken, optional |
| **Gotify** | Serveradresse, Priorität 0–10 | App-Token |
| **Telegram** | Chat-ID (Zahl oder `@kanal`), optional eine eigene API-Adresse | Bot-Token (von @BotFather) |
| **Microsoft Teams** | – | Webhook-Adresse eines Workflows („Beim Empfang einer Webhook-Anfrage in einem Kanal posten“) oder eines Incoming-Webhooks |
| **Matrix** | Homeserver-Adresse, Raum-ID (`!abc:example.org`, nicht der Alias) | Zugriffstoken eines Benutzers, der im Raum ist |
Die Zugangsdaten liegen wie das SMTP-Passwort in der Geheimnis-Ablage des Servers, nie in der
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.
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. Geprüft wird im eingestellten Intervall (Standard 300 s). Ein Problem
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
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
ohne „behoben“-Meldung, weil sich ein verpasster Termin nicht von selbst behebt. Der
Text der Benachrichtigungen ist nicht übersetzt.
#### Gateway-, Ressourcen- und Dienst-Alarme
Diese drei Ereignisse lesen die **Minutenmessung** (dieselbe wie die Auslastungs-Diagramme), fragen die
Firewalls also nicht zusätzlich ab. Damit Einzelspitzen und flackernde Verbindungen nichts melden,
öffnet ein Alarm erst, wenn die letzten *N* Messungen alle schlecht sind, und schließt erst, wenn die letzten
*N* alle wieder gut sind; dazwischen bleibt der Zustand, wie er ist. *N* ist einstellbar (Standard 3 für
Gateways und Dienste, 5 für Ressourcen; die Messung läuft jede Minute). Meldet eine Firewall gerade nicht,
beurteilt OPNexus sie nicht (das meldet „Host nicht erreichbar“) und lässt offene Alarme stehen.
- **Gateway gestört** – **standardmäßig an**, weil ein WAN-Ausfall das ist, was jeder wissen will. Ein Alarm je Gateway, wenn OPNsense es als
*down* führt, oder wenn der Paketverlust (Standard ab 20 %) oder die Latenz (Standard ab 500 ms) darüber liegt (0 schaltet die jeweilige Schwelle ab).
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.
- **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.
- **Dienst ausgefallen** – standardmäßig **aus**. OPNsense liefert nur „läuft / läuft nicht“ und „gesperrt“, aber nicht, ob jemand einen Dienst
absichtlich gestoppt hat. Deshalb gilt eine **Liste überwachter Dienste** (Standard: unbound, dnsmasq, dhcpd, dhcpd6, kea-dhcp4, kea-dhcp6, ntpd,
openvpn, wireguard, strongswan), und gemeldet wird nur ein Dienst, der in den letzten rund zwei Stunden lief und jetzt nicht mehr. Ein Dienst, der
nie lief (etwa DHCPv6 ohne Präfix), bleibt still; einen absichtlich gestoppten Dienst nimmst du aus der Liste. Eine Firewall listet nur Dienste, die
in ihrer Konfiguration aktiviert sind, ein nicht eingerichteter Dienst taucht also gar nicht auf.
### Sicherheitslage
Unter **Monitoring › Sicherheitslage** prüft OPNexus jede Firewall auf typische
@@ -1030,6 +1127,10 @@ scrape_configs:
| `opnexus_host_enabled`, `…_last_sample_timestamp_seconds` | Überwachung an; Zeit der letzten Messung |
| `opnexus_host_memory_used_percent`, `opnexus_host_load1`, `opnexus_host_disk_used_percent` | Letzte Messung |
| `opnexus_host_carp_master` | 1 = MASTER, 0 = BACKUP (fehlt, wenn unbekannt) |
| `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_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 |
+9 -2
View File
@@ -1,9 +1,11 @@
FROM node:20-alpine AS deps
# deps und builder laufen immer auf der Build-Plattform (nativ schnell, `next build` unter QEMU-Emulation
# hängt); das Ergebnis (.next/standalone) ist reines JavaScript und gilt für jede Zielarchitektur.
FROM --platform=$BUILDPLATFORM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM node:20-alpine AS builder
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -11,10 +13,15 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
ARG TARGETARCH
ARG BUILDARCH
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Beim Cross-Build liegen die nativen sharp-Binärdateien der Build-Architektur im Standalone-Ordner; die App nutzt
# next/image nicht, sie wären nur totes Gewicht.
RUN if [ "$TARGETARCH" != "$BUILDARCH" ]; then rm -rf node_modules/@img node_modules/sharp; fi
EXPOSE 3000
CMD ["node", "server.js"]
+15 -4
View File
@@ -18,11 +18,13 @@ import {
Tags,
Trash2,
XCircle,
Download,
} from "lucide-react";
import { WorkflowBadge, workflowStates } from "@/components/WorkflowBadge";
import { useLanguage } from "@/components/LanguageProvider";
import { AliasReview } from "@/components/AliasReview";
import { LiveAliasesDrift } from "@/components/LiveAliasesDrift";
import { AdoptObjectsModal } from "@/components/AdoptObjectsModal";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
@@ -70,6 +72,7 @@ function AliasesContent() {
const typeLabel = (type: string) => (aliasTypes.includes(type as PolicyAliasType) ? t(`aliases.type.${type}`) : type);
const typeHint = (type: PolicyAliasType) => t(`aliases.hint.${type}`);
const readOnly = useReadOnly();
const [showAdopt, setShowAdopt] = useState(false);
const toast = useToast();
const router = useRouter();
const params = useSearchParams();
@@ -242,13 +245,21 @@ function AliasesContent() {
</Button>
)}
{activeTab === "managed" && (
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("aliases.new")}
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowAdopt(true)} disabled={readOnly}>
<Download size={16} />
{t("adopt.button")}
</Button>
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("aliases.new")}
</Button>
</div>
)}
</div>
{showAdopt && <AdoptObjectsModal kind="aliases" hosts={hosts} onClose={() => setShowAdopt(false)} onAdopted={load} />}
<Tabs
className="mb-6"
items={[
+38 -2
View File
@@ -11,6 +11,7 @@ import {
Activity,
CheckCircle2,
ClipboardList,
Globe,
Layers,
Network,
RefreshCw,
@@ -21,6 +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 { labelFor, type Language } from "@/lib/i18n";
const PENDING_KIND_LABELS: Record<string, string> = {
@@ -110,7 +112,7 @@ function certProblemLabel(context: Record<string, unknown>, t: (key: string, par
}
export default function DashboardPage() {
const { language, t, errorMessage } = useLanguage();
const { language, t, ts, errorMessage } = useLanguage();
const router = useRouter();
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
@@ -180,7 +182,11 @@ export default function DashboardPage() {
};
}, [errorMessage, router]);
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift", "gateway_down", "resource_threshold", "service_down"].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 })))
.sort((a, b) => gatewayOrder[a.gateway.state] - gatewayOrder[b.gateway.state]);
const securityWarningsLoading = problems === null || firmwareWarnings === null;
const securityWarningCount = healthProblems.length + (firmwareWarnings?.length ?? 0);
@@ -245,6 +251,28 @@ export default function DashboardPage() {
)}
</SectionCard>
<SectionCard title={t("dashboard.gateways")} icon={Globe}>
{summary === null ? (
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
) : gatewayRows.length === 0 ? (
<EmptyState icon={Globe} title={t("dashboard.noGateways")} description={t("dashboard.noGatewaysDescription")} />
) : (
<ul className="divide-y divide-slate-100">
{gatewayRows.slice(0, 8).map(({ host, gateway }) => (
<li key={`${host.id}-${gateway.name}`} className="flex flex-wrap items-center justify-between gap-2 py-2 text-sm">
<Link href={`/hosts/${host.id}`} className="min-w-0 truncate font-medium text-sky-700 hover:underline">
{host.name} · {gateway.name}
</Link>
<span className="flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">{gatewayNumbers(gateway, t)}</span>
<GatewayStateBadge state={gateway.state} />
</span>
</li>
))}
</ul>
)}
</SectionCard>
<SectionCard title={t("dashboard.securityPosture")} icon={ShieldAlert}>
{posture === null ? (
<Skeleton className="h-10" />
@@ -305,6 +333,14 @@ export default function DashboardPage() {
<Badge tone="danger" className="shrink-0">{t("dashboard.hostOffline")}</Badge>
</Link>
)}
{(problem.problem_type === "gateway_down" || problem.problem_type === "resource_threshold" || problem.problem_type === "service_down") && (
<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">
{String(problem.context.host_name)}: {ts(String(problem.context.gateway ?? problem.context.ressource ?? problem.context.dienst))}
</span>
<Badge tone="danger" className="shrink-0">{t(`dashboard.alert.${problem.problem_type}`)}</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">
+3
View File
@@ -2,6 +2,7 @@
import { HostMaintenance } from "@/components/HostMaintenance";
import { HostResourceUsage } from "@/components/HostResourceUsage";
import { HostGateways } from "@/components/GatewayStatus";
import { HostServices } from "@/components/HostServices";
import { RestoreWizard } from "@/components/RestoreWizard";
import { Button } from "@/components/ui/Button";
@@ -154,6 +155,8 @@ export default function HostDetailPage() {
<HostResourceUsage host={host} />
<HostGateways host={host} />
<HostMaintenance host={host} />
<h2 className="mb-3 text-lg font-semibold text-slate-900">{t("hostDetail.managedRules")}</h2>
+66 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { TwoFactorSection } from "@/components/TwoFactorSection";
import { NotificationChannelsSection } from "@/components/NotificationChannelsSection";
import { OpnexusUpdateSection } from "@/components/OpnexusUpdateSection";
import { InfoTip, Tooltip } from "@/components/ui/Tooltip";
import { useEffect, useState } from "react";
@@ -7,7 +8,7 @@ import { api, AppSettings, LogSettings, NotificationSettings, SmtpSecurity, Them
import { useTheme } from "@/components/ThemeProvider";
import { useLanguage } from "@/components/LanguageProvider";
import { Button } from "@/components/ui/Button";
import { Input, Select } from "@/components/ui/Field";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { useNotAdmin, useReadOnly } from "@/lib/useReadOnly";
import { OidcSection } from "@/components/OidcSection";
import { BackupSettingsSection } from "@/components/BackupSettingsSection";
@@ -66,6 +67,18 @@ function NotificationsSection() {
const [scheduledFailed, setScheduledFailed] = useState(true);
const [backupFailed, setBackupFailed] = useState(true);
const [complianceAlerts, setComplianceAlerts] = useState(false);
const [gatewayDown, setGatewayDown] = useState(true);
const [resourceThreshold, setResourceThreshold] = useState(false);
const [serviceDown, setServiceDown] = useState(false);
const [gatewayLoss, setGatewayLoss] = useState(20);
const [gatewayDelay, setGatewayDelay] = useState(500);
const [diskLimit, setDiskLimit] = useState(90);
const [memoryLimit, setMemoryLimit] = useState(95);
const [swapLimit, setSwapLimit] = useState(50);
const [loadLimit, setLoadLimit] = useState(2);
const [confirmSamples, setConfirmSamples] = useState(3);
const [resourceSamples, setResourceSamples] = useState(5);
const [watchlist, setWatchlist] = useState("");
const [pollInterval, setPollInterval] = useState(300);
function applyFromServer(data: NotificationSettings) {
@@ -91,6 +104,18 @@ function NotificationsSection() {
setScheduledFailed(data.scheduled_maintenance_failed_enabled);
setBackupFailed(data.backup_failed_enabled);
setComplianceAlerts(data.compliance_alerts_enabled);
setGatewayDown(data.gateway_down_enabled);
setResourceThreshold(data.resource_threshold_enabled);
setServiceDown(data.service_down_enabled);
setGatewayLoss(data.gateway_loss_threshold_pct);
setGatewayDelay(data.gateway_delay_threshold_ms);
setDiskLimit(data.disk_threshold_pct);
setMemoryLimit(data.memory_threshold_pct);
setSwapLimit(data.swap_threshold_pct);
setLoadLimit(data.load_per_cpu_threshold);
setConfirmSamples(data.alert_confirm_samples);
setResourceSamples(data.resource_confirm_samples);
setWatchlist(data.service_watchlist.join(", "));
setPollInterval(data.poll_interval_seconds);
setWebhookSecret("");
setClearWebhookSecret(false);
@@ -134,6 +159,18 @@ function NotificationsSection() {
scheduled_maintenance_failed_enabled: scheduledFailed,
backup_failed_enabled: backupFailed,
compliance_alerts_enabled: complianceAlerts,
gateway_down_enabled: gatewayDown,
resource_threshold_enabled: resourceThreshold,
service_down_enabled: serviceDown,
gateway_loss_threshold_pct: gatewayLoss,
gateway_delay_threshold_ms: gatewayDelay,
disk_threshold_pct: diskLimit,
memory_threshold_pct: memoryLimit,
swap_threshold_pct: swapLimit,
load_per_cpu_threshold: loadLimit,
alert_confirm_samples: confirmSamples,
resource_confirm_samples: resourceSamples,
service_watchlist: watchlist.split(/[\s,]+/).filter(Boolean),
poll_interval_seconds: pollInterval,
});
applyFromServer(data);
@@ -268,6 +305,9 @@ function NotificationsSection() {
[t("settings.notify.event.scheduledFailed"), scheduledFailed, setScheduledFailed],
[t("settings.notify.event.backupFailed"), backupFailed, setBackupFailed],
[t("settings.notify.event.complianceAlerts"), complianceAlerts, setComplianceAlerts],
[t("settings.notify.event.gatewayDown"), gatewayDown, setGatewayDown],
[t("settings.notify.event.resourceThreshold"), resourceThreshold, setResourceThreshold],
[t("settings.notify.event.serviceDown"), serviceDown, setServiceDown],
].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)} />
@@ -281,9 +321,34 @@ function NotificationsSection() {
</label>
</div>
<div className="space-y-3 rounded-lg border border-slate-100 p-4">
<p className="flex items-center gap-2 text-sm font-medium">{t("settings.notify.monitoring")}<InfoTip text={t("settings.notify.monitoringHint")} /></p>
<div className="grid gap-3 sm:grid-cols-2">
{([
["gatewayLoss", gatewayLoss, setGatewayLoss, 0, 100],
["gatewayDelay", gatewayDelay, setGatewayDelay, 0, 60000],
["diskLimit", diskLimit, setDiskLimit, 0, 100],
["memoryLimit", memoryLimit, setMemoryLimit, 0, 100],
["swapLimit", swapLimit, setSwapLimit, 0, 100],
["loadLimit", loadLimit, setLoadLimit, 0, 1000],
["confirmSamples", confirmSamples, setConfirmSamples, 1, 60],
["resourceSamples", resourceSamples, setResourceSamples, 1, 60],
] as [string, number, (v: number) => void, number, number][]).map(([key, value, setter, min, max]) => (
<label key={key} className="block text-sm">
<span className="mb-1 block font-medium text-slate-700">{t(`settings.notify.limit.${key}`)}</span>
<Input type="number" min={min} max={max} step={key === "loadLimit" ? 0.5 : 1} value={value} onChange={(e) => setter(Number(e.target.value))} />
</label>
))}
</div>
<FieldGroup label={t("settings.notify.limit.watchlist")} hint={t("settings.notify.limit.watchlistHint")}>
<Input value={watchlist} onChange={(e) => setWatchlist(e.target.value)} placeholder="unbound, dnsmasq, ntpd" />
</FieldGroup>
</div>
<div className="flex justify-end border-t border-slate-100 pt-4"><Button type="submit" disabled={saving || !ns}>{t("common.save")}</Button></div>
</fieldset>
</form>
<NotificationChannelsSection />
{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}
</section>
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Field";
import { useToast } from "@/components/ui/Toast";
import { useLanguage } from "@/components/LanguageProvider";
import { AdoptAllResult, AdoptKind, api, Host, UnmanagedObject } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
interface Props {
kind: AdoptKind;
hosts: Host[];
onClose: () => void;
onAdopted: () => void;
}
const TITLE_KEY: Record<AdoptKind, string> = {
aliases: "adopt.title.aliases",
"port-forward": "adopt.title.portForward",
"one-to-one": "adopt.title.oneToOne",
"outbound-nat": "adopt.title.outboundNat",
};
// The alias types OPNexus manages (the others only show OPNsense's own English label).
const ALIAS_TYPES = ["host", "network", "port", "networkgroup", "mac", "asn"];
function endpoint(net: string | undefined, port: string | undefined): string {
const base = net || "any";
return port ? `${base}:${port}` : base;
}
function isSystem(item: UnmanagedObject): boolean {
return item.reasons.length === 1 && item.reasons[0] === "system";
}
/**
* Lists the aliases / NAT rules that exist on a firewall but are not managed by OPNexus yet and lets the
* operator take them over, one by one or all adoptable ones at once. Taking over only records the object —
* nothing is written to the firewall until it is changed and rolled out the normal way (same idea as
* AdoptRulesModal for filter rules).
*/
export function AdoptObjectsModal({ kind, hosts, onClose, onAdopted }: Props) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const enabledHosts = hosts.filter((h) => h.enabled);
// Derived rather than initialised once: the host list may still be loading when the dialog opens.
const [chosenHostId, setHostId] = useState("");
const hostId = chosenHostId || enabledHosts[0]?.id || "";
const [items, setItems] = useState<UnmanagedObject[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [result, setResult] = useState<AdoptAllResult | null>(null);
function load(id: string) {
setItems(null);
setError(null);
api
.listUnmanagedObjects(kind, id)
.then(setItems)
.catch((e) => {
setItems([]);
setError(errorMessage(e, "adopt.loadFailed"));
});
}
useEffect(() => {
if (hostId) {
setResult(null);
load(hostId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
const shown = useMemo(
() => (items ?? []).filter((i) => !isSystem(i)).sort((a, b) => Number(b.adoptable) - Number(a.adoptable) || Number(b.bulk_adoptable) - Number(a.bulk_adoptable)),
[items]
);
const systemCount = (items ?? []).filter(isSystem).length;
const bulkCount = shown.filter((i) => i.bulk_adoptable).length;
function reasonText(code: string, details: Record<string, string>): string {
return t(`adopt.reasons.${code}`, { detail: details[code] ?? "" });
}
function describe(item: UnmanagedObject): string {
const i = item.info;
switch (kind) {
case "aliases":
return [ALIAS_TYPES.includes(i.type) ? t(`aliases.type.${i.type}`) : i.type_label, t("adopt.entries", { count: i.entries })].filter(Boolean).join(" · ");
case "port-forward":
return `${i.interface_label} · ${i.protocol} ${endpoint(i.source, i.source_port)} → ${endpoint(i.destination, i.destination_port)} ⇒ ${endpoint(i.target, i.target_port)}`;
case "one-to-one":
return `${i.interface_label} · ${i.type.toUpperCase()} · ${i.source} ⇄ ${i.external}${i.destination && i.destination !== "any" ? ` · ${t("adopt.towards", { destination: i.destination })}` : ""}`;
case "outbound-nat":
return `${i.interface_label} · ${i.protocol} ${endpoint(i.source, i.source_port)} → ${endpoint(i.destination, i.destination_port)} ⇒ ${i.target ? endpoint(i.target, i.target_port) : t("adopt.interfaceAddress")}`;
}
}
async function adopt(item: UnmanagedObject) {
setBusy(item.uuid);
try {
await api.adoptObject(kind, hostId, item.uuid);
toast.show(t("adopt.adopted", { name: item.title }), "success");
onAdopted();
load(hostId);
} catch (e) {
toast.show(errorMessage(e, "adopt.failed"), "danger");
} finally {
setBusy(null);
}
}
async function adoptAll() {
setBusy("all");
try {
const done = await api.adoptAllObjects(kind, hostId);
setResult(done);
toast.show(t("adopt.allDone", { count: done.adopted.length, skipped: done.skipped.length }), done.adopted.length ? "success" : "info");
onAdopted();
load(hostId);
} catch (e) {
toast.show(errorMessage(e, "adopt.failed"), "danger");
} finally {
setBusy(null);
}
}
return (
<Modal title={t(TITLE_KEY[kind])} onClose={onClose}>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
<p className="text-sm text-slate-600">{t("adopt.intro")}</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-slate-600">
{t("adopt.host")}
<Select className="w-auto" value={hostId} onChange={(e) => setHostId(e.target.value)}>
{enabledHosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Select>
</label>
{bulkCount > 0 && (
<Button onClick={adoptAll} disabled={busy !== null} loading={busy === "all"}>
{t("adopt.all", { count: bulkCount })}
</Button>
)}
</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>}
{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>
{result.skipped.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-slate-500">
{result.skipped.map((s) => (
<li key={s.uuid}>
<span className="font-medium text-slate-700">{s.title}</span>: {s.reasons.map((code) => reasonText(code, s.reason_details)).join(" · ")}
</li>
))}
</ul>
)}
</div>
)}
<ul className="space-y-2">
{shown.map((item) => (
<li key={item.uuid} className="rounded-lg border border-slate-200 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium text-slate-900">
{item.title}
{!item.enabled && <span className="ml-2 text-xs font-normal text-slate-500">{t("common.statusDisabled")}</span>}
</p>
<p className="break-words text-xs text-slate-500">{describe(item)}</p>
{kind === "aliases" && item.info.content && (
<p className="mt-1 whitespace-pre-line break-all font-mono text-xs text-slate-500">{item.info.content}</p>
)}
{kind === "aliases" && item.info.description && <p className="mt-1 text-xs text-slate-500">{item.info.description}</p>}
</div>
{item.adoptable ? (
<Button variant="outline" onClick={() => adopt(item)} disabled={busy !== null} loading={busy === item.uuid}>
{t("adopt.action")}
</Button>
) : (
<Badge tone={item.bulk_adoptable ? "info" : "neutral"}>
{item.bulk_adoptable ? t("adopt.bulkOnly") : t("adopt.notPossible")}
</Badge>
)}
</div>
{!item.adoptable && (
<p className="mt-2 text-xs text-slate-500">{item.reasons.map((code) => reasonText(code, item.reason_details)).join(" · ")}</p>
)}
{item.kept.length > 0 && (
<p className="mt-2 text-xs text-slate-500">
{t("adopt.kept", { fields: item.kept.map((field) => t(`adopt.keptFields.${field}`)).join(", ") })}
</p>
)}
</li>
))}
</ul>
{systemCount > 0 && <p className="text-xs text-slate-500">{t("adopt.systemNote", { count: systemCount })}</p>}
<div className="flex justify-end">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.close")}
</Button>
</div>
</div>
</Modal>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { Badge, BadgeTone } from "@/components/ui/Badge";
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 { useLanguage } from "@/components/LanguageProvider";
import { api, GatewayHealth, GatewayState, Host } from "@/lib/api";
import { Globe, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
const TONE: Record<GatewayState, BadgeTone> = {
online: "success", degraded: "warning", offline: "danger", forced_down: "neutral", unknown: "neutral",
};
export function GatewayStateBadge({ state }: { state: GatewayState }) {
const { t } = useLanguage();
return <Badge tone={TONE[state]}>{t(`gateway.state.${state}`)}</Badge>;
}
export function gatewayNumbers(g: GatewayHealth, t: (key: string, params?: Record<string, string | number>) => string): string {
if (g.loss === null && g.delay === null) return t("gateway.notMeasured");
const parts: string[] = [];
if (g.loss !== null) parts.push(t("gateway.loss", { value: g.loss }));
if (g.delay !== null) parts.push(t("gateway.delay", { value: g.delay }));
if (g.stddev !== null) parts.push(t("gateway.stddev", { value: g.stddev }));
return parts.join(" · ");
}
/** Live gateway state of one firewall (host detail page). */
export function HostGateways({ host }: { host: Host }) {
const { t, errorMessage } = useLanguage();
const [gateways, setGateways] = useState<GatewayHealth[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setError(null);
api.hostGateways(host.id).then(setGateways).catch((e) => { setGateways([]); setError(errorMessage(e, "gateway.loadFailed")); });
}, [host.id, errorMessage]);
useEffect(() => { load(); }, [load]);
return (
<>
<div className="mb-3 mt-8 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-slate-900">{t("gateway.title")}</h2>
<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">
{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")} />}
{gateways !== null && gateways.length > 0 && (
<ul className="divide-y divide-slate-100">
{gateways.map((g) => (
<li key={g.name} className="flex flex-wrap items-center justify-between gap-2 py-2 text-sm">
<span className="min-w-0">
<span className="font-medium text-slate-900">{g.name}</span>
{g.address && <span className="text-slate-500"> · {g.address}</span>}
</span>
<span className="flex flex-wrap items-center gap-3">
<span className="text-xs text-slate-500">{gatewayNumbers(g, t)}</span>
<GatewayStateBadge state={g.state} />
</span>
</li>
))}
</ul>
)}
</Card>
</>
);
}
@@ -0,0 +1,225 @@
"use client";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { InfoTip } from "@/components/ui/Tooltip";
import { useLanguage } from "@/components/LanguageProvider";
import { api, ChannelType, NotificationChannel } from "@/lib/api";
import { useNotAdmin } from "@/lib/useReadOnly";
import { BellRing } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
const TYPES: ChannelType[] = ["ntfy", "gotify", "telegram", "teams", "matrix"];
interface FieldSpec { key: string; kind: "text" | "number"; placeholder?: string; min?: number; max?: number }
// Non-secret settings per channel type; the credential is a separate write-only field.
const FIELDS: Record<ChannelType, FieldSpec[]> = {
ntfy: [{ key: "server_url", kind: "text", placeholder: "https://ntfy.sh" }, { key: "topic", kind: "text" }, { key: "priority", kind: "number", min: 1, max: 5 }],
gotify: [{ key: "server_url", kind: "text", placeholder: "https://gotify.example.org" }, { key: "priority", kind: "number", min: 0, max: 10 }],
telegram: [{ key: "chat_id", kind: "text", placeholder: "-1001234567890" }, { key: "api_base", kind: "text", placeholder: "https://api.telegram.org" }],
teams: [],
matrix: [{ key: "homeserver_url", kind: "text", placeholder: "https://matrix.example.org" }, { key: "room_id", kind: "text", placeholder: "!abc123:example.org" }],
};
const DEFAULTS: Record<ChannelType, Record<string, string | number>> = {
ntfy: { server_url: "https://ntfy.sh", topic: "", priority: 4 },
gotify: { server_url: "", priority: 5 },
telegram: { chat_id: "" },
teams: {},
matrix: { homeserver_url: "", room_id: "" },
};
function ChannelForm({ channel, onClose, onSaved }: { channel: NotificationChannel | null; onClose: () => void; onSaved: () => void }) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [type, setType] = useState<ChannelType>(channel?.channel_type ?? "ntfy");
const [name, setName] = useState(channel?.name ?? "");
const [enabled, setEnabled] = useState(channel?.enabled ?? true);
const [config, setConfig] = useState<Record<string, string | number>>(channel ? { ...channel.config } : { ...DEFAULTS.ntfy });
const [secret, setSecret] = useState("");
const [clearSecret, setClearSecret] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const secretRequired = type !== "ntfy";
function changeType(next: ChannelType) {
setType(next);
setConfig({ ...DEFAULTS[next] });
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError("");
try {
if (channel) {
await api.updateNotificationChannel(channel.id, { name, enabled, config, ...(secret ? { secret } : {}), clear_secret: clearSecret });
} else {
await api.createNotificationChannel({ name, channel_type: type, enabled, config, ...(secret ? { secret } : {}) });
}
toast.show(t("settings.channels.saved"), "success");
onSaved();
onClose();
} catch (err) {
setError(errorMessage(err, "common.saveFailed"));
} finally {
setSaving(false);
}
}
return (
<Modal title={channel ? t("settings.channels.edit") : t("settings.channels.add")} onClose={onClose}>
<form onSubmit={submit} className="min-h-0 space-y-3 overflow-y-auto pr-1">
{!channel && (
<label className="block text-sm">
<span className="mb-1 block font-medium text-slate-700">{t("settings.channels.type")}</span>
<Select value={type} onChange={(e) => changeType(e.target.value as ChannelType)}>
{TYPES.map((v) => <option key={v} value={v}>{t(`settings.channels.types.${v}`)}</option>)}
</Select>
</label>
)}
<label className="block text-sm">
<span className="mb-1 block font-medium text-slate-700">{t("settings.channels.name")}</span>
<Input value={name} maxLength={100} required onChange={(e) => setName(e.target.value)} />
</label>
{FIELDS[type].map((f) => (
<label key={f.key} className="block text-sm">
<span className="mb-1 block font-medium text-slate-700">{t(`settings.channels.field.${f.key}`)}</span>
<Input
type={f.kind}
min={f.min}
max={f.max}
placeholder={f.placeholder}
value={config[f.key] ?? ""}
onChange={(e) => setConfig({ ...config, [f.key]: f.kind === "number" ? Number(e.target.value) : e.target.value })}
/>
</label>
))}
<label className="block text-sm">
<span className="mb-1 block font-medium text-slate-700">
{t(`settings.channels.secret.${type}`)} {channel?.secret_set && !clearSecret ? t("settings.notify.isSet") : t("settings.notify.notSet")}
</span>
<Input
type="password"
autoComplete="off"
value={secret}
required={secretRequired && !channel?.secret_set}
onChange={(e) => { setSecret(e.target.value); setClearSecret(false); }}
placeholder={channel?.secret_set ? t("settings.notify.keepUnchanged") : secretRequired ? "" : t("settings.notify.optional")}
/>
</label>
{channel?.secret_set && !secretRequired && (
<label className="flex items-center gap-2 text-sm text-slate-500">
<input type="checkbox" checked={clearSecret} onChange={(e) => { setClearSecret(e.target.checked); if (e.target.checked) setSecret(""); }} />
{t("settings.channels.removeSecret")}
</label>
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
{t("settings.channels.enabled")}
</label>
{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
<div className="flex justify-end gap-2 border-t border-slate-100 pt-3">
<Button type="button" variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" loading={saving}>{t("common.save")}</Button>
</div>
</form>
</Modal>
);
}
export function NotificationChannelsSection() {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const readOnly = useNotAdmin();
const [channels, setChannels] = useState<NotificationChannel[] | null>(null);
const [editing, setEditing] = useState<NotificationChannel | "new" | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(() => {
api.notificationChannels().then(setChannels).catch((e) => { setChannels([]); toast.show(errorMessage(e, "settings.channels.loadFailed"), "danger"); });
}, [errorMessage, toast]);
useEffect(() => { load(); }, [load]);
async function test(c: NotificationChannel) {
setBusy(c.id);
try {
await api.testNotificationChannel(c.id);
toast.show(t("settings.channels.testSent", { name: c.name }), "success");
} catch (e) {
toast.show(errorMessage(e, "settings.notify.testFailed"), "danger");
} finally {
setBusy(null);
}
}
async function toggle(c: NotificationChannel) {
setBusy(c.id);
try {
await api.updateNotificationChannel(c.id, { enabled: !c.enabled });
load();
} catch (e) {
toast.show(errorMessage(e, "common.saveFailed"), "danger");
} finally {
setBusy(null);
}
}
async function remove(c: NotificationChannel) {
if (!confirm(t("settings.channels.confirmDelete", { name: c.name }))) return;
setBusy(c.id);
try {
await api.deleteNotificationChannel(c.id);
toast.show(t("settings.channels.deleted", { name: c.name }), "success");
load();
} catch (e) {
toast.show(errorMessage(e, "common.deleteFailed"), "danger");
} finally {
setBusy(null);
}
}
return (
<div className="space-y-3 rounded-lg border border-slate-100 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-medium">{t("settings.channels.title")}<InfoTip text={t("settings.channels.hint")} /></h3>
{!readOnly && <Button type="button" variant="outline" onClick={() => setEditing("new")}>{t("settings.channels.add")}</Button>}
</div>
{channels === null ? (
<Skeleton className="h-12" />
) : channels.length === 0 ? (
<EmptyState icon={BellRing} title={t("settings.channels.empty")} />
) : (
<ul className="divide-y divide-slate-100">
{channels.map((c) => (
<li key={c.id} className="flex flex-wrap items-center justify-between gap-2 py-2 text-sm">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-slate-900">{c.name}</span>
<Badge tone="info">{t(`settings.channels.types.${c.channel_type}`)}</Badge>
{!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={() => 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>
</>
)}
</span>
</li>
))}
</ul>
)}
{editing !== null && <ChannelForm channel={editing === "new" ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
</div>
);
}
+15 -4
View File
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { WorkflowBadge, workflowStates } from "@/components/WorkflowBadge";
import { useLanguage } from "@/components/LanguageProvider";
import { OneToOneReview } from "@/components/OneToOneReview";
import { AdoptObjectsModal } from "@/components/AdoptObjectsModal";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
@@ -39,6 +40,7 @@ import {
ShieldCheck,
Trash2,
XCircle,
Download,
} from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useReadOnly } from "@/lib/useReadOnly";
@@ -115,6 +117,7 @@ export default function OneToOnePanel() {
function OneToOneContent() {
const { t, errorMessage } = useLanguage();
const readOnly = useReadOnly();
const [showAdopt, setShowAdopt] = useState(false);
const toast = useToast();
const router = useRouter();
const params = useSearchParams();
@@ -391,13 +394,21 @@ function OneToOneContent() {
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-slate-900 flex items-center gap-2">{selectedGroup ? t("rules.groupTitle", { name: selectedGroup.name }) : '1:1 NAT'}<InfoTip text={t("rules.subtitle")} size={18} /></h1>
{activeTab === "managed" && (
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("rules.new")}
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowAdopt(true)} disabled={readOnly}>
<Download size={16} />
{t("adopt.button")}
</Button>
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("rules.new")}
</Button>
</div>
)}
</div>
{showAdopt && <AdoptObjectsModal kind="one-to-one" hosts={hosts} onClose={() => setShowAdopt(false)} onAdopted={load} />}
<Tabs
className="mb-6"
items={[
+3 -1
View File
@@ -208,7 +208,9 @@ export function OpnexusUpdateSection() {
{!status.available && (
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
{status.reason === "not_enabled" || status.reason === "unauthorized" ? (
{(status.reason === "not_enabled" || status.reason === "unauthorized") && status.mode === "local" ? (
<p>{t("settings.update.localMode")}</p>
) : status.reason === "not_enabled" || status.reason === "unauthorized" ? (
<>
<p className="font-medium">{t("settings.update.notEnabledTitle")}</p>
<p>{t("settings.update.notEnabledBody")}</p>
+15 -4
View File
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { WorkflowBadge, workflowStates } from "@/components/WorkflowBadge";
import { useLanguage } from "@/components/LanguageProvider";
import { OutboundNatReview } from "@/components/OutboundNatReview";
import { AdoptObjectsModal } from "@/components/AdoptObjectsModal";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
@@ -40,6 +41,7 @@ import {
ShieldCheck,
Trash2,
XCircle,
Download,
} from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useReadOnly } from "@/lib/useReadOnly";
@@ -118,6 +120,7 @@ export default function OutboundNatPanel() {
function OutboundNatContent() {
const { t, errorMessage } = useLanguage();
const readOnly = useReadOnly();
const [showAdopt, setShowAdopt] = useState(false);
const toast = useToast();
const router = useRouter();
const params = useSearchParams();
@@ -399,14 +402,22 @@ function OutboundNatContent() {
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-slate-900 flex items-center gap-2">{selectedGroup ? t("rules.groupTitle", { name: selectedGroup.name }) : "Outbound NAT"}<InfoTip text={t("rules.subtitle")} size={18} /></h1>
{activeTab === "managed" && (
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("rules.new")}
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowAdopt(true)} disabled={readOnly}>
<Download size={16} />
{t("adopt.button")}
</Button>
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("rules.new")}
</Button>
</div>
)}
</div>
<OutboundModes hosts={hosts} />
{showAdopt && <AdoptObjectsModal kind="outbound-nat" hosts={hosts} onClose={() => setShowAdopt(false)} onAdopted={load} />}
<Tabs
className="mb-6"
items={[
+15 -4
View File
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { WorkflowBadge, workflowStates } from "@/components/WorkflowBadge";
import { useLanguage } from "@/components/LanguageProvider";
import { PortForwardReview } from "@/components/PortForwardReview";
import { AdoptObjectsModal } from "@/components/AdoptObjectsModal";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
@@ -39,6 +40,7 @@ import {
ShieldCheck,
Trash2,
XCircle,
Download,
} from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useReadOnly } from "@/lib/useReadOnly";
@@ -116,6 +118,7 @@ export default function PortForwardPanel() {
function PortForwardContent() {
const { t, errorMessage } = useLanguage();
const readOnly = useReadOnly();
const [showAdopt, setShowAdopt] = useState(false);
const toast = useToast();
const router = useRouter();
const params = useSearchParams();
@@ -397,13 +400,21 @@ function PortForwardContent() {
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-slate-900 flex items-center gap-2">{selectedGroup ? t("rules.groupTitle", { name: selectedGroup.name }) : "Port Forward"}<InfoTip text={t("rules.subtitle")} size={18} /></h1>
{activeTab === "managed" && (
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("rules.new")}
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowAdopt(true)} disabled={readOnly}>
<Download size={16} />
{t("adopt.button")}
</Button>
<Button disabled={readOnly} onClick={() => openEditor(null)}>
<Plus size={16} />
{t("rules.new")}
</Button>
</div>
)}
</div>
{showAdopt && <AdoptObjectsModal kind="port-forward" hosts={hosts} onClose={() => setShowAdopt(false)} onAdopted={load} />}
<Tabs
className="mb-6"
items={[
+74 -2
View File
@@ -601,6 +601,28 @@ export interface UnmanagedRule {
reasons: string[];
}
export type AdoptKind = "aliases" | "port-forward" | "one-to-one" | "outbound-nat";
/** An alias / NAT rule found live on a host that OPNexus does not manage yet. */
export interface UnmanagedObject {
uuid: string;
title: string;
enabled: boolean;
adoptable: boolean;
/** Not adoptable alone yet, but in the same "adopt all" run (an alias whose referenced aliases come first). */
bulk_adoptable: boolean;
reasons: string[];
reason_details: Record<string, string>;
/** Settings OPNexus does not manage that stay untouched on the firewall. */
kept: string[];
info: Record<string, string>;
}
export interface AdoptAllResult {
adopted: { uuid: string; title: string; id: string }[];
skipped: { uuid: string; title: string; reasons: string[]; reason_details: Record<string, string> }[];
}
export type PolicyAliasType = "host" | "network" | "port" | "networkgroup" | "mac" | "asn";
export interface PolicyAlias {
@@ -1264,6 +1286,12 @@ export const api = {
getAuditExport: () => request<AuditExportSettings>("/settings/audit-export"),
updateAuditExport: (data: Partial<AuditExportUpdate>) => request<AuditExportSettings>("/settings/audit-export", { method: "PUT", body: JSON.stringify(data) }),
testAuditExport: () => request<{ success: boolean }>("/settings/audit-export/test", { method: "POST" }),
notificationChannels: () => request<NotificationChannel[]>("/notifications/channels"),
createNotificationChannel: (data: NotificationChannelCreate) => request<NotificationChannel>("/notifications/channels", { method: "POST", body: JSON.stringify(data) }),
updateNotificationChannel: (id: string, data: NotificationChannelUpdate) => request<NotificationChannel>(`/notifications/channels/${id}`, { method: "PUT", body: JSON.stringify(data) }),
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`),
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)}` : ""}`),
@@ -1396,6 +1424,9 @@ export const api = {
listRules: () => request<Rule[]>("/rules"),
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 }) }),
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`),
@@ -1678,7 +1709,9 @@ export interface OutboundMode { host_id: string; host_name: string; mode: string
export interface AuditEvent { id: string; created_at: string; correlation_id: string; actor: string; kind: string; action: string; status: string; entity_id: string | null; entity_name: string | null; host_id: string | null; host_name: string | null; message: string | null; details: Record<string,unknown>; }
export interface AuditPage { total: number; offset: number; limit: number; items: AuditEvent[]; }
export interface DashboardHost { id: string; name: string; status: HostStatus; carp_status: string | null; cluster_name: string | null; }
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 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[]; }
@@ -1761,10 +1794,36 @@ export interface NotificationSettings {
scheduled_maintenance_failed_enabled: boolean;
backup_failed_enabled: boolean;
compliance_alerts_enabled: boolean;
gateway_down_enabled: boolean;
resource_threshold_enabled: boolean;
service_down_enabled: boolean;
gateway_loss_threshold_pct: number;
gateway_delay_threshold_ms: number;
disk_threshold_pct: number;
memory_threshold_pct: number;
swap_threshold_pct: number;
load_per_cpu_threshold: number;
alert_confirm_samples: number;
resource_confirm_samples: number;
service_watchlist: string[];
poll_interval_seconds: number;
updated_at: string;
}
export type ChannelType = "ntfy" | "gotify" | "telegram" | "teams" | "matrix";
export interface NotificationChannel {
id: string;
name: string;
channel_type: ChannelType;
enabled: boolean;
config: Record<string, string | number>;
secret_set: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationChannelCreate { name: string; channel_type: ChannelType; enabled: boolean; config: Record<string, string | number>; secret?: string; }
export interface NotificationChannelUpdate { name?: string; enabled?: boolean; config?: Record<string, string | number>; secret?: string; clear_secret?: boolean; }
export interface NotificationSettingsUpdate {
webhook_enabled?: boolean;
webhook_url?: string;
@@ -1791,6 +1850,18 @@ export interface NotificationSettingsUpdate {
scheduled_maintenance_failed_enabled?: boolean;
backup_failed_enabled?: boolean;
compliance_alerts_enabled?: boolean;
gateway_down_enabled?: boolean;
resource_threshold_enabled?: boolean;
service_down_enabled?: boolean;
gateway_loss_threshold_pct?: number;
gateway_delay_threshold_ms?: number;
disk_threshold_pct?: number;
memory_threshold_pct?: number;
swap_threshold_pct?: number;
load_per_cpu_threshold?: number;
alert_confirm_samples?: number;
resource_confirm_samples?: number;
service_watchlist?: string[];
poll_interval_seconds?: number;
}
@@ -1817,7 +1888,8 @@ export type ProblemType =
| "scheduled_maintenance_failed"
| "backup_stale"
| "backup_offsite_failed"
| "compliance_finding";
| "compliance_finding"
| "gateway_down" | "resource_threshold" | "service_down";
export interface CarpEvent {
id: string;
+168 -8
View File
@@ -66,10 +66,27 @@
"scheduledFailed": "Geplante Wartung nicht ausgeführt",
"backupFailed": "Kein aktuelles Backup / Offsite-Kopie fehlgeschlagen",
"complianceAlerts": "Kritischer oder hoher Sicherheitsbefund (standardmäßig aus)",
"networkDrift": "Netzwerk-Abweichung im Cluster"
"networkDrift": "Netzwerk-Abweichung im Cluster",
"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)"
},
"pollInterval": "Prüfintervall (Sekunden)",
"pollHint": "Mindestens 60s, um die Firewalls nicht unnötig zu belasten."
"pollHint": "Mindestens 60s, 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": {
"gatewayLoss": "Gateway: Paketverlust ab (%)",
"gatewayDelay": "Gateway: Latenz ab (ms)",
"diskLimit": "Festplatte ab (% belegt)",
"memoryLimit": "Arbeitsspeicher ab (% belegt)",
"swapLimit": "Swap ab (% belegt)",
"loadLimit": "CPU-Last ab (Last je Thread)",
"confirmSamples": "Gateways und Dienste: Messungen in Folge",
"resourceSamples": "Ressourcen: Messungen in Folge",
"watchlist": "Überwachte Dienste",
"watchlistHint": "OPNsense-Dienstnamen, kommagetrennt. OPNexus meldet einen Dienst nur, wenn er zuvor lief und jetzt nicht mehr. Absichtlich gestoppte Dienste hier entfernen."
}
},
"logs": {
"saved": "Log-Ingest-Einstellungen gespeichert.",
@@ -122,7 +139,7 @@
"upToDate": "OPNexus ist auf dem neuesten Stand.",
"notes": "Release-Notes",
"checked": "Zuletzt geprüft: {time}",
"feedOff": "Die Versionsprüfung ist aus. Unten einschalten, damit OPNexus neue Versionen erkennt.",
"feedOff": "Die Versionsprüfung ist aus. Schalte sie oben ein, damit OPNexus neue Versionen erkennt.",
"feedTitle": "Auf neue Versionen prüfen",
"feedHint": "Fragt regelmäßig den Release-Feed ab, ob eine neuere Version erschienen ist. Gesendet wird nur eine einfache Anfrage (GET) mit der eigenen Versionsnummer, nichts sonst. Standardmäßig aus.",
"feedToggle": "Versionsprüfung aktivieren",
@@ -138,7 +155,7 @@
"notEnabledImages": "Das geht nur mit Release-Images (Release-Bundle oder OPNEXUS_IMAGE_REPO in der .env). Wer OPNexus selbst aus dem Quellcode baut, aktualisiert mit „git pull“ und „docker compose up -d --build“.",
"notEnabledCli": "Bis dahin geht das Update auf dem Server mit „opnexus update“.",
"unreachable": "Der Updater antwortet nicht. Auf dem Server prüfen: docker compose ps updater",
"localMode": "Diese Installation baut OPNexus lokal aus dem Quellcode (kein Release-Image). Aktualisieren auf dem Server mit git pull und docker compose up -d --build.",
"localMode": "Diese Installation nutzt keine Release-Images (OPNEXUS_IMAGE_REPO ist in der .env nicht gesetzt). Aktualisieren auf dem Server mit „git pull“ und „docker compose up -d --build“.",
"majorChange": "Version {latest} ist ein Hauptversions-Sprung. Bitte erst die Release-Notes lesen und dann auf dem Server mit „opnexus update {latest}“ aktualisieren.",
"button": "Auf {latest} aktualisieren",
"confirmTitle": "Auf {latest} aktualisieren?",
@@ -180,6 +197,51 @@
"title": "Einrichtungsassistent",
"intro": "Führt Schritt für Schritt durch Passwort, Zwei-Faktor-Anmeldung und das Verbinden einer Firewall.",
"open": "Assistent öffnen"
},
"channels": {
"title": "Weitere Kanäle",
"hint": "ntfy, Gotify, Telegram, Microsoft Teams und Matrix erhalten dieselben Meldungen wie Webhook und E-Mail. Zugangsdaten werden getrennt von der Datenbank gespeichert und nie angezeigt.",
"add": "Kanal hinzufügen",
"edit": "Kanal bearbeiten",
"type": "Typ",
"name": "Name",
"enabled": "Aktiv",
"disabled": "Aus",
"enable": "Einschalten",
"disable": "Ausschalten",
"test": "Testen",
"editAction": "Bearbeiten",
"deleteAction": "Löschen",
"empty": "Noch keine weiteren Kanäle eingerichtet",
"saved": "Kanal gespeichert.",
"deleted": "Kanal „{name}“ gelöscht.",
"confirmDelete": "Kanal „{name}“ wirklich löschen?",
"testSent": "Testnachricht über „{name}“ gesendet.",
"loadFailed": "Kanäle konnten nicht geladen werden",
"removeSecret": "Zugriffstoken entfernen",
"types": {
"ntfy": "ntfy",
"gotify": "Gotify",
"telegram": "Telegram",
"teams": "Microsoft Teams",
"matrix": "Matrix"
},
"field": {
"server_url": "Serveradresse",
"topic": "Thema",
"priority": "Priorität",
"chat_id": "Chat-ID",
"api_base": "API-Adresse (optional)",
"homeserver_url": "Homeserver-Adresse",
"room_id": "Raum-ID"
},
"secret": {
"ntfy": "Zugriffstoken (optional)",
"gotify": "App-Token",
"telegram": "Bot-Token",
"teams": "Webhook-Adresse des Workflows",
"matrix": "Zugriffstoken"
}
}
},
"nav": {
@@ -297,7 +359,15 @@
"majorAvailable": "Major-Upgrade verfügbar",
"stableAvailable": "Stabiles Update verfügbar",
"updateBadge": "Update",
"loadFailed": "Dashboard konnte nicht geladen werden"
"loadFailed": "Dashboard konnte nicht geladen werden",
"gateways": "Gateways",
"noGateways": "Keine Gateway-Daten",
"noGatewaysDescription": "Sobald die Messungen der Firewalls vorliegen (jede Minute), erscheinen hier Status, Verlust und Latenz.",
"alert": {
"gateway_down": "Gateway",
"resource_threshold": "Ressource",
"service_down": "Dienst"
}
},
"hosts": {
"add": "Host hinzufügen",
@@ -435,6 +505,67 @@
},
"logPackets": "Protokollieren"
},
"adopt": {
"button": "Von Firewall übernehmen",
"title": {
"aliases": "Aliase von der Firewall übernehmen",
"portForward": "Port-Forwards von der Firewall übernehmen",
"oneToOne": "1:1-NAT-Regeln von der Firewall übernehmen",
"outboundNat": "Outbound-NAT-Regeln von der Firewall übernehmen"
},
"intro": "Hier stehen Objekte, die direkt in OPNsense angelegt wurden. Nach der Übernahme verwaltest du sie in OPNexus. Die Übernahme selbst ändert nichts auf der Firewall.",
"host": "Firewall",
"none": "Nichts Übernehmbares gefunden. Alles ist bereits verwaltet.",
"action": "Übernehmen",
"all": "Alle übernehmbaren übernehmen ({count})",
"adopted": "„{name}“ übernommen.",
"allDone": "{count} übernommen, {skipped} übersprungen.",
"failed": "Übernahme fehlgeschlagen",
"loadFailed": "Objekte konnten nicht geladen werden.",
"notPossible": "Nicht übernehmbar",
"bulkOnly": "Nur mit „Alle übernehmbaren“",
"entries": "Einträge: {count}",
"towards": "Ziel {destination}",
"interfaceAddress": "Adresse der Schnittstelle",
"systemNote": "{count} OPNsense-eigene Aliase (z. B. bogons, Schnittstellen-Netze) werden nie übernommen.",
"kept": "Bleibt auf der Firewall unverändert: {fields}",
"keptFields": {
"log": "Protokollieren",
"poolopts": "Pool-Optionen",
"poolopts_sourcehashkey": "Source-Hash-Schlüssel",
"tag": "Tag",
"tagged": "Tagged",
"nosync": "Nicht synchronisieren",
"categories": "Kategorien",
"staticnatport": "Statischer NAT-Port",
"endpoint-independent": "Endpoint-Independent"
},
"reasons": {
"system": "OPNsense-eigenes Objekt",
"already_managed": "Wird bereits verwaltet",
"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",
"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",
"references_unmanaged_alias": "Verweist auf nicht übernommene Aliase: {detail}",
"interface_not_single": "Mehrere oder keine Schnittstelle",
"ipprotocol_unsupported": "IP-Version nicht abbildbar (nur IPv4 oder IPv6)",
"protocol_any": "Protokoll „beliebig“ wird nicht unterstützt",
"protocol_missing": "Protokoll fehlt",
"no_redirect": "„Kein Redirect“-Regel (No RDR)",
"no_target": "Kein Ziel gesetzt",
"filter_rule_linked": "Legt eine zugehörige Filterregel an oder ist mit einer verknüpft",
"negated_source": "Quelle ist invertiert (NOT)",
"negated_destination": "Ziel ist invertiert (NOT)",
"natreflection_unsupported": "NAT-Reflection-Wert wird nicht unterstützt",
"type_unsupported": "Regeltyp wird nicht unterstützt",
"incomplete": "Pflichtangaben fehlen",
"no_nat_rule": "„Kein NAT“-Regel (nonat) wird nicht unterstützt",
"no_description": "Beschreibung fehlt – bitte in OPNsense setzen"
}
},
"login": {
"prompt": "Bitte anmelden",
"username": "Benutzername",
@@ -596,6 +727,9 @@
"TOTP_NOT_ENABLED": "Zwei-Faktor-Anmeldung ist nicht aktiv",
"TOTP_SETUP_MISSING": "Es wurde keine Einrichtung gestartet",
"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.",
"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}",
"NETWORK_BACKUP_FAILED": "Backup von „{host}“ vor der Änderung fehlgeschlagen – es wurde nichts geändert",
@@ -624,7 +758,12 @@
"OFFSITE_TEST_FAILED": "Der Test des Offsite-Ziels ist fehlgeschlagen: {reason}",
"API_TOKEN_NOT_FOUND": "API-Token nicht gefunden",
"API_TOKEN_SCOPE_FORBIDDEN": "Dieses API-Token (Bereich „{scope}“) darf diese Aktion nicht ausführen.",
"API_TOKEN_SCOPE_TOO_HIGH": "Mit deiner Rolle ist dieser Token-Bereich nicht möglich (Nur-Lesen: „Lesen“, Operator: bis „Ausrollen“)."
"API_TOKEN_SCOPE_TOO_HIGH": "Mit deiner Rolle ist dieser Token-Bereich nicht möglich (Nur-Lesen: „Lesen“, Operator: bis „Ausrollen“).",
"NOTIFICATION_CHANNEL_INVALID": "Ungültige Angabe: {reason}",
"NOTIFICATION_CHANNEL_NOT_FOUND": "Der Benachrichtigungskanal wurde nicht gefunden.",
"NOTIFICATION_CHANNEL_NAME_TAKEN": "Ein Kanal mit diesem Namen existiert bereits.",
"NOTIFICATION_CHANNEL_TEST_FAILED": "Testnachricht nicht zugestellt: {reason}",
"NOTIFICATION_WEBHOOK_URL_INVALID": "Ungültige Webhook-Adresse: {reason}"
},
"workflow": {
"attention": "Fehler prüfen",
@@ -962,7 +1101,12 @@
"handoverOption": "Vorher automatisch auf den Partner umschalten und danach zurückschalten (CARP)",
"handoverHint": "Ablauf: Wartungsmodus auf diesem Host, Partner wird MASTER (wird geprüft), dann läuft die Aktion, nach Rückkehr des Hosts wird der Wartungsmodus wieder verlassen. Bei Problemen bleibt der Partner MASTER und es kommt eine Benachrichtigung. Beim Zurückschalten kann der Host je nach CARP-Einstellung die MASTER-Rolle zurückholen (kurzer zweiter Wechsel).",
"handoverBadge": "mit CARP-Umschaltung",
"phase": {"handover_wait": "wartet auf Übernahme durch den Partner", "job_wait": "Auftrag läuft", "leave": "schaltet zurück", "leave_wait": "prüft den Normalbetrieb"}
"phase": {
"handover_wait": "wartet auf Übernahme durch den Partner",
"job_wait": "Auftrag läuft",
"leave": "schaltet zurück",
"leave_wait": "prüft den Normalbetrieb"
}
},
"restore": {
"handoffWarning": "OPNexus führt die Wiederherstellung NICHT selbst aus. Nach der Übergabe musst du die Sicherung manuell im OPNsense-Webinterface importieren. Solange dieser Vorgang offen ist, sind Regel-/Alias-/NAT-Pushes und Host-Wartung für diesen Host gesperrt.",
@@ -1944,7 +2088,7 @@
"schedule": "Eingeplant",
"major_update": "Major-Upgrade",
"changes": "Änderungen",
"download": "Download",
"download": "Download",
"push": "Übertragen",
"register": "Registrieren",
"sign": "Signieren",
@@ -2686,5 +2830,21 @@
"backupHelp": "Sichert die Datenbank (Regeln, Hosts, Verlauf). API-Secrets und Schlüssel liegen in eigenen Docker-Volumes und sind nicht im Dump. Details im Handbuch.",
"docs": "Dokumentation zum Kommandozeilen-Werkzeug"
}
},
"gateway": {
"title": "Gateways",
"none": "Diese Firewall meldet keine Gateways.",
"loadFailed": "Gateway-Status konnte nicht geladen werden",
"notMeasured": "nicht überwacht",
"loss": "Verlust {value} %",
"delay": "{value} ms",
"stddev": "± {value} ms",
"state": {
"online": "Online",
"degraded": "Beeinträchtigt",
"offline": "Offline",
"forced_down": "Abgeschaltet",
"unknown": "Unbekannt"
}
}
}
+168 -8
View File
@@ -66,10 +66,27 @@
"scheduledFailed": "Scheduled maintenance did not run",
"backupFailed": "No recent backup / offsite copy failed",
"complianceAlerts": "Critical or high security finding (off by default)",
"networkDrift": "Network differences in the cluster"
"networkDrift": "Network differences in the cluster",
"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)"
},
"pollInterval": "Check interval (seconds)",
"pollHint": "At least 60s, so the firewalls are not loaded unnecessarily."
"pollHint": "At least 60s, so the firewalls are not loaded unnecessarily.",
"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": {
"gatewayLoss": "Gateway: packet loss from (%)",
"gatewayDelay": "Gateway: latency from (ms)",
"diskLimit": "Disk from (% used)",
"memoryLimit": "Memory from (% used)",
"swapLimit": "Swap from (% used)",
"loadLimit": "CPU load from (load per thread)",
"confirmSamples": "Gateways and services: consecutive measurements",
"resourceSamples": "Resources: consecutive measurements",
"watchlist": "Watched services",
"watchlistHint": "OPNsense service names, comma-separated. OPNexus reports a service only if it ran before and has stopped now. Remove services you stopped on purpose."
}
},
"logs": {
"saved": "Log ingest settings saved.",
@@ -122,7 +139,7 @@
"upToDate": "OPNexus is up to date.",
"notes": "Release notes",
"checked": "Last checked: {time}",
"feedOff": "The version check is off. Switch it on below so OPNexus can detect new versions.",
"feedOff": "The version check is off. Switch it on above so OPNexus can detect new versions.",
"feedTitle": "Check for new versions",
"feedHint": "Regularly asks the release feed whether a newer version has been published. Only a plain request (GET) carrying your own version number is sent, nothing else. Off by default.",
"feedToggle": "Enable version check",
@@ -138,7 +155,7 @@
"notEnabledImages": "This only works with release images (release bundle, or OPNEXUS_IMAGE_REPO in the .env). If you build OPNexus from source, update with “git pull” and “docker compose up -d --build”.",
"notEnabledCli": "Until then, update on the server with “opnexus update”.",
"unreachable": "The updater does not respond. Check on the server: docker compose ps updater",
"localMode": "This installation builds OPNexus locally from source (no release image). Update on the server with git pull and docker compose up -d --build.",
"localMode": "This installation does not use release images (OPNEXUS_IMAGE_REPO is not set in the .env). Update on the server with “git pull” and “docker compose up -d --build”.",
"majorChange": "Version {latest} is a major version jump. Please read the release notes first, then update on the server with “opnexus update {latest}”.",
"button": "Update to {latest}",
"confirmTitle": "Update to {latest}?",
@@ -180,6 +197,51 @@
"title": "Setup assistant",
"intro": "Walks you step by step through password, two-factor sign-in and connecting a firewall.",
"open": "Open assistant"
},
"channels": {
"title": "More channels",
"hint": "ntfy, Gotify, Telegram, Microsoft Teams and Matrix receive the same messages as webhook and email. Credentials are stored apart from the database and are never shown.",
"add": "Add channel",
"edit": "Edit channel",
"type": "Type",
"name": "Name",
"enabled": "Active",
"disabled": "Off",
"enable": "Turn on",
"disable": "Turn off",
"test": "Test",
"editAction": "Edit",
"deleteAction": "Delete",
"empty": "No further channels set up yet",
"saved": "Channel saved.",
"deleted": "Channel “{name}” deleted.",
"confirmDelete": "Really delete channel “{name}”?",
"testSent": "Test message sent through “{name}”.",
"loadFailed": "Channels could not be loaded",
"removeSecret": "Remove access token",
"types": {
"ntfy": "ntfy",
"gotify": "Gotify",
"telegram": "Telegram",
"teams": "Microsoft Teams",
"matrix": "Matrix"
},
"field": {
"server_url": "Server address",
"topic": "Topic",
"priority": "Priority",
"chat_id": "Chat ID",
"api_base": "API address (optional)",
"homeserver_url": "Homeserver address",
"room_id": "Room ID"
},
"secret": {
"ntfy": "Access token (optional)",
"gotify": "App token",
"telegram": "Bot token",
"teams": "Workflow webhook address",
"matrix": "Access token"
}
}
},
"nav": {
@@ -297,7 +359,15 @@
"majorAvailable": "Major upgrade available",
"stableAvailable": "Stable update available",
"updateBadge": "Update",
"loadFailed": "Could not load dashboard"
"loadFailed": "Could not load dashboard",
"gateways": "Gateways",
"noGateways": "No gateway data",
"noGatewaysDescription": "Once the firewalls' measurements are in (every minute), status, loss and latency appear here.",
"alert": {
"gateway_down": "Gateway",
"resource_threshold": "Resource",
"service_down": "Service"
}
},
"hosts": {
"add": "Add host",
@@ -435,6 +505,67 @@
},
"logPackets": "Log matching packets"
},
"adopt": {
"button": "Adopt from firewall",
"title": {
"aliases": "Adopt aliases from the firewall",
"portForward": "Adopt port forwards from the firewall",
"oneToOne": "Adopt 1:1 NAT rules from the firewall",
"outboundNat": "Adopt outbound NAT rules from the firewall"
},
"intro": "These objects were created directly in OPNsense. Once adopted you manage them in OPNexus. Adopting itself changes nothing on the firewall.",
"host": "Firewall",
"none": "Nothing adoptable found. Everything is already managed.",
"action": "Adopt",
"all": "Adopt all adoptable ({count})",
"adopted": "Adopted “{name}”.",
"allDone": "{count} adopted, {skipped} skipped.",
"failed": "Adopting failed",
"loadFailed": "Could not load objects.",
"notPossible": "Cannot be adopted",
"bulkOnly": "Only via “Adopt all”",
"entries": "Entries: {count}",
"towards": "to {destination}",
"interfaceAddress": "interface address",
"systemNote": "{count} OPNsense system aliases (e.g. bogons, interface networks) are never adopted.",
"kept": "Stays unchanged on the firewall: {fields}",
"keptFields": {
"log": "Logging",
"poolopts": "Pool options",
"poolopts_sourcehashkey": "Source hash key",
"tag": "Tag",
"tagged": "Tagged",
"nosync": "No sync",
"categories": "Categories",
"staticnatport": "Static NAT port",
"endpoint-independent": "Endpoint-independent"
},
"reasons": {
"system": "OPNsense system object",
"already_managed": "Already managed",
"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",
"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",
"references_unmanaged_alias": "Refers to aliases that are not adopted: {detail}",
"interface_not_single": "Several or no interface",
"ipprotocol_unsupported": "IP version cannot be represented (IPv4 or IPv6 only)",
"protocol_any": "Protocol “any” is not supported",
"protocol_missing": "Protocol missing",
"no_redirect": "“No redirect” rule (No RDR)",
"no_target": "No target set",
"filter_rule_linked": "Creates or is linked to an associated filter rule",
"negated_source": "Source is inverted (NOT)",
"negated_destination": "Destination is inverted (NOT)",
"natreflection_unsupported": "NAT reflection value is not supported",
"type_unsupported": "Rule type is not supported",
"incomplete": "Required fields missing",
"no_nat_rule": "“No NAT” rule (nonat) is not supported",
"no_description": "Description missing – please set one in OPNsense"
}
},
"login": {
"prompt": "Please sign in",
"username": "Username",
@@ -596,6 +727,9 @@
"TOTP_NOT_ENABLED": "Two-factor authentication is not enabled",
"TOTP_SETUP_MISSING": "No setup has been started",
"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.",
"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}",
"NETWORK_BACKUP_FAILED": "Backup of “{host}” before the change failed – nothing was changed",
@@ -624,7 +758,12 @@
"OFFSITE_TEST_FAILED": "The offsite target test failed: {reason}",
"API_TOKEN_NOT_FOUND": "API token not found",
"API_TOKEN_SCOPE_FORBIDDEN": "This API token (scope “{scope}”) is not allowed to perform this action.",
"API_TOKEN_SCOPE_TOO_HIGH": "Your role does not allow a token with this scope (read-only: read, operator: up to deploy)."
"API_TOKEN_SCOPE_TOO_HIGH": "Your role does not allow a token with this scope (read-only: read, operator: up to deploy).",
"NOTIFICATION_CHANNEL_INVALID": "Invalid value: {reason}",
"NOTIFICATION_CHANNEL_NOT_FOUND": "The notification channel was not found.",
"NOTIFICATION_CHANNEL_NAME_TAKEN": "A channel with this name already exists.",
"NOTIFICATION_CHANNEL_TEST_FAILED": "Test message not delivered: {reason}",
"NOTIFICATION_WEBHOOK_URL_INVALID": "Invalid webhook address: {reason}"
},
"workflow": {
"attention": "Check errors",
@@ -962,7 +1101,12 @@
"handoverOption": "Switch to the partner first and switch back afterwards (CARP)",
"handoverHint": "Flow: maintenance mode on this host, the partner becomes MASTER (verified), then the action runs, and once the host is back the maintenance mode is left again. If anything goes wrong the partner stays MASTER and a notification is sent. When switching back, the host may take the MASTER role back depending on the CARP settings (a short second switchover).",
"handoverBadge": "with CARP switchover",
"phase": {"handover_wait": "waiting for the partner to take over", "job_wait": "job running", "leave": "switching back", "leave_wait": "verifying normal operation"}
"phase": {
"handover_wait": "waiting for the partner to take over",
"job_wait": "job running",
"leave": "switching back",
"leave_wait": "verifying normal operation"
}
},
"restore": {
"handoffWarning": "OPNexus does NOT perform the restore itself. After the handover you must import the backup manually in the OPNsense web interface. While this operation is open, rule/alias/NAT pushes and host maintenance are blocked for this host.",
@@ -1944,7 +2088,7 @@
"schedule": "Scheduled",
"major_update": "Major upgrade",
"changes": "Changes",
"download": "Download",
"download": "Download",
"push": "Push",
"register": "Register",
"sign": "Sign",
@@ -2686,5 +2830,21 @@
"backupHelp": "Covers the database (rules, hosts, history). API secrets and keys live in their own Docker volumes and are not in the dump. See the manual for details.",
"docs": "Documentation for the command-line tool"
}
},
"gateway": {
"title": "Gateways",
"none": "This firewall reports no gateways.",
"loadFailed": "Gateway status could not be loaded",
"notMeasured": "not monitored",
"loss": "Loss {value} %",
"delay": "{value} ms",
"stddev": "± {value} ms",
"state": {
"online": "Online",
"degraded": "Degraded",
"offline": "Offline",
"forced_down": "Switched off",
"unknown": "Unknown"
}
}
}
+30
View File
@@ -203,4 +203,34 @@ export const serverMessagesEn: Array<[string | RegExp, string]> = [
["Für diese Aktion wird ein Zertifikat (PEM) benötigt", "A certificate (PEM) is required for this action"],
["Ein privater Schlüssel kann nur bei Aktion 'import' hinterlegt werden", "A private key can only be stored for the “import” action"],
["Für CA 'custom' wird eine Custom-CA-URL benötigt", "A custom CA URL is required for CA “custom”"],
// --- notification channels, webhook address check, monitoring alerts (1.24) ---
["Die Adresse ist leer, zu lang oder enthält Leerzeichen", "The address is empty, too long or contains spaces"],
["Die Adresse ist ungültig", "The address is invalid"],
["Die Adresse muss mit http:// oder https:// beginnen", "The address must start with http:// or https://"],
["Zugangsdaten gehören nicht in die Adresse", "Credentials do not belong in the address"],
["Diese Adresse (lokal, Link-Local oder Metadaten-Dienst) ist als Ziel nicht erlaubt", "This address (local, link-local or metadata service) is not allowed as a target"],
["Der Name zeigt auf eine nicht erlaubte Adresse (lokal, Link-Local oder Metadaten-Dienst)", "The name points to an address that is not allowed (local, link-local or metadata service)"],
["Unbekannter Kanaltyp", "Unknown channel type"],
[/^(?<key>\w+): ganze Zahl erwartet$/, "{key}: whole number expected"],
[/^(?<key>\w+): Wert zwischen (?<low>-?\d+) und (?<high>-?\d+) erwartet$/, "{key}: value between {low} and {high} expected"],
["Thema: 1–64 Zeichen aus Buchstaben, Ziffern, _ und -", "Topic: 1–64 characters (letters, digits, _ and -)"],
["Serveradresse fehlt", "Server address missing"],
["Chat-ID: Zahl (z. B. -1001234567890) oder @Kanalname", "Chat ID: a number (e.g. -1001234567890) or @channelname"],
["Homeserver-Adresse fehlt", "Homeserver address missing"],
["Raum-ID: Form !abc123:example.org (nicht der Alias #raum:…)", "Room ID: form !abc123:example.org (not the alias #room:…)"],
["Zugangsdaten fehlen", "Credentials missing"],
["Konfiguration unvollständig", "Configuration incomplete"],
["Die Teams-Webhook-Adresse muss mit https:// beginnen", "The Teams webhook address must start with https://"],
["Token: keine Leerzeichen, höchstens 500 Zeichen", "Token: no spaces, at most 500 characters"],
["Zugangsdaten nicht lesbar – bitte neu setzen", "Credentials not readable – please set them again"],
["Zustellung abgelehnt", "Delivery refused"],
[/^Zustellung fehlgeschlagen \((?<reason>[^)]+)\)$/, "Delivery failed ({reason})"],
["Antwort nicht lesbar", "Response not readable"],
[/^Kanal „(?<name>[\s\S]+?)“ \((?<type>\w+)\): (?<reason>[\s\S]+)$/, "Channel “{name}” ({type}): {reason}"],
[/^Kanal „(?<name>[\s\S]+?)“: (?<reason>[\s\S]+)$/, "Channel “{name}”: {reason}"],
["Dienstnamen bestehen aus Buchstaben, Ziffern und . _ + -", "Service names consist of letters, digits and . _ + -"],
["Arbeitsspeicher", "Memory"],
["CPU-Last (pro Thread)", "CPU load (per thread)"],
[/^Festplatte (?<disk>[\s\S]+)$/, "Disk {disk}"],
];
+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.23.6";
export const APP_CODENAME = "Harrier";
export const APP_VERSION = "1.24.0";
export const APP_CODENAME = "Merlin";
export const APP_MAINTAINER = "Julian Boldt";
+34 -1
View File
@@ -9,6 +9,13 @@
# scripts/build_release_images.sh v1.10.0 --push # bauen und pushen
# scripts/build_release_images.sh v1.10.0 --push --multiarch # amd64+arm64 (braucht QEMU/buildx)
#
# Einmalig pro Build-Host für --multiarch (scripts/release_all.sh nutzt es immer):
# docker run --privileged --rm tonistiigi/binfmt --install arm64 # QEMU-Emulation (bis zum Neustart)
# docker buildx create --name opnexus-multiarch --driver docker-container --bootstrap
# Der Builder lässt sich mit OPNEXUS_BUILDER ändern; fehlt er, bricht das Skript mit dieser Anleitung ab.
#
# Signieren (cosign): OPNEXUS_SIGN=1 COSIGN_KEY=/pfad/cosign.key [COSIGN_PASSWORD=…] — der öffentliche
# Schlüssel liegt als cosign.pub im Repo; die Registry muss ihren Token-Realm per https melden.
# REPO: Umgebungsvariable OPNEXUS_IMAGE_REPO (Standard git.opnexus.dev/opnexus).
# Hinweis: Hinter dem Cloudflare-Proxy (Free) scheitern Pushes mit Layern > 100 MB —
# dann die Registry-Domain auf "DNS only" stellen oder aus dem LAN pushen.
@@ -22,6 +29,17 @@ for a in "$@"; do case "$a" in --push) push=1;; --multiarch) multi=1;; *) echo "
[ "$multi" = 0 ] || [ "$push" = 1 ] || { echo "--multiarch geht nur zusammen mit --push" >&2; exit 1; }
git rev-parse -q --verify "refs/tags/$tag^{commit}" >/dev/null || { echo "Tag $tag existiert lokal nicht" >&2; exit 1; }
repo="${OPNEXUS_IMAGE_REPO:-git.opnexus.dev/opnexus}"
builder="${OPNEXUS_BUILDER:-opnexus-multiarch}"
if [ "$multi" = 1 ]; then
docker buildx inspect "$builder" >/dev/null 2>&1 \
|| { echo "buildx-Builder '$builder' fehlt: docker buildx create --name $builder --driver docker-container --bootstrap" >&2; exit 1; }
# Der Cross-Build füllt den Build-Cache um mehrere GB; ein volles Dateisystem legt auch die laufenden Dienste lahm.
free_mb="$(df --output=avail -BM . | tail -1 | tr -dc 0-9)"
[ "$free_mb" -ge 3500 ] || { echo "Nur ${free_mb} MB frei (für --multiarch werden ca. 3500 MB gebraucht): Platz schaffen (docker image prune, docker buildx prune)" >&2; exit 1; }
# Emulation ist nach einem Neustart weg; ohne sie scheitert der arm64-Teil erst nach Minuten.
docker buildx inspect "$builder" | grep -q 'linux/arm64' \
|| { echo "arm64 nicht verfügbar: docker run --privileged --rm tonistiigi/binfmt --install arm64" >&2; exit 1; }
fi
version="${tag#v}"
# Aus dem Tag bauen, nicht aus dem Working Tree (keine uncommitteten Änderungen im Image).
@@ -40,10 +58,25 @@ for svc in backend frontend proxy updater; do
targs=(); for t in "${tags[@]}"; do targs+=(-t "$image:$t"); done
echo "==> $image (${tags[*]})"
if [ "$multi" = 1 ]; then
docker buildx build --platform linux/amd64,linux/arm64 "${targs[@]}" --push "$src/$svc"
docker buildx build --builder "$builder" --platform linux/amd64,linux/arm64 "${targs[@]}" --push "$src/$svc"
docker buildx prune --builder "$builder" -af >/dev/null 2>&1 || true # Platz für den nächsten Dienst
else
docker build "${targs[@]}" "$src/$svc"
if [ "$push" = 1 ]; then for t in "${tags[@]}"; do docker push "$image:$t"; done; fi
fi
if [ "$push" = 1 ] && [ "${OPNEXUS_SIGN:-0}" = 1 ]; then
# Signiert wird der Digest (nicht der veränderliche Tag). Ein Schlüssel: COSIGN_KEY (+ COSIGN_PASSWORD).
[ -n "${COSIGN_KEY:-}" ] || { echo "OPNEXUS_SIGN=1, aber COSIGN_KEY ist nicht gesetzt" >&2; exit 1; }
if [ "$multi" = 1 ]; then
digest="$(docker buildx imagetools inspect "$image:$version" --format '{{json .Manifest.Digest}}' | tr -d '"')"
else
digest="$(docker image inspect "$image:$version" --format '{{json .RepoDigests}}' | tr ',' '\n' | grep -F "$image@" | tr -d '[]"')"; digest="${digest##*@}"
fi
[ -n "$digest" ] || { echo "Digest von $image:$version nicht gefunden (Push fehlgeschlagen?)" >&2; exit 1; }
# Ohne öffentliches Transparenzlog (Rekor): Prüfen soll auch in abgeschotteten Netzen gehen.
cosign sign --yes --use-signing-config=false --tlog-upload=false --key "$COSIGN_KEY" "$image@$digest"
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$image@$digest" >/dev/null
echo " signiert: $image@$digest"
fi
done
[ "$push" = 1 ] && echo "Fertig und gepusht: $repo (${tags[*]})" || echo "Fertig (lokal gebaut, nicht gepusht)."
+1
View File
@@ -33,6 +33,7 @@ sed -e "s|^# OPNEXUS_IMAGE_REPO=.*|OPNEXUS_IMAGE_REPO=$repo|" \
cp scripts/opnexus "$out/opnexus"
cp LICENSE "$out/"
cp cosign.pub "$out/"
cat > "$out/INSTALL.txt" <<TXT
OPNexus $version
+14
View File
@@ -5,6 +5,20 @@ FROM python:3.12-alpine
RUN apk add --no-cache bash docker-cli docker-cli-compose
# cosign prüft vor einem Update die Signatur der neuen Images (cosign.pub liegt im gemounteten
# Projektverzeichnis, siehe scripts/opnexus). Version und Prüfsummen sind fest eingetragen.
ARG COSIGN_VERSION=v3.1.3
ARG TARGETARCH
RUN set -e; arch="${TARGETARCH:-amd64}"; \
case "$arch" in \
amd64) sha=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 ;; \
arm64) sha=c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a ;; \
*) echo "Architektur $arch nicht unterstützt" >&2; exit 1 ;; \
esac; \
python -c "import urllib.request; urllib.request.urlretrieve('https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${arch}', '/usr/local/bin/cosign')"; \
echo "$sha /usr/local/bin/cosign" | sha256sum -c -; \
chmod 0755 /usr/local/bin/cosign
COPY opnexus /usr/local/bin/opnexus
COPY server.py /app/server.py
RUN chmod 0755 /usr/local/bin/opnexus
Binary file not shown.
Binary file not shown.
+18
View File
@@ -217,6 +217,23 @@ cmd_restore() {
say "Wiederhergestellt."
}
# Prüft die Signatur der gezogenen Images (cosign), bevor irgendetwas gewechselt wird. Nur wenn `cosign`
# installiert ist und cosign.pub neben der docker-compose.yml liegt; sonst wird das gesagt und übersprungen.
verify_signatures() {
local target="$1"; shift
local repo; repo=$(env_get OPNEXUS_IMAGE_REPO)
if ! command -v cosign >/dev/null 2>&1 || [ ! -f cosign.pub ]; then
say "Signaturprüfung übersprungen (cosign oder cosign.pub fehlt — siehe Handbuch, „Images prüfen“)."
return 0
fi
local svc
for svc in "$@"; do
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$repo/opnexus-$svc:$target" >/dev/null 2>&1 \
|| die "Signatur von $repo/opnexus-$svc:$target ist ungültig oder fehlt — nichts wurde verändert."
done
say "Signaturen geprüft."
}
cmd_update() {
require_release_mode
local target="${1:-$(env_get OPNEXUS_VERSION)}"
@@ -235,6 +252,7 @@ cmd_update() {
local pull_services=("${SERVICES[@]}")
if updater_enabled; then pull_services+=(updater); fi
OPNEXUS_VERSION="$target" c pull "${pull_services[@]}" || die "Pull fehlgeschlagen — nichts wurde verändert."
verify_signatures "$target" "${pull_services[@]}"
started=$(date -u +%Y%m%dT%H%M%SZ)
cat > "$STATE_FILE" <<STATE