Compare commits

...
6 Commits
Author SHA1 Message Date
Julian Boldt cf326d2d17 Release v1.16.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:

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

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

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

- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Dazu Gateway-Gruppen,
  DNS-Weiterleitungen (Unbound) und DHCP-Bereiche (dnsmasq).
- **Neue Gruppe „Host-System“:** Tunables (immer mit Bestätigung, Vorgaben nur per
  „Überschreiben“), Cron, Syslog-Ziele und ZFS-Snapshots (anlegen und löschen; der
  aktive Snapshot ist geschützt, das Aktivieren bleibt OPNsense selbst überlassen).
- **Netzwerk › Abgleich:** zeigt Unterschiede in der Netzwerk-Konfiguration zwischen den
  Knoten eines Clusters. Neuer Alarm „Netzwerk-Abweichung im Cluster“ (Einstellungen ›
  Benachrichtigungen, Migration 0004).
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts lässt es sich mit
  einem Klick auch auf dem anderen Cluster-Knoten anlegen (eigene Vorschau, eigenes Backup).
- **Kopfzeile:** Mit acht Gruppen zeigt die Navigation die Namen ab 1150 px, darunter
  nur Icons, damit Suche und Abmelden in einer Zeile bleiben.
2026-10-09 22:08:39 +02:00
74 changed files with 5338 additions and 369 deletions

No files matched your search

+9 -1
View File
@@ -71,10 +71,18 @@ TRUST_PROXY_HEADERS=false
# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht
# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update`.
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
# (optional, siehe unten) per Klick im Webinterface.
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
# UPDATE_CHECK_INTERVAL_HOURS=12
# ── Updates aus dem Webinterface (optional, braucht Release-Images) ───
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch.
# COMPOSE_PROFILES=updater
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
# Firewall-API-Zugangsdaten gehen per HTTP-Basic-Auth raus, deshalb sind
# http://-Adressen für Firewalls abgelehnt (beim Anlegen UND bei jeder
+2
View File
@@ -59,3 +59,5 @@ jobs:
- uses: actions/checkout@v4
- run: python3 scripts/check_i18n.py
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
# Updater-Container (Zustandsübergänge, Ablehnungen, Auth) ohne Docker-Daemon.
- run: python3 -m unittest discover -s updater -p 'test_*.py'
+2 -2
View File
@@ -12,7 +12,7 @@
# Berechtigung "write:package" eintragen)
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration.
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy}:<version>
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy,updater}:<version>
# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen).
#
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
@@ -42,7 +42,7 @@ jobs:
strategy:
fail-fast: true
matrix:
service: [backend, frontend, proxy]
service: [backend, frontend, proxy, updater]
steps:
- uses: actions/checkout@v4
+74
View File
@@ -31,6 +31,80 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.16.0] "Hobby" — 2026-10-10
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:
- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
(Migration `0005`).
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
(`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
`python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
## [1.15.0] "Gyrfalcon" — 2026-10-10
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
„Update aus dem Webinterface (optional)“:
- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
(mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
**Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
**aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
„zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
## [1.14.0] "Eagle" — 2026-10-09
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
Engine wie die übrigen Geräte.
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
falsch, weil `/hostsystem` mit `/hosts` beginnt.
## [1.13.0] "Buzzard" — 2026-10-09
Mehr Verwaltung pro Firewall und ein Abgleich der Cluster-Knoten. Alles über
dieselbe abgesicherte Engine wie in 1.12.0 und live auf drei Test-Firewalls
geprüft, siehe Handbuch „Erweiterung 1.13.0“:
- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Dazu Gateway-Gruppen,
DNS-Weiterleitungen (Unbound) und DHCP-Bereiche (dnsmasq).
- **Neue Gruppe „Host-System“:** Tunables (immer mit Bestätigung, Vorgaben nur per
„Überschreiben“), Cron, Syslog-Ziele und ZFS-Snapshots (anlegen und löschen; der
aktive Snapshot ist geschützt, das Aktivieren bleibt OPNsense selbst überlassen).
- **Netzwerk › Abgleich:** zeigt Unterschiede in der Netzwerk-Konfiguration zwischen den
Knoten eines Clusters. Neuer Alarm „Netzwerk-Abweichung im Cluster“ (Einstellungen ›
Benachrichtigungen, Migration 0004).
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts lässt es sich mit
einem Klick auch auf dem anderen Cluster-Knoten anlegen (eigene Vorschau, eigenes Backup).
- **Kopfzeile:** Mit acht Gruppen zeigt die Navigation die Namen ab 1150 px, darunter
nur Icons, damit Suche und Abmelden in einer Zeile bleiben.
## [1.12.0] "Condor" — 2026-10-09
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
+17 -6
View File
@@ -5,9 +5,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp)
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.12.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
> **Stand:** `1.16.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -16,14 +16,23 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Sichere Änderungen** — jede Änderung läuft *Prüfen → Ausrollen → Bestätigen*; bleibt die Bestätigung im Zeitfenster aus, rollt OPNexus zurück.
- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume.
- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt.
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, DNS und DHCP je Firewall, 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 lassen sich übernehmen.
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
Alle Screenshots zeigen fiktive Beispieldaten.
## Ratgeber
- [OPNsense-Firewalls zentral verwalten](https://opnexus.dev/opnsense-zentral-verwalten/) – wann sich zentrale Verwaltung lohnt und wie sie funktioniert
- [OPNsense-HA-Cluster mit CARP verwalten](https://opnexus.dev/opnsense-ha-cluster-carp/) – Rollen, Alarme, geordnete Updates, Abgleich beider Knoten
- [OPNsense-Regeln sicher ausrollen](https://opnexus.dev/opnsense-regeln-sicher-ausrollen/) – Vorschau, Bestätigungsfenster und automatischer Rollback
- [Das Kommandozeilen-Werkzeug `opnexus`](https://opnexus.dev/docs/cli/) – Installation aktualisieren, zurückrollen und sichern
## Schnellstart
Voraussetzung: Docker mit Compose-Plugin.
@@ -35,19 +44,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
docker compose up -d --build
```
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben:
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben. Verloren oder nie gesehen? Dieses Kommando setzt ein neues Zufallspasswort und zeigt es an (im Repo-Checkout `scripts/opnexus`, im Release-Bundle `./opnexus`):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls).
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.12.0
OPNEXUS_VERSION=1.16.0
```
```bash
+20 -7
View File
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp)
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.12.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
> **Status:** `1.16.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -16,16 +16,25 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Safe changes** — every change goes *preview → deploy → confirm*; without your confirmation inside the time window OPNexus rolls it back.
- **Hierarchical policies** — pre/post rulebases with inheritance across group trees.
- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out.
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP per firewall, 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 created by hand on a firewall can be adopted.
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
![Cluster view](website/assets/screenshots/en/cluster-light-desktop.webp)
![Cluster view](docs/screenshots/readme-cluster-en.webp)
All screenshots show fictitious sample data.
## Guides
- [Manage OPNsense firewalls centrally](https://opnexus.dev/en/manage-opnsense-centrally/) – when central management pays off and how it works
- [Manage OPNsense HA clusters with CARP](https://opnexus.dev/en/opnsense-ha-cluster-carp/) – roles, alerts, ordered updates, comparing both nodes
- [Roll out OPNsense rules safely](https://opnexus.dev/en/roll-out-opnsense-rules-safely/) – preview, confirmation window and automatic rollback
- [The `opnexus` command-line tool](https://opnexus.dev/en/docs/cli/) – update, roll back and back up an installation
## Quick start
Requires Docker with the Compose plugin.
@@ -37,19 +46,21 @@ cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD)
docker compose up -d --build
```
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log:
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log. Lost it, or never saw it? This sets a new random one and prints it (use `scripts/opnexus` in a repo checkout, `./opnexus` in the release bundle):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German).
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.12.0
OPNEXUS_VERSION=1.16.0
```
```bash
@@ -58,6 +69,8 @@ docker compose pull && docker compose up -d --no-build
`scripts/opnexus update` / `rollback` then update the installation (pull first, database backup before migrations, automatic rollback if the services do not become healthy).
**Updating from the web interface** is available as an opt-in: set `COMPOSE_PROFILES=updater` in `.env` and an administrator can update (and roll back) under *Settings › Update OPNexus*. It runs as a separate small container that is the only one with Docker access — see the [handbook](docs/HANDBUCH.md#update-aus-dem-webinterface-optional) (German) for the security model.
## Privacy
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
+1 -1
View File
@@ -92,7 +92,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
user = await auth_service.resolve_session(db, token)
if user is not None:
parts = request.url.path.strip("/").split("/")
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user"}
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system"}
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
+8
View File
@@ -28,6 +28,14 @@ class Settings(BaseSettings):
update_check_url: str = ""
update_check_interval_hours: int = 12
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
# nie Zugriff auf den Docker-Socket; es reicht nur "aktualisieren" /
# "zurückrollen" an diesen Dienst weiter, authentifiziert mit dem Token,
# den der Updater in sein Zustands-Volume schreibt.
updater_url: str = "http://updater:8080"
updater_token_file: str = "/updater/token"
# Cookies now carry the session, so this can no longer be a bare "*"
# (the Fetch spec forbids credentialed requests against a wildcard
# response) — see main.py's allow_origin_regex. Default only matches
+6
View File
@@ -6,6 +6,12 @@ from fastapi import HTTPException
class ErrorCode(str, Enum):
UPDATER_UNAVAILABLE = "UPDATER_UNAVAILABLE"
UPDATER_BUSY = "UPDATER_BUSY"
UPDATER_REJECTED = "UPDATER_REJECTED"
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
ADMIN_REQUIRED = "ADMIN_REQUIRED"
BACKUP_EMPTY = "BACKUP_EMPTY"
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
+6
View File
@@ -37,6 +37,9 @@ from app.routers import openvpn as openvpn_router
from app.routers import wireguard as wireguard_router
from app.routers import certificates as certificates_router
from app.routers import network as network_router
from app.routers import snapshots as snapshots_router
from app.routers import network_drift as network_drift_router
from app.routers import diagnostics as diagnostics_router
from app.routers import restore as restore_router
from app.version import APP_VERSION
from app.routers import dashboard as dashboard_router
@@ -392,5 +395,8 @@ app.include_router(dashboard_router.router, prefix="/api")
app.include_router(cluster_router.router, prefix="/api")
app.include_router(services_router.router, prefix="/api")
app.include_router(network_router.router, prefix="/api")
app.include_router(snapshots_router.router, prefix="/api")
app.include_router(network_drift_router.router, prefix="/api")
app.include_router(diagnostics_router.router, prefix="/api")
app.include_router(resource_samples_router.router, prefix="/api")
app.include_router(scheduled_maintenance_router.router, prefix="/api")
+1
View File
@@ -48,6 +48,7 @@ class NotificationSettings(Base):
maintenance_unknown_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
carp_health_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
nat_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
+4 -1
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -9,3 +9,6 @@ class UserPreferences(Base):
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
theme: Mapped[str] = mapped_column(String(10), default='system')
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
# Set once the user finished or dismissed the first-run setup assistant
# (frontend /setup); it is then no longer offered automatically.
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
+66
View File
@@ -0,0 +1,66 @@
"""Recovery: give an account a new random password and print it once.
For a fresh install whose generated first-start password scrolled out of the
log, and for an admin who forgot theirs. Needs shell access to the server,
which is the trust boundary for this tool anyway (same as app.reset_2fa).
All sessions of the account are ended. Normally called through the host
CLI:
./opnexus password [username]
or directly:
docker compose exec backend python -m app.reset_password [username]
Without a username the configured ADMIN_USERNAME is used; if that account does
not exist and there is exactly one admin, that one.
"""
import asyncio
import secrets
import sys
from sqlalchemy import select
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser
from app.services import auth_service
async def main(username: str | None) -> int:
async with SessionLocal() as db:
if username is None:
username = settings.admin_username
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
admins = (await db.execute(select(AdminUser).where(AdminUser.role == "admin"))).scalars().all()
if len(admins) != 1:
names = ", ".join(sorted(a.username for a in admins)) or "-"
print(f"No account named '{username}'. Pass the username (admins: {names}).", file=sys.stderr)
return 1
user = admins[0]
else:
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
print(f"No such user: {username}", file=sys.stderr)
return 1
password = secrets.token_urlsafe(18)
user.password_hash = await auth_service.hash_password_async(password)
await auth_service.revoke_sessions(db, user.id, commit=False)
await db.commit()
name = user.username
has_totp = user.totp_enabled
print(f"Username: {name}")
print(f"Password: {password}")
if has_totp:
print("Two-factor sign-in is still on (lost the code? python -m app.reset_2fa).", file=sys.stderr)
return 0
if __name__ == "__main__":
if len(sys.argv) > 2:
print("usage: python -m app.reset_password [username]", file=sys.stderr)
sys.exit(2)
sys.exit(asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else None)))
+68
View File
@@ -0,0 +1,68 @@
import uuid
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import diagnostics_service
router = APIRouter(prefix="/hosts/{host_id}/diagnostics", tags=["diagnostics"])
IpProto = Literal["inet", "inet6"]
class PingRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
count: int = Field(default=4, ge=1, le=10)
packetsize: int | None = Field(default=None, ge=1, le=9000)
source: str | None = Field(default=None, max_length=64)
class TracerouteRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
protocol: Literal["udp", "icmp"] = "icmp"
source: str | None = Field(default=None, max_length=64)
class PortprobeRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
port: int = Field(ge=1, le=65535)
ipproto: IpProto = "inet"
source: str | None = Field(default=None, max_length=64)
class DnsRequest(BaseModel):
name: str = Field(min_length=1, max_length=253)
server: str | None = Field(default=None, max_length=64)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.post("/ping")
async def ping(host_id: uuid.UUID, p: PingRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.ping(await _host(db, host_id), p.target, p.ipproto, p.count, p.packetsize, p.source)
@router.post("/traceroute")
async def traceroute(host_id: uuid.UUID, p: TracerouteRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.traceroute(await _host(db, host_id), p.target, p.ipproto, p.protocol, p.source)
@router.post("/portprobe")
async def portprobe(host_id: uuid.UUID, p: PortprobeRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.portprobe(await _host(db, host_id), p.target, p.port, p.ipproto, p.source)
@router.post("/dns")
async def dns(host_id: uuid.UUID, p: DnsRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.dns_lookup(await _host(db, host_id), p.name, p.server)
+12
View File
@@ -76,3 +76,15 @@ async def current_item(host_id: uuid.UUID, kind: str, item_uuid: str, db: AsyncS
return await network_change_service.item(host, kind, item_uuid)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.get("/objects/{kind}")
async def list_objects(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
"""Rows of a generic object kind (tunables, cron jobs, LAGG, ...), reduced to their display columns."""
if kind not in network_change_service.OBJECT_COLUMNS:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.list_objects(host, kind)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
+29
View File
@@ -0,0 +1,29 @@
from collections import defaultdict
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.host import OpnsenseHost
from app.services import network_drift_service
router = APIRouter(prefix="/network", tags=["network"])
@router.get("/drift")
async def network_drift(db: AsyncSession = Depends(get_db)):
"""Per cluster: differences in the network configuration between its nodes (live read of every node)."""
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
clusters = []
for name, members in sorted(by_cluster.items()):
if len(members) < 2:
continue
differences, compared, unreachable = await network_drift_service.compute_cluster(members)
clusters.append({"name": name, "hosts": [h.name for h in members], "compared": compared,
"unreachable": unreachable, "differences": differences})
return {"clusters": clusters}
+5 -2
View File
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
class Preferences(BaseModel):
theme: Literal['light','dark','system'] | None = None
language: Literal['de','en'] | None = None
setup_done: bool | None = None
@router.get('')
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
preferences=await db.get(UserPreferences,user.id)
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
'language':preferences.language if preferences else 'de',
'setup_done':preferences.setup_done if preferences else False,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
current = await db.get(UserPreferences, user.id)
theme = payload.theme or (current.theme if current else 'system')
language = payload.language or (current.language if current else 'de')
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
await db.commit()
return {'username':username,'theme':theme,'language':language,
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
+55
View File
@@ -0,0 +1,55 @@
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.routers.hosts import _upstream_error
from app.services import snapshot_service
from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/hosts/{host_id}/snapshots", tags=["snapshots"])
class SnapshotCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
note: str = Field(default="", max_length=200)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.get("")
async def list_snapshots(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
try:
return await snapshot_service.list_snapshots(host)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.post("", status_code=201)
async def create_snapshot(host_id: uuid.UUID, payload: SnapshotCreate, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, host_id)
await ensure_no_maintenance(db, [host])
return await snapshot_service.create(host, payload.name, payload.note)
@router.delete("/{snapshot_uuid}", status_code=204)
async def delete_snapshot(host_id: uuid.UUID, snapshot_uuid: str, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, host_id)
await ensure_no_maintenance(db, [host])
if not snapshot_uuid.replace("-", "").isalnum() or len(snapshot_uuid) > 64:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
await snapshot_service.delete(host, snapshot_uuid)
+96 -2
View File
@@ -1,6 +1,10 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.services import update_check_service
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.services import update_check_service, updater_client
router = APIRouter(prefix="/system", tags=["system"])
@@ -10,3 +14,93 @@ async def version_check() -> dict:
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
return await update_check_service.check()
def _admin(user: AdminUser) -> AdminUser:
# Der Updater ist Administration der Installation selbst — auch das reine
# Lesen (Logs, Rollback-Optionen) bleibt Admins vorbehalten.
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
return user
def _major(version: str | None) -> int | None:
try:
return int((version or "").split(".")[0])
except ValueError:
return None
class UpdateRequest(BaseModel):
version: str = Field(min_length=5, max_length=20)
@router.get("/updater")
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
_admin(user)
check = await update_check_service.check()
result: dict = {
"available": False, "reason": None, "mode": None,
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
"release_url": check["release_url"], "feed_enabled": check["enabled"],
"feed_error": check["error"], "checked_at": check["checked_at"],
"can_update": False, "major_change": False,
"job": None, "phases": [], "log": [], "services": {},
"rollback_available": False, "rollback_loses_data": False, "rollback_version": None,
}
try:
updater = await updater_client.status()
except updater_client.UpdaterUnavailable as exc:
result["reason"] = str(exc)
return result
except updater_client.UpdaterRejected:
result["reason"] = "unreachable"
return result
mode = updater.get("mode")
running = (updater.get("job") or {}).get("state") == "running"
result.update(
available=True, mode=mode, job=updater.get("job"), phases=updater.get("phases") or [],
log=updater.get("log") or [], services=updater.get("services") or {},
rollback_available=bool(updater.get("rollback_available")),
rollback_loses_data=bool(updater.get("rollback_loses_data")),
rollback_version=((updater.get("last_update") or {}).get("prev_version")),
)
# Hauptversionssprünge nur bewusst über die Kommandozeile (Release-Notes lesen).
result["major_change"] = bool(check["latest"]) and _major(check["latest"]) != _major(check["current"])
result["can_update"] = (
mode == "release" and not running and bool(check["update_available"]) and not result["major_change"]
)
return result
@router.post("/updater/update", status_code=202)
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
Version. Beliebige Versionen werden bewusst nicht angenommen."""
_admin(user)
check = await update_check_service.check()
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
if _major(check["latest"]) != _major(check["current"]):
raise api_error(409, ErrorCode.UPDATE_MAJOR_CHANGE)
return await _forward(updater_client.start_update(check["latest"]))
@router.post("/updater/rollback", status_code=202)
async def updater_rollback(user: AdminUser = Depends(current_user)) -> dict:
_admin(user)
return await _forward(updater_client.start_rollback())
async def _forward(call) -> dict:
try:
return await call
except updater_client.UpdaterUnavailable:
raise api_error(503, ErrorCode.UPDATER_UNAVAILABLE)
except updater_client.UpdaterRejected as rejected:
if rejected.code == "busy":
raise api_error(409, ErrorCode.UPDATER_BUSY)
raise api_error(409, ErrorCode.UPDATER_REJECTED, reason=rejected.code)
+2 -1
View File
@@ -135,7 +135,8 @@ class HostNetwork(BaseModel):
class NetworkChangeRequest(BaseModel):
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation"]
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation",
"tunable", "cron_job", "syslog_destination", "gateway_group", "lagg", "bridge", "loopback", "gre", "gif", "vxlan", "dns_forward", "dhcp_range"]
action: Literal["add", "set", "del"]
# Goes into a URL path, so only plain identifiers.
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
+3 -1
View File
@@ -9,7 +9,7 @@ from app.config import settings
SmtpSecurity = Literal["none", "starttls", "ssl"]
ProblemType = Literal[
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
]
@@ -36,6 +36,7 @@ class NotificationSettingsRead(BaseModel):
maintenance_unknown_enabled: bool
carp_health_enabled: bool
nat_drift_enabled: bool
network_drift_enabled: bool = True
carp_auto_revert_failed_enabled: bool
poll_interval_seconds: int
@@ -71,6 +72,7 @@ class NotificationSettingsUpdate(BaseModel):
maintenance_unknown_enabled: bool | None = None
carp_health_enabled: bool | None = None
nat_drift_enabled: bool | None = None
network_drift_enabled: bool | None = None
carp_auto_revert_failed_enabled: bool | None = None
poll_interval_seconds: int | None = Field(
+2 -1
View File
@@ -367,7 +367,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
logger.warning(
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
" generated password:\n\n %s\n\n"
" Log in and change it now. This is shown only once.\n%s",
" Log in and change it now. This is shown only once.\n"
" Lost it? ./opnexus password (sets and prints a new one)\n%s",
"=" * 64, settings.admin_username, password, "=" * 64,
)
else:
+142
View File
@@ -0,0 +1,142 @@
"""Network probes run FROM a firewall: ping, traceroute, TCP port probe, DNS lookup.
Admin-only by construction (every call is a POST, which the read-only role is
refused for) and audited with the target, because a firewall that probes
arbitrary addresses on request is also a scanning tool. Inputs are checked
here before they are sent (the firewall validates them again); results are
shown and never stored. Ping runs as a short job that is always stopped and
removed again, even when polling fails.
"""
import asyncio
import ipaddress
import re
import time
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import audit_service, opnsense_client
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseDiagnosticRejected
TARGET = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.:_-]{0,251}[A-Za-z0-9])?$")
# At most a few probes at a time per OPNexus process, so the page cannot be used to hammer a firewall.
_SLOTS = asyncio.Semaphore(4)
def clean_target(value: str, field: str = "target") -> str:
value = (value or "").strip()
if not TARGET.match(value):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
return value
def clean_source(value: str | None) -> str:
value = (value or "").strip()
if not value:
return ""
try:
return str(ipaddress.ip_address(value))
except ValueError:
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="source") from None
async def _audit(host: OpnsenseHost, tool: str, target: str, ok: bool, details: dict, message: str | None = None) -> None:
await audit_service.persist(kind="diagnostic", action=f"diag.{tool}", status="ok" if ok else "failed",
entity_name=target, host_id=str(host.id), host_name=host.name, message=message, details=details)
def _rejected(exc: OpnsenseDiagnosticRejected):
field = next(iter(exc.validations), "target").split(".")[-1]
return api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field=field)
async def _guarded(host: OpnsenseHost, tool: str, target: str, params: dict, call):
async with _SLOTS:
try:
result = await call()
except OpnsenseDiagnosticRejected as exc:
await _audit(host, tool, target, False, params, "rejected: " + ", ".join(exc.validations))
raise _rejected(exc) from exc
except OpnsenseAPIError as exc:
await _audit(host, tool, target, False, params, str(exc)[:300])
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await _audit(host, tool, target, True, params)
return result
async def traceroute(host: OpnsenseHost, target: str, ipproto: str, protocol: str, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "ipproto": ipproto, "protocol": protocol, "source": source}
async def call():
data = await opnsense_client.diagnostic_set(
host, "/diagnostics/traceroute/set", "traceroute",
{"hostname": target, "ipproto": ipproto, "protocol": protocol, "source_address": source})
resp = data.get("response") or {}
hops = [{"ttl": str(r.get("ttl", "")), "address": str(r.get("address", "")), "host": str(r.get("host", "")),
"as": str(r.get("AS", "")), "probes": str(r.get("probes", ""))} for r in resp.get("rows", []) if isinstance(r, dict)]
return {"target": target, "notice": str(resp.get("notice", "")), "hops": hops}
return await _guarded(host, "traceroute", target, params, call)
async def portprobe(host: OpnsenseHost, target: str, port: int, ipproto: str, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "port": port, "ipproto": ipproto, "source": source}
async def call():
data = await opnsense_client.diagnostic_set(
host, "/diagnostics/portprobe/set", "portprobe",
{"hostname": target, "port": str(port), "ipproto": ipproto, "source_address": source, "source_port": "", "showtext": "0"})
message = str((data.get("response") or {}).get("message", ""))
# Never forward the remote payload (a service banner): only whether the connection worked.
return {"target": target, "port": port, "open": "succeeded" in message.lower(), "message": message}
return await _guarded(host, "portprobe", target, params, call)
async def dns_lookup(host: OpnsenseHost, name: str, server: str | None) -> dict:
name = clean_target(name, "name")
server = clean_source(server)
params = {"name": name, "server": server}
async def call():
data = await opnsense_client.diagnostic_set(host, "/diagnostics/dns_diagnostics/set", "dns", {"hostname": name, "server": server})
out = {}
for rtype, rec in (data.get("response") or {}).items():
if isinstance(rec, dict):
out[str(rtype)] = {"answers": [str(a) for a in rec.get("answers", [])], "query_time": str(rec.get("query_time", "")),
"server": str(rec.get("server", ""))}
return {"name": name, "records": out}
return await _guarded(host, "dns", name, params, call)
async def ping(host: OpnsenseHost, target: str, ipproto: str, count: int, packetsize: int | None, source: str | None) -> dict:
target, source = clean_target(target), clean_source(source)
params = {"target": target, "ipproto": ipproto, "count": count, "packetsize": packetsize, "source": source}
async def call():
job = await opnsense_client.ping_job_start(host, {
"hostname": target, "fam": "ip6" if ipproto == "inet6" else "ip", "source_address": source,
"packetsize": str(packetsize or ""), "disable_frag": "0", "interval": "1", "description": "OPNexus"})
try:
state = None
deadline = time.monotonic() + count + 6
while time.monotonic() < deadline:
await asyncio.sleep(1)
state = await opnsense_client.ping_job_state(host, job)
if state and int(state.get("send") or 0) >= count:
break
await opnsense_client.ping_job_stop(host, job)
await asyncio.sleep(0.5)
state = await opnsense_client.ping_job_state(host, job) or state
finally:
# Never leave a job running on the firewall.
try:
await opnsense_client.ping_job_stop(host, job)
await opnsense_client.ping_job_remove(host, job)
except OpnsenseAPIError:
pass
if not state:
raise OpnsenseAPIError("Ping: kein Ergebnis von der Firewall")
return {"target": target, "sent": int(state.get("send") or 0), "received": int(state.get("received") or 0),
"loss": str(state.get("loss", "")), "min": state.get("min"), "avg": state.get("avg"), "max": state.get("max"),
"stddev": state.get("std-dev"), "error": state.get("last_error")}
return await _guarded(host, "ping", target, params, call)
+65 -2
View File
@@ -48,6 +48,18 @@ FIELDS: dict[str, set[str]] = {
"kea_subnet": {"subnet", "pools", "valid_lifetime", "next_server", "description", "option_data.routers",
"option_data.domain_name_servers", "option_data.domain_name", "option_data.ntp_servers"},
"kea_reservation": {"subnet", "ip_address", "hw_address", "client_id", "hostname", "description", "next_server"},
"tunable": {"tunable", "value", "descr"},
"cron_job": {"enabled", "minutes", "hours", "days", "months", "weekdays", "who", "command", "parameters", "description"},
"syslog_destination": {"enabled", "transport", "program", "level", "facility", "hostname", "port", "rfc5424", "description"},
"gateway_group": {"name", "item", "item2", "item3", "item4", "item5", "trigger", "poolopts", "descr"},
"lagg": {"members", "primary_member", "proto", "lacp_fast_timeout", "use_flowid", "lagghash", "lacp_strict", "mtu", "descr"},
"bridge": {"members", "linklocal", "enablestp", "proto", "descr"},
"loopback": {"deviceId", "description"},
"gre": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
"gif": {"local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "tunnel-remote-net", "descr"},
"vxlan": {"deviceId", "vxlanid", "vxlanlocal", "vxlanlocalport", "vxlanremote", "vxlanremoteport", "vxlangroup", "vxlandev"},
"dns_forward": {"enabled", "type", "domain", "server", "port", "verify", "forward_tcp_upstream", "forward_first", "description"},
"dhcp_range": {"interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "domain_type", "domain", "description"},
"interface": {"if", "descr", "enable", "type4", "ipaddr", "gateway", "type6", "ipaddrv6", "gatewayv6", "mtu",
"blockpriv", "blockbogons", "lock"},
}
@@ -56,8 +68,23 @@ SECRET_FIELDS: dict[str, set[str]] = {"vip": {"password"}}
SECTION = {"vlan": "vlans", "vip": "vips", "gateway": "gateways", "route": "routes", "interface": "assignments",
"dns_host": "dns_hosts", "dhcp_host": "dhcp_hosts", "kea_subnet": "kea_subnets",
"kea_reservation": "kea_reservations"}
# Columns the generic object lists show (the firewall's own field names). Lists never carry fields outside FIELDS.
OBJECT_COLUMNS: dict[str, list[str]] = {
"tunable": ["tunable", "value", "default_value", "descr"],
"cron_job": ["enabled", "minutes", "hours", "days", "months", "weekdays", "command", "description"],
"syslog_destination": ["enabled", "transport", "hostname", "port", "program", "level", "description"],
"gateway_group": ["name", "item", "item2", "item3", "trigger", "descr"],
"lagg": ["laggif", "members", "proto", "descr"],
"bridge": ["bridgeif", "members", "proto", "descr"],
"loopback": ["deviceId", "description"],
"gre": ["greif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
"gif": ["gifif", "local-addr", "remote-addr", "tunnel-local-addr", "tunnel-remote-addr", "descr"],
"vxlan": ["deviceId", "vxlanid", "vxlanlocal", "vxlanremote", "vxlangroup", "vxlandev"],
"dns_forward": ["enabled", "type", "domain", "server", "port", "description"],
"dhcp_range": ["interface", "start_addr", "end_addr", "subnet_mask", "lease_time", "description"],
}
# These change how the network is wired; the others (DNS/DHCP data) cannot cut the connection to the firewall.
WIRING_KINDS = {"vlan", "vip", "gateway", "route", "interface"}
WIRING_KINDS = {"vlan", "vip", "gateway", "route", "interface", "lagg", "bridge", "gre", "gif", "vxlan"}
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
@@ -135,7 +162,7 @@ def assess(kind: str, action: str, uuid: str | None, before: dict, after: dict,
mgmt_iface = next((i for i in net.interfaces if i.device == mgmt_device), None)
mgmt_ident = mgmt_iface.identifier if mgmt_iface else None
if SECTION[kind] in net.unavailable:
if SECTION.get(kind) in net.unavailable:
blocks.append(_risk("api_unavailable", section=SECTION[kind]))
if mgmt_device is None and kind in WIRING_KINDS:
# We cannot tell what this change could cut, so treat every change to existing objects as risky.
@@ -160,6 +187,22 @@ def assess(kind: str, action: str, uuid: str | None, before: dict, after: dict,
if action in ("set", "del") and uuid in ("wan", "lan") and uuid != mgmt_ident:
warnings.append(_risk("core_interface", name=uuid))
if kind in ("lagg", "bridge"):
members = {m for side in (before, after) for m in side.get("members", "").split(",") if m}
if mgmt_device and mgmt_device in members:
risks.append(_risk("management_member", device=mgmt_device))
elif mgmt_device is None and action in ("set", "del"):
pass # already covered by management_unknown above
if kind == "tunable":
# sysctl values can change how the whole system behaves (and some only apply after a reboot).
risks.append(_risk("tunable_change"))
if action in ("set", "del") and uuid and "-" not in uuid:
# A built-in default (its id is not a UUID): the firewall would create a new entry instead of
# changing it, so overriding one is an "add", not an edit.
blocks.append(_risk("tunable_default_row"))
if kind == "gateway_group" and action == "del":
warnings.append(_risk("gateway_group_in_use"))
if kind == "vlan" and action in ("set", "del"):
device = next((v.device for v in net.vlans if v.uuid == uuid), None)
if device and device == mgmt_device:
@@ -231,6 +274,26 @@ async def _peers(db: AsyncSession, host: OpnsenseHost) -> list[str]:
return list(rows.all())
async def list_objects(host: OpnsenseHost, kind: str) -> dict:
"""Rows of one generic object kind, reduced to the columns above (plus uuid) — never whole rows,
so nothing the list does not need (or must not show) leaves the server."""
columns = OBJECT_COLUMNS[kind]
try:
rows = await opnsense_client.search_network_objects(host, kind)
except OpnsenseAPIError as exc:
if "404" in str(exc):
return {"available": False, "columns": columns, "rows": []} # this OPNsense version lacks the API
raise
out = []
for row in rows:
item = {"uuid": str(row.get("uuid", ""))}
for col in columns:
value = row.get(col)
item[col] = "" if value is None or isinstance(value, (dict, list)) else str(value)
out.append(item)
return {"available": True, "columns": columns, "rows": out}
async def _rules_using(host: OpnsenseHost, identifier: str) -> list[str]:
"""Descriptions of the user-made rules that name this interface (OPNsense deletes them with it)."""
data = await opnsense_client.search_rules(host, all_rows=True)
@@ -0,0 +1,86 @@
"""Compares the network configuration of the nodes of an HA cluster.
Interfaces, VLANs, virtual IPs, gateways, routes, DNS and DHCP objects should
be the same on both nodes of a pair; a VLAN or gateway that exists on one node
only is exactly the kind of difference that breaks a failover months later.
Per-node values are deliberately NOT compared: interface addresses, CARP
advskew/password and the active default gateway legitimately differ between
the nodes. What is compared is the identity of each object (its key) and the
settings that must agree (its signature). A category is skipped when any
reachable node cannot deliver it (older OPNsense), instead of reporting that
as a difference.
"""
from app.models.host import OpnsenseHost
from app.schemas.network import HostNetwork
from app.services import network_service
from app.services.opnsense_client import OpnsenseAPIError
CATEGORIES = ["interfaces", "vlans", "vips", "gateways", "routes", "dns_hosts", "dhcp_hosts", "kea_subnets", "kea_reservations"]
def signatures(net: HostNetwork) -> dict[str, dict[str, str]]:
out: dict[str, dict[str, str]] = {c: {} for c in CATEGORIES}
for i in net.interfaces:
if i.assigned and i.identifier:
out["interfaces"][i.identifier] = f"{i.description}|{i.vlan_tag or ''}|{i.ipv4_type or ''}"
for v in net.vlans:
out["vlans"][f"{v.tag}@{v.parent}"] = v.description
for v in net.vips:
if v.mode == "carp":
out["vips"][f"carp:{v.vhid}"] = f"{v.address}|{v.advbase or ''}|{v.description}"
else:
out["vips"][f"{v.mode}:{v.address}"] = v.description
for g in net.gateways:
out["gateways"][g.name] = f"{g.interface}|{g.ip_protocol}|{g.address}|{g.disabled}"
for r in net.routes:
out["routes"][r.network] = f"{r.gateway}|{r.disabled}"
for d in net.dns_hosts:
out["dns_hosts"][f"{d.hostname}.{d.domain}|{d.record_type}"] = f"{d.server}|{d.enabled}"
for h in net.dhcp_hosts:
out["dhcp_hosts"][f"{h.host}.{h.domain}"] = f"{h.ip}|{h.hwaddr}"
for s in net.kea_subnets:
out["kea_subnets"][s.subnet] = f"{s.pools}|{s.routers}|{s.dns_servers}"
for k in net.kea_reservations:
out["kea_reservations"][f"{k.ip_address}|{k.hw_address}"] = k.hostname
return out
# Which HostNetwork.unavailable entry makes a category incomparable.
_UNAVAILABLE_KEY = {"interfaces": "assignments"}
def compare(per_host: dict[str, HostNetwork]) -> list[dict]:
"""per_host: host name -> its network. Returns one row per object that is missing on a node or differs."""
names = sorted(per_host)
sigs = {n: signatures(per_host[n]) for n in names}
rows: list[dict] = []
for category in CATEGORIES:
skip_key = _UNAVAILABLE_KEY.get(category, category)
if any(skip_key in per_host[n].unavailable for n in names):
continue
keys = sorted({k for n in names for k in sigs[n][category]})
for key in keys:
present = {n: sigs[n][category][key] for n in names if key in sigs[n][category]}
if len(present) < len(names):
rows.append({"category": category, "key": key, "status": "missing",
"present_on": sorted(present), "missing_from": sorted(set(names) - set(present)),
"signatures": present})
elif len(set(present.values())) > 1:
rows.append({"category": category, "key": key, "status": "different",
"present_on": names, "missing_from": [], "signatures": present})
return rows
async def compute_cluster(hosts: list[OpnsenseHost]) -> tuple[list[dict], list[str], list[str]]:
"""Returns (differences, compared host names, unreachable host names)."""
per_host: dict[str, HostNetwork] = {}
unreachable: list[str] = []
for host in hosts:
try:
per_host[host.name] = await network_service.get_host_network(host)
except OpnsenseAPIError:
unreachable.append(host.name)
if len(per_host) < 2:
return [], sorted(per_host), sorted(unreachable)
return compare(per_host), sorted(per_host), sorted(unreachable)
+35 -1
View File
@@ -14,7 +14,7 @@ from app.models.one_to_one_deployment import OneToOneDeployment
from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.port_forward_deployment import PortForwardDeployment
from app.models.rule_deployment import RuleDeployment
from app.services import nat_drift_service, rule_drift_service
from app.services import nat_drift_service, network_drift_service, rule_drift_service
from app.services.notification_dedup import Problems
from app.services.host_status_service import get_statuses
from app.services.live_certificates_service import get_live_certificates
@@ -148,6 +148,40 @@ async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
return problems
_NETWORK_LABELS = {
"interfaces": "Schnittstelle", "vlans": "VLAN", "vips": "Virtuelle IP", "gateways": "Gateway", "routes": "Route",
"dns_hosts": "DNS-Eintrag", "dhcp_hosts": "DHCP-Host", "kea_subnets": "DHCP-Subnetz", "kea_reservations": "DHCP-Reservierung",
}
async def check_network_drift(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
differences, compared, _unreachable = await network_drift_service.compute_cluster(cluster_hosts)
if len(compared) < 2:
# Not evaluable right now (a node is down, which check_host_offline reports): keep what is open.
problems.preserve_prefixes.add(f"{cluster_name}:")
continue
for row in differences:
key = f"{cluster_name}:{row['category']}:{row['key']}"
problems[key] = {
"cluster_name": cluster_name,
"object": f"{_NETWORK_LABELS.get(row['category'], row['category'])} {row['key']}",
"status": "fehlt auf einem Knoten" if row["status"] == "missing" else "Einstellungen weichen ab",
"present_on": row["present_on"],
"missing_from": row["missing_from"],
}
return problems
async def check_revert_failed(db: AsyncSession) -> dict[str, dict]:
problems: dict[str, dict] = {}
for deployment_type, model, id_attr in _DEPLOYMENT_MODELS:
@@ -31,6 +31,7 @@ _LABELS = {
"maintenance_unknown": "Wartungsergebnis unklar",
"carp_split_brain": "CARP-Zustand kritisch",
"nat_drift": "NAT-Drift im Cluster",
"network_drift": "Netzwerk-Abweichung im Cluster",
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
}
@@ -28,6 +28,7 @@ _CHECKS = [
("maintenance_unknown", "maintenance_unknown_enabled", notification_checks.check_maintenance_unknown),
("carp_split_brain", "carp_health_enabled", notification_checks.check_carp_health),
("nat_drift", "nat_drift_enabled", notification_checks.check_nat_drift),
("network_drift", "network_drift_enabled", notification_checks.check_network_drift),
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
]
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
"smtp_from_address", "smtp_to_addresses",
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
"carp_auto_revert_failed_enabled",
"network_drift_enabled", "carp_auto_revert_failed_enabled",
"poll_interval_seconds",
]
for field in simple_fields:
+107
View File
@@ -1101,6 +1101,34 @@ NETWORK_ITEM_APIS = {
"kea_subnet": {"body_key": "subnet4", "base": "/kea/dhcpv4", "add": "addSubnet", "set": "setSubnet",
"del": "delSubnet", "get": "getSubnet", "search": "searchSubnet",
"reconfigure": "/kea/service/reconfigure"},
# System / interface devices / DNS+DHCP extras (same controllers pattern; read 2026-10-09).
"tunable": {"body_key": "sysctl", "base": "/core/tunables", "add": "addItem", "set": "setItem", "del": "delItem",
"get": "getItem", "search": "searchItem", "reconfigure": "/core/tunables/reconfigure"},
"cron_job": {"body_key": "job", "base": "/cron/settings", "add": "addJob", "set": "setJob", "del": "delJob",
"get": "getJob", "search": "searchJobs", "reconfigure": "/cron/service/reconfigure"},
"syslog_destination": {"body_key": "destination", "base": "/syslog/settings", "add": "addDestination",
"set": "setDestination", "del": "delDestination", "get": "getDestination",
"search": "searchDestinations", "reconfigure": "/syslog/service/reconfigure"},
"gateway_group": {"body_key": "gateway_group", "base": "/routing/group_settings", "add": "add", "set": "set",
"del": "del", "get": "get", "search": "search", "reconfigure": "/routing/group_settings/reconfigure"},
"lagg": {"body_key": "lagg", "base": "/interfaces/lagg_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/lagg_settings/reconfigure"},
"bridge": {"body_key": "bridge", "base": "/interfaces/bridge_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/bridge_settings/reconfigure"},
"loopback": {"body_key": "loopback", "base": "/interfaces/loopback_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/loopback_settings/reconfigure"},
"gre": {"body_key": "gre", "base": "/interfaces/gre_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gre_settings/reconfigure"},
"vxlan": {"body_key": "vxlan", "base": "/interfaces/vxlan_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/vxlan_settings/reconfigure"},
"gif": {"body_key": "gif", "base": "/interfaces/gif_settings", "add": "addItem", "set": "setItem",
"del": "delItem", "get": "getItem", "search": "searchItem", "reconfigure": "/interfaces/gif_settings/reconfigure"},
"dns_forward": {"body_key": "dot", "base": "/unbound/settings", "add": "addForward", "set": "setForward",
"del": "delForward", "get": "getForward", "search": "searchForward",
"reconfigure": "/unbound/service/reconfigure"},
"dhcp_range": {"body_key": "range", "base": "/dnsmasq/settings", "add": "addRange", "set": "setRange",
"del": "delRange", "get": "getRange", "search": "searchRange",
"reconfigure": "/dnsmasq/service/reconfigure"},
"kea_reservation": {"body_key": "reservation", "base": "/kea/dhcpv4", "add": "addReservation",
"set": "setReservation", "del": "delReservation", "get": "getReservation",
"search": "searchReservation", "reconfigure": "/kea/service/reconfigure"},
@@ -1178,3 +1206,82 @@ async def search_kea_subnets(host: OpnsenseHost) -> dict:
async def search_kea_reservations(host: OpnsenseHost) -> dict:
return await _search_all(host, "/kea/dhcpv4/searchReservation")
async def search_network_objects(host: OpnsenseHost, kind: str) -> list[dict]:
api = NETWORK_ITEM_APIS[kind]
data = await _request(host, "GET", f"{api['base']}/{api['search']}", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict)]
# ── ZFS snapshots (Core/Api/SnapshotsController, read 2026-10-09) ────────
# Not a config object: flat form fields instead of {body_key: {...}}, no reconfigure, and the answers are
# free-form ("OK" strings / {"status": ...}), so callers confirm by listing again.
async def list_snapshots(host: OpnsenseHost) -> list[dict]:
data = await _request(host, "GET", "/core/snapshots/search", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return [r for r in rows if isinstance(r, dict)]
async def create_snapshot(host: OpnsenseHost, name: str, note: str = "") -> None:
data = await _request(host, "POST", "/core/snapshots/add", json={"name": name, "note": note})
if isinstance(data, dict) and (data.get("validations") or str(data.get("status", "")).lower() == "failed"):
raise OpnsenseAPIError(f"Snapshot anlegen von Firewall nicht bestätigt: {redact_data(data)}")
async def delete_snapshot(host: OpnsenseHost, uuid: str) -> None:
data = await _request(host, "POST", f"/core/snapshots/del/{uuid}")
if isinstance(data, dict) and (data.get("validations") or str(data.get("status", "")).lower() == "failed"):
raise OpnsenseAPIError(f"Snapshot löschen von Firewall nicht bestätigt: {redact_data(data)}")
# ── Diagnostics (Diagnostics/Api/{Ping,Traceroute,Portprobe,DnsDiagnostics}Controller, read + live 2026-10-09) ──
# Traceroute/portprobe/DNS answer synchronously to the same "set" call that validates the form:
# {"result": "ok", "response": {...}} or {"result": "failed", "validations": {"<model>.settings.<field>": "..."}}.
# Ping is a job: set (returns a uuid) -> start -> searchJobs shows live counters -> stop -> remove.
# A probe to a closed/blackholed port takes the firewall 10 s, so these calls need a longer timeout.
DIAGNOSTIC_TIMEOUT = 40.0
async def diagnostic_set(host: OpnsenseHost, path: str, model: str, settings: dict) -> dict:
data = await _request(host, "POST", path, json={model: {"settings": settings}}, timeout=DIAGNOSTIC_TIMEOUT)
if not isinstance(data, dict):
raise OpnsenseAPIError("Diagnose: keine auswertbare Antwort der Firewall")
if data.get("result") != "ok":
raise OpnsenseDiagnosticRejected(data.get("validations") or {})
return data
class OpnsenseDiagnosticRejected(OpnsenseAPIError):
"""The firewall refused the input (its own validation); carries {field: message}."""
def __init__(self, validations: dict):
super().__init__("Eingabe von der Firewall abgelehnt")
self.validations = validations
async def ping_job_start(host: OpnsenseHost, settings: dict) -> str:
data = await diagnostic_set(host, "/diagnostics/ping/set", "ping", settings)
job = data.get("uuid")
if not job:
raise OpnsenseAPIError("Ping: die Firewall hat keine Job-ID geliefert")
started = await _request(host, "POST", f"/diagnostics/ping/start/{job}", timeout=DIAGNOSTIC_TIMEOUT)
if not isinstance(started, dict) or started.get("status") != "ok":
await ping_job_remove(host, job)
raise OpnsenseAPIError("Ping: der Job konnte nicht gestartet werden")
return job
async def ping_job_state(host: OpnsenseHost, job: str) -> dict | None:
data = await _request(host, "GET", "/diagnostics/ping/searchJobs", params={"rowCount": -1})
rows = data.get("rows", []) if isinstance(data, dict) else []
return next((r for r in rows if isinstance(r, dict) and r.get("id") == job), None)
async def ping_job_stop(host: OpnsenseHost, job: str) -> None:
await _request(host, "POST", f"/diagnostics/ping/stop/{job}")
async def ping_job_remove(host: OpnsenseHost, job: str) -> None:
await _request(host, "POST", f"/diagnostics/ping/remove/{job}")
+73
View File
@@ -0,0 +1,73 @@
"""ZFS snapshots (boot environments) of one firewall: list, create, delete.
Snapshots are a safety net before bigger changes, not a rollback button:
activating one needs a reboot and discards everything since, so that action is
deliberately NOT offered here. Deleting refuses the active/next-boot snapshot
(the firewall refuses too; we say so up front). Every step is audited.
"""
import re
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import audit_service, opnsense_client
from app.services.opnsense_client import OpnsenseAPIError
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
def _row(row: dict) -> dict:
return {
"uuid": str(row.get("uuid", "")), "name": str(row.get("name", "")), "active": str(row.get("active", "")),
"size": str(row.get("size", "")), "created": str(row.get("created_str", "")),
"mountpoint": str(row.get("mountpoint", "")),
}
async def list_snapshots(host: OpnsenseHost) -> dict:
try:
supported = (await opnsense_client._request(host, "GET", "/core/snapshots/isSupported")).get("supported")
except OpnsenseAPIError as exc:
if "404" in str(exc):
return {"supported": False, "rows": []}
raise
if not supported:
return {"supported": False, "rows": []}
return {"supported": True, "rows": [_row(r) for r in await opnsense_client.list_snapshots(host)]}
async def create(host: OpnsenseHost, name: str, note: str) -> dict:
if not NAME.match(name):
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="name")
audit = {"kind": "snapshot", "action": "snapshot.add", "entity_name": name, "host_id": str(host.id), "host_name": host.name}
await audit_service.persist(**audit, status="started", message=None, details={"note": note[:200]})
try:
await opnsense_client.create_snapshot(host, name, note[:200])
created = next((r for r in await opnsense_client.list_snapshots(host) if r.get("name") == name), None)
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
await audit_service.persist(**audit, status="applied" if created else "unverified", message=None, details={})
if not created:
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
return _row(created)
async def delete(host: OpnsenseHost, uuid: str) -> None:
rows = await opnsense_client.list_snapshots(host)
target = next((r for r in rows if str(r.get("uuid")) == uuid), None)
if target is None:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
if str(target.get("active", "")).strip() not in ("", "-"):
# N = active now, R = active after reboot: removing either would break the boot environment.
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="snapshot_active")
audit = {"kind": "snapshot", "action": "snapshot.del", "entity_name": str(target.get("name")), "host_id": str(host.id), "host_name": host.name}
await audit_service.persist(**audit, status="started", message=None, details={})
try:
await opnsense_client.delete_snapshot(host, uuid)
still = any(str(r.get("uuid")) == uuid for r in await opnsense_client.list_snapshots(host))
except OpnsenseAPIError as exc:
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
await audit_service.persist(**audit, status="unverified" if still else "applied", message=None, details={})
if still:
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
+70
View File
@@ -0,0 +1,70 @@
"""Client für den optionalen OPNexus-Updater-Container (updater/server.py).
Das Backend kann selbst nichts am Host ändern und soll das auch nicht können:
Es fragt den Updater-Dienst nach dem Zustand und reicht eine bereits
geprüfte Update-/Rollback-Anfrage weiter. Ist der Dienst nicht aktiviert oder
nicht erreichbar, ist das ein normaler Zustand ("nicht verfügbar"), kein Fehler.
"""
import logging
from pathlib import Path
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
_STATUS_TIMEOUT = 50.0 # der Updater fragt docker ab (bis zu 45 s)
_ACTION_TIMEOUT = 15.0
class UpdaterUnavailable(Exception):
"""Updater nicht aktiviert (kein Token) oder nicht erreichbar."""
class UpdaterRejected(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
def _token() -> str:
try:
token = Path(settings.updater_token_file).read_text(encoding="utf-8").strip()
except OSError:
token = ""
if not token:
raise UpdaterUnavailable("not_enabled")
return token
async def _call(method: str, path: str, payload: dict | None, timeout: float) -> dict:
headers = {"Authorization": f"Bearer {_token()}"}
try:
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.request(method, settings.updater_url.rstrip("/") + path, headers=headers, json=payload)
except httpx.HTTPError as exc:
logger.info("Updater nicht erreichbar: %s", exc.__class__.__name__)
raise UpdaterUnavailable("unreachable") from exc
try:
body = response.json()
except ValueError:
body = {}
if response.status_code == 401:
# Token passt nicht (Volume neu angelegt?) — wie "nicht verfügbar" behandeln.
raise UpdaterUnavailable("unauthorized")
if response.status_code >= 400:
raise UpdaterRejected(str(body.get("error") or "rejected"))
return body
async def status() -> dict:
return await _call("GET", "/status", None, _STATUS_TIMEOUT)
async def start_update(version: str) -> dict:
return await _call("POST", "/update", {"version": version}, _ACTION_TIMEOUT)
async def start_rollback() -> dict:
return await _call("POST", "/rollback", {}, _ACTION_TIMEOUT)
+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.12.0"
APP_CODENAME = "Condor"
APP_VERSION = "1.16.0"
APP_CODENAME = "Hobby"
@@ -0,0 +1,20 @@
"""Benachrichtigung bei Netzwerk-Abweichungen zwischen den Knoten eines Clusters.
Revision ID: 0004
Revises: 0003
Create Date: 2026-10-09
"""
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS network_drift_enabled boolean NOT NULL DEFAULT true")
def downgrade() -> None:
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS network_drift_enabled")
@@ -0,0 +1,20 @@
"""Einrichtungsassistent: Merker, dass ein Nutzer ihn abgeschlossen oder übersprungen hat.
Revision ID: 0005
Revises: 0004
Create Date: 2026-10-10
"""
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS setup_done boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE user_preferences DROP COLUMN IF EXISTS setup_done")
+143
View File
@@ -0,0 +1,143 @@
"""Ping / traceroute / port probe / DNS lookup run from a firewall."""
import asyncio
import json
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
BASE = "https://10.20.30.85"
async def _host(client, respx_mock, name="fw-diag"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
return (await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})).json()["id"]
def _url(path):
return f"{BASE}/api{path}"
async def _events(host_id):
async with SessionLocal() as db:
rows = await db.scalars(select(AuditEvent).where(AuditEvent.host_id == host_id, AuditEvent.kind == "diagnostic").order_by(AuditEvent.created_at))
return list(rows.all())
@pytest.fixture
def fast_sleep(monkeypatch):
async def instant(_seconds):
return None
monkeypatch.setattr(asyncio, "sleep", instant)
@pytest.mark.asyncio
async def test_traceroute_is_parsed_and_audited(client, respx_mock):
host_id = await _host(client, respx_mock)
sent = respx_mock.post(_url("/diagnostics/traceroute/set")).mock(return_value=httpx.Response(200, json={
"result": "ok", "response": {"notice": "traceroute to x", "rows": [
{"address": "10.0.0.1", "ttl": "1", "AS": "AS0", "host": "gw.lan", "probes": "0.4 ms"}]}}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/traceroute", json={"target": "example.com", "protocol": "icmp"})
assert resp.status_code == 200, resp.text
assert resp.json()["hops"] == [{"ttl": "1", "address": "10.0.0.1", "host": "gw.lan", "as": "AS0", "probes": "0.4 ms"}]
assert json.loads(sent.calls.last.request.content)["traceroute"]["settings"]["hostname"] == "example.com"
events = await _events(host_id)
assert [(e.action, e.status, e.entity_name) for e in events] == [("diag.traceroute", "ok", "example.com")]
@pytest.mark.asyncio
async def test_bad_targets_never_reach_the_firewall(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag2")
route = respx_mock.post(_url("/diagnostics/traceroute/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {}}))
for bad in ("a;b", "x y", "$(reboot)", "-h", "a" * 300, "host/../x"):
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/traceroute", json={"target": bad})
assert resp.status_code == 422, bad
assert not route.called
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "x.test", "port": 70000})).status_code == 422
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "x.test", "count": 500})).status_code == 422
assert (await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "x.test", "source": "not-an-ip"})).status_code == 422
@pytest.mark.asyncio
async def test_the_firewalls_own_validation_is_reported_as_422_and_audited(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag3")
respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={
"result": "failed", "validations": {"dns.settings.server": "Provide a valid server."}}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com", "server": "1.2.3.4"})
assert resp.status_code == 422 and resp.json()["detail"]["params"]["field"] == "server"
assert [(e.status) for e in await _events(host_id)] == ["failed"]
@pytest.mark.asyncio
async def test_portprobe_reports_open_or_closed_and_never_the_banner(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag4")
answers = iter([
{"result": "ok", "response": {"message": "Connection to 10.0.0.1 443 port [tcp/https] succeeded!", "payload": "SSH-2.0-SECRET-BANNER"}},
{"result": "ok", "response": {"message": "nc: connect to 10.0.0.1 port 1 (tcp) failed: Operation timed out", "payload": ""}},
])
respx_mock.post(_url("/diagnostics/portprobe/set")).mock(side_effect=lambda r: httpx.Response(200, json=next(answers)))
opened = await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "10.0.0.1", "port": 443})
closed = await client.post(f"/api/hosts/{host_id}/diagnostics/portprobe", json={"target": "10.0.0.1", "port": 1})
assert opened.json()["open"] is True and closed.json()["open"] is False
assert "SECRET-BANNER" not in opened.text
@pytest.mark.asyncio
async def test_dns_lookup(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-diag5")
respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {
"A": {"answers": ["example.com.\t300\tIN\tA\t192.0.2.1"], "query_time": "42 msec", "server": "10.0.0.1"}}}))
body = (await client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com"})).json()
assert body["records"]["A"]["answers"][0].endswith("192.0.2.1") and body["records"]["A"]["query_time"] == "42 msec"
@pytest.mark.asyncio
async def test_ping_runs_a_job_and_always_cleans_it_up(client, respx_mock, fast_sleep):
host_id = await _host(client, respx_mock, "fw-diag6")
respx_mock.post(_url("/diagnostics/ping/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "uuid": "job-1"}))
start = respx_mock.post(_url("/diagnostics/ping/start/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
row = {"id": "job-1", "status": "running", "send": 4, "received": 4, "loss": "0.0%", "min": 0.3, "avg": 0.4, "max": 0.5, "std-dev": 0.05, "last_error": None}
respx_mock.get(_url("/diagnostics/ping/searchJobs")).mock(return_value=httpx.Response(200, json={"rows": [row]}))
stop = respx_mock.post(_url("/diagnostics/ping/stop/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
remove = respx_mock.post(_url("/diagnostics/ping/remove/job-1")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "10.0.0.1", "count": 4})
assert resp.status_code == 200, resp.text
body = resp.json()
assert (body["sent"], body["received"], body["loss"], body["avg"]) == (4, 4, "0.0%", 0.4)
assert start.called and stop.called and remove.called
@pytest.mark.asyncio
async def test_ping_job_is_removed_even_when_polling_fails(client, respx_mock, fast_sleep):
host_id = await _host(client, respx_mock, "fw-diag7")
respx_mock.post(_url("/diagnostics/ping/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "uuid": "job-2"}))
respx_mock.post(_url("/diagnostics/ping/start/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
respx_mock.get(_url("/diagnostics/ping/searchJobs")).mock(side_effect=httpx.ConnectError("gone"))
stop = respx_mock.post(_url("/diagnostics/ping/stop/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
remove = respx_mock.post(_url("/diagnostics/ping/remove/job-2")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await client.post(f"/api/hosts/{host_id}/diagnostics/ping", json={"target": "10.0.0.1"})
assert resp.status_code == 502
assert stop.called and remove.called # no orphaned job on the firewall
assert [e.status for e in await _events(host_id)] == ["failed"]
@pytest.mark.asyncio
async def test_only_admins_can_run_probes(anon_client, client, respx_mock):
from httpx import ASGITransport, AsyncClient
from app.main import app
host_id = await _host(client, respx_mock, "fw-diag8")
assert (await anon_client.post(f"/api/hosts/{host_id}/diagnostics/dns", json={"name": "example.com"})).status_code == 401
created = await client.post("/api/users", json={"username": "diag_ro", "password": "Diag-Read-Only-2026", "role": "read_only"})
assert created.status_code == 201, created.text
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ro:
assert (await ro.post("/api/auth/login", json={"username": "diag_ro", "password": "Diag-Read-Only-2026"})).status_code == 200
probe = respx_mock.post(_url("/diagnostics/dns_diagnostics/set")).mock(return_value=httpx.Response(200, json={"result": "ok", "response": {}}))
for tool, body in (("dns", {"name": "example.com"}), ("ping", {"target": "10.0.0.1"}), ("traceroute", {"target": "10.0.0.1"}), ("portprobe", {"target": "10.0.0.1", "port": 22})):
assert (await ro.post(f"/api/hosts/{host_id}/diagnostics/{tool}", json=body)).status_code == 403, tool
assert not probe.called
finally:
await client.delete(f"/api/users/{created.json()['id']}")
+61
View File
@@ -496,3 +496,64 @@ async def test_verification_catches_a_field_that_changed_without_being_asked(cli
plan = (await _preview(client, host_id, **change)).json()
done = (await _execute(client, host_id, plan["token"], **change)).json()
assert done["verified"] is False and done["mismatches"] == ["network"]
# ── generic object kinds ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_objects_list_returns_only_the_display_columns(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o1")
respx_mock.get(_url("/syslog/settings/searchDestinations")).mock(return_value=httpx.Response(200, json={"rows": [
{"uuid": "d1", "enabled": "1", "transport": "udp4", "hostname": "192.0.2.10", "port": "514", "program": "", "level": "info",
"facility": "daemon", "certificate": "SECRET-CERT-REF", "description": "central"}]}))
body = (await client.get(f"/api/hosts/{host_id}/network/objects/syslog_destination")).json()
assert body["available"] is True and body["rows"][0]["hostname"] == "192.0.2.10"
assert "certificate" not in body["rows"][0] and "facility" not in body["rows"][0]
assert (await client.get(f"/api/hosts/{host_id}/network/objects/vlan")).status_code == 404 # typed kinds have their own lists
@pytest.mark.asyncio
async def test_objects_list_reports_a_missing_api_instead_of_failing(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o2")
respx_mock.get(_url("/interfaces/gre_settings/searchItem")).mock(return_value=httpx.Response(404, json={"errorMessage": "Endpoint not found"}))
body = (await client.get(f"/api/hosts/{host_id}/network/objects/gre")).json()
assert body == {"available": False, "columns": body["columns"], "rows": []}
@pytest.mark.asyncio
async def test_tunables_always_need_acknowledgement_and_defaults_cannot_be_edited(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o3")
add = (await _preview(client, host_id, kind="tunable", action="add", fields={"tunable": "vfs.read_max", "value": "128"})).json()
assert [r["code"] for r in add["risks"]] == ["tunable_change"] and add["requires_acknowledgement"] is True
_item(respx_mock, "/core/tunables/getItem/0add3c58db981d0d24d5d4ab322e3695", "sysctl",
{"tunable": "net.inet.tcp.drop_synfin", "value": "1", "descr": "built in"})
default = (await _preview(client, host_id, kind="tunable", action="set", uuid="0add3c58db981d0d24d5d4ab322e3695", fields={"value": "0"})).json()
assert [b["code"] for b in default["blocks"]] == ["tunable_default_row"] and default["can_execute"] is False
@pytest.mark.asyncio
async def test_lagg_or_bridge_containing_the_management_device_needs_acknowledgement(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o4") # reached on vtnet0
mine = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet0,vtnet2", "proto": "failover"})).json()
assert [r["code"] for r in mine["risks"]] == ["management_member"]
other = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet2,vtnet3", "proto": "failover"})).json()
assert other["risks"] == []
@pytest.mark.asyncio
async def test_vxlan_and_gif_are_listed_and_changed_like_the_other_devices(client, respx_mock):
host_id = await _setup(client, respx_mock, "fw-o5")
respx_mock.get(_url("/interfaces/vxlan_settings/searchItem")).mock(return_value=httpx.Response(200, json={"rows": [
{"uuid": "x1", "vxlanif": "vxlan1", "vxlanid": "100", "vxlanlocal": "10.20.30.57", "vxlanremote": "10.20.30.58",
"vxlangroup": "", "vxlandev": "lan", "secret_like": "dropped"}]}))
body = (await client.get(f"/api/hosts/{host_id}/network/objects/vxlan")).json()
assert body["rows"][0]["vxlanid"] == "100" and "secret_like" not in body["rows"][0]
_backup(respx_mock)
add = respx_mock.post(_url("/interfaces/gif_settings/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "g1"}))
respx_mock.post(_url("/interfaces/gif_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
_item(respx_mock, "/interfaces/gif_settings/getItem/g1", "gif", {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"})
change = {"kind": "gif", "action": "add", "fields": {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"}}
plan = (await _preview(client, host_id, **change)).json()
done = await _execute(client, host_id, plan["token"], **change)
assert done.status_code == 200 and done.json()["verified"] is True, done.text
assert _body(add.calls.last) == {"gif": change["fields"]}
+94
View File
@@ -0,0 +1,94 @@
"""Network configuration differences between the nodes of an HA cluster."""
import httpx
import pytest
from app.database import SessionLocal
from app.schemas.network import HostNetwork
from app.services import network_drift_service, notification_checks
from tests.test_network import DHCP_HOSTS, DNS_HOSTS, GATEWAYS, INTERFACES_INFO, KEA_RESERVATIONS, KEA_SUBNETS, ROUTES, VIPS, VLANS, ASSIGNMENTS
ENDPOINTS = {
"/interfaces/overview/interfacesInfo": INTERFACES_INFO, "/interfaces/assignment/searchItem": ASSIGNMENTS,
"/interfaces/vlan_settings/searchItem": VLANS, "/interfaces/vip_settings/searchItem": VIPS,
"/routing/settings/searchGateway": GATEWAYS, "/routes/routes/searchroute": ROUTES,
"/unbound/settings/searchHostOverride": DNS_HOSTS, "/dnsmasq/settings/searchHost": DHCP_HOSTS,
"/kea/dhcpv4/searchSubnet": KEA_SUBNETS, "/kea/dhcpv4/searchReservation": KEA_RESERVATIONS,
}
async def _host(client, name, base, cluster="ha"):
r = await client.post("/api/hosts", json={"name": name, "base_url": base, "api_key": "k", "api_secret": "s",
"verify_tls": False, "cluster_name": cluster})
assert r.status_code == 201, r.text
def _serve(respx_mock, base, **replace):
for path, body in ENDPOINTS.items():
respx_mock.get(f"{base}/api{path}").mock(return_value=httpx.Response(200, json=replace.get(path, body)))
def _net(**replace) -> HostNetwork:
data = {
"host_id": "x", "host_name": "x", "interfaces": [], "vlans": [], "vips": [], "gateways": [], "routes": [],
"management_device": None, "can_configure_interfaces": True, "unavailable": [],
}
data.update(replace)
return HostNetwork(**data)
def test_identical_configuration_has_no_differences_even_with_per_node_values():
vip_a = {"uuid": "1", "interface": "lan", "interface_label": "LAN", "mode": "carp", "address": "192.168.1.1/24",
"vhid": "10", "advbase": "1", "advskew": "0", "description": "x"}
vip_b = {**vip_a, "uuid": "2", "advskew": "100"} # advskew legitimately differs between master and backup
a, b = _net(vips=[vip_a]), _net(vips=[vip_b])
assert network_drift_service.compare({"a": a, "b": b}) == []
def test_missing_and_different_objects_are_reported_with_who_has_what():
vlan = {"uuid": "1", "parent": "vtnet1", "tag": "10", "pcp": "0", "description": "LAN", "device": "vtnet1_vlan10"}
gw_a = {"uuid": "g", "name": "WAN_GW", "description": "", "interface": "wan", "interface_label": "WAN", "ip_protocol": "inet",
"address": "10.0.0.1", "is_default": True, "disabled": False, "priority": "255", "status": "Online"}
a = _net(vlans=[vlan, {**vlan, "uuid": "2", "tag": "20"}], gateways=[gw_a])
b = _net(vlans=[vlan], gateways=[{**gw_a, "address": "10.0.0.9"}])
rows = {(r["category"], r["key"]): r for r in network_drift_service.compare({"a": a, "b": b})}
assert rows[("vlans", "20@vtnet1")]["status"] == "missing" and rows[("vlans", "20@vtnet1")]["missing_from"] == ["b"]
assert rows[("gateways", "WAN_GW")]["status"] == "different"
assert rows[("gateways", "WAN_GW")]["signatures"]["a"] != rows[("gateways", "WAN_GW")]["signatures"]["b"]
assert ("vlans", "10@vtnet1") not in rows
def test_a_category_one_node_cannot_deliver_is_not_reported_as_a_difference():
vlan = {"uuid": "1", "parent": "vtnet1", "tag": "10", "pcp": "0", "description": "", "device": "d"}
a, b = _net(vlans=[vlan]), _net(vlans=[], unavailable=["vlans"])
assert network_drift_service.compare({"a": a, "b": b}) == []
@pytest.mark.asyncio
async def test_endpoint_and_alert_report_a_route_missing_on_one_node(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
await _host(client, "fw-da", "https://10.20.30.91")
await _host(client, "fw-db", "https://10.20.30.92")
_serve(respx_mock, "https://10.20.30.91")
_serve(respx_mock, "https://10.20.30.92", **{"/routes/routes/searchroute": {"rows": []}})
body = (await client.get("/api/network/drift")).json()
cluster = next(c for c in body["clusters"] if c["name"] == "ha")
assert cluster["unreachable"] == []
diff = [d for d in cluster["differences"] if d["category"] == "routes"]
assert [(d["key"], d["status"], d["missing_from"]) for d in diff] == [("10.9.0.0/16", "missing", ["fw-db"])]
async with SessionLocal() as db:
problems = await notification_checks.check_network_drift(db)
mine = [v for k, v in problems.items() if k.startswith("ha:routes:")]
assert mine and mine[0]["missing_from"] == ["fw-db"] and "Route" in mine[0]["object"]
@pytest.mark.asyncio
async def test_an_unreachable_node_keeps_open_alerts_instead_of_resolving_them(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
await _host(client, "fw-dc", "https://10.20.30.93", cluster="ha2")
await _host(client, "fw-dd", "https://10.20.30.94", cluster="ha2")
_serve(respx_mock, "https://10.20.30.93")
respx_mock.get(url__regex=r"https://10\.20\.30\.94/.*").mock(side_effect=httpx.ConnectError("down"))
async with SessionLocal() as db:
problems = await notification_checks.check_network_drift(db)
assert "ha2:" in problems.preserve_prefixes
+66
View File
@@ -0,0 +1,66 @@
"""First-run setup assistant: per-user "done" flag and the password reset tool."""
import pytest
from sqlalchemy import select
from app import reset_password
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser, Session
from app.services import auth_service
@pytest.mark.asyncio
async def test_setup_done_defaults_to_false_and_persists(client):
assert (await client.get("/api/settings")).json()["setup_done"] is False
resp = await client.put("/api/settings", json={"setup_done": True})
assert resp.status_code == 200
assert resp.json()["setup_done"] is True
assert (await client.get("/api/settings")).json()["setup_done"] is True
@pytest.mark.asyncio
async def test_other_preference_changes_keep_setup_done(client):
await client.put("/api/settings", json={"setup_done": True})
resp = await client.put("/api/settings", json={"theme": "dark", "language": "en"})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", True)
# ...and the other direction: saving setup_done leaves theme/language alone.
resp = await client.put("/api/settings", json={"setup_done": False})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", False)
@pytest.mark.asyncio
async def test_setup_done_rejects_non_boolean(client):
assert (await client.put("/api/settings", json={"setup_done": "maybe"})).status_code == 422
@pytest.mark.asyncio
async def test_reset_password_prints_new_password_and_ends_sessions(client, capsys):
async with SessionLocal() as db:
before = (await db.execute(select(Session))).scalars().all()
assert before, "the client fixture logged in, so a session must exist"
assert await reset_password.main(None) == 0
out = capsys.readouterr().out
lines = dict(line.split(": ", 1) for line in out.strip().splitlines())
assert lines["Username"] == settings.admin_username
new_password = lines["Password"]
assert len(new_password) >= 20 and new_password != settings.admin_password
async with SessionLocal() as db:
assert (await db.execute(select(Session))).scalars().all() == []
user = (await db.execute(select(AdminUser).where(AdminUser.username == settings.admin_username))).scalar_one()
assert await auth_service.verify_password_async(user.password_hash, new_password)
assert not await auth_service.verify_password_async(user.password_hash, settings.admin_password)
# Put the shared bootstrap admin back for the tests that follow.
user.password_hash = await auth_service.hash_password_async(settings.admin_password)
await db.commit()
@pytest.mark.asyncio
async def test_reset_password_unknown_user_fails(capsys):
assert await reset_password.main("does-not-exist") == 1
assert "No such user" in capsys.readouterr().err
+69
View File
@@ -0,0 +1,69 @@
"""ZFS snapshots: list, create, delete (never the active one)."""
import httpx
import pytest
BASE = "https://10.20.30.80"
async def _host(client, respx_mock, name="fw-snap"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
return (await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})).json()["id"]
def _snaps(respx_mock, rows):
respx_mock.get(f"{BASE}/api/core/snapshots/isSupported").mock(return_value=httpx.Response(200, json={"supported": True}))
return respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": rows}))
ROW = {"uuid": "s1", "name": "default", "active": "NR", "mountpoint": "/", "size": "1.2G", "created_str": "2026-10-06 13:59"}
@pytest.mark.asyncio
async def test_list_snapshots(client, respx_mock):
host_id = await _host(client, respx_mock)
_snaps(respx_mock, [ROW])
body = (await client.get(f"/api/hosts/{host_id}/snapshots")).json()
assert body["supported"] is True and body["rows"][0]["name"] == "default" and body["rows"][0]["active"] == "NR"
@pytest.mark.asyncio
async def test_unsupported_firewall_is_reported(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-nozfs")
respx_mock.get(f"{BASE}/api/core/snapshots/isSupported").mock(return_value=httpx.Response(200, json={"supported": False}))
assert (await client.get(f"/api/hosts/{host_id}/snapshots")).json() == {"supported": False, "rows": []}
@pytest.mark.asyncio
async def test_create_is_confirmed_by_listing_again(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-snap2")
new = {"uuid": "s2", "name": "before_change", "active": "-", "mountpoint": "-", "size": "0", "created_str": "now"}
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": [ROW, new]}))
post = respx_mock.post(f"{BASE}/api/core/snapshots/add").mock(return_value=httpx.Response(200, json="OK"))
resp = await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "before_change", "note": "x"})
assert resp.status_code == 201, resp.text
assert resp.json()["uuid"] == "s2" and post.called
@pytest.mark.asyncio
async def test_create_rejects_bad_names_and_reports_a_snapshot_that_never_appeared(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-snap3")
assert (await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "bad name!"})).status_code == 422
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": [ROW]}))
respx_mock.post(f"{BASE}/api/core/snapshots/add").mock(return_value=httpx.Response(200, json="OK"))
assert (await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "ghost"})).status_code == 502
@pytest.mark.asyncio
async def test_active_snapshot_cannot_be_deleted_others_can(client, respx_mock):
host_id = await _host(client, respx_mock, "fw-snap4")
old = {"uuid": "s3", "name": "old", "active": "-", "mountpoint": "-", "size": "1M", "created_str": "x"}
state = {"rows": [ROW, old]}
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(side_effect=lambda r: httpx.Response(200, json=state))
def drop(request):
state["rows"] = [ROW]
return httpx.Response(200, json={"status": "ok"})
delete = respx_mock.post(f"{BASE}/api/core/snapshots/del/s3").mock(side_effect=drop)
blocked = await client.delete(f"/api/hosts/{host_id}/snapshots/s1")
assert blocked.status_code == 409 and blocked.json()["detail"]["params"]["reasons"] == "snapshot_active"
assert (await client.delete(f"/api/hosts/{host_id}/snapshots/s3")).status_code == 204 and delete.called
assert (await client.delete(f"/api/hosts/{host_id}/snapshots/nope")).status_code == 404
+176
View File
@@ -0,0 +1,176 @@
import httpx
import pytest
from tests.test_users import _login_as
from app.config import settings
from app.services import update_check_service
from app.version import APP_VERSION
FEED = "https://feed.test/api/v1/repos/o/r/releases/latest"
UPDATER = "http://updater.test:8080"
def _bump(minor=0, patch=0, major=0):
a, b, c = (int(x) for x in APP_VERSION.split("."))
return f"{a + major}.{b + minor}.{c + patch}"
@pytest.fixture(autouse=True)
def _setup(monkeypatch, tmp_path):
token = tmp_path / "token"
token.write_text("t" * 40)
monkeypatch.setattr(settings, "update_check_url", FEED)
monkeypatch.setattr(settings, "updater_url", UPDATER)
monkeypatch.setattr(settings, "updater_token_file", str(token))
update_check_service.reset_cache()
yield
update_check_service.reset_cache()
def _feed(respx_mock, version):
respx_mock.get(FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{version}"}))
def _updater_status(respx_mock, **over):
body = {"mode": "release", "running_version": APP_VERSION, "job": None, "phases": [], "log": [],
"services": {"backend": "healthy"}, "rollback_available": False, "rollback_loses_data": False,
"last_update": None}
body.update(over)
return respx_mock.get(f"{UPDATER}/status").mock(return_value=httpx.Response(200, json=body))
@pytest.mark.asyncio
async def test_status_reports_update_and_can_update(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
_updater_status(respx_mock)
body = (await client.get("/api/system/updater")).json()
assert body["available"] is True and body["can_update"] is True
assert body["latest"] == _bump(minor=1) and body["major_change"] is False
@pytest.mark.asyncio
async def test_status_without_token_is_unavailable_not_an_error(client, respx_mock, monkeypatch, tmp_path):
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
_feed(respx_mock, _bump(minor=1))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "not_enabled" and body["can_update"] is False
assert body["update_available"] is True # der Hinweis bleibt trotzdem
@pytest.mark.asyncio
async def test_status_unreachable_updater(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
respx_mock.get(f"{UPDATER}/status").mock(side_effect=httpx.ConnectError("down"))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "unreachable"
@pytest.mark.asyncio
async def test_major_change_is_not_offered_for_one_click(client, respx_mock):
_feed(respx_mock, _bump(major=1))
_updater_status(respx_mock)
body = (await client.get("/api/system/updater")).json()
assert body["major_change"] is True and body["can_update"] is False
resp = await client.post("/api/system/updater/update", json={"version": _bump(major=1)})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_MAJOR_CHANGE"
@pytest.mark.asyncio
async def test_local_build_install_cannot_update(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
_updater_status(respx_mock, mode="local")
body = (await client.get("/api/system/updater")).json()
assert body["can_update"] is False and body["mode"] == "local"
@pytest.mark.asyncio
async def test_update_forwards_only_the_feed_version(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.status_code == 202
sent = route.calls.last.request
assert sent.headers["authorization"] == "Bearer " + "t" * 40
assert b'"version": "%s"' % newer.encode() in sent.content or newer.encode() in sent.content
@pytest.mark.asyncio
@pytest.mark.parametrize("version", ["0.0.1", "99.99.99", "1.0.0-rc1", "latest", "../../x"])
async def test_update_rejects_any_other_version(client, respx_mock, version):
_feed(respx_mock, _bump(patch=1))
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
resp = await client.post("/api/system/updater/update", json={"version": version})
assert resp.status_code in (409, 422)
assert route.call_count == 0
@pytest.mark.asyncio
async def test_update_when_no_update_available(client, respx_mock):
_feed(respx_mock, APP_VERSION)
resp = await client.post("/api/system/updater/update", json={"version": APP_VERSION})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_NOT_OFFERED"
@pytest.mark.asyncio
async def test_busy_and_rejected_map_to_error_codes(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
route = respx_mock.post(f"{UPDATER}/update")
route.mock(return_value=httpx.Response(409, json={"error": "busy"}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.json()["detail"]["code"] == "UPDATER_BUSY"
route.mock(return_value=httpx.Response(409, json={"error": "not_release_mode"}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.json()["detail"] == {"code": "UPDATER_REJECTED", "params": {"reason": "not_release_mode"}}
@pytest.mark.asyncio
async def test_update_without_updater_is_503(client, respx_mock, monkeypatch, tmp_path):
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
newer = _bump(patch=1)
_feed(respx_mock, newer)
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.status_code == 503 and resp.json()["detail"]["code"] == "UPDATER_UNAVAILABLE"
@pytest.mark.asyncio
async def test_rollback_is_forwarded(client, respx_mock):
route = respx_mock.post(f"{UPDATER}/rollback").mock(return_value=httpx.Response(202, json={"job": {"id": "r"}}))
resp = await client.post("/api/system/updater/rollback")
assert resp.status_code == 202 and route.call_count == 1
@pytest.mark.asyncio
async def test_read_only_user_cannot_read_or_start_updates(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
status = _updater_status(respx_mock)
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
await client.post("/api/users", json={"username": "viewer-upd", "password": "password-123456", "role": "read_only"})
viewer = await _login_as("viewer-upd", "password-123456")
try:
assert (await viewer.get("/api/system/updater")).status_code == 403
assert (await viewer.post("/api/system/updater/update", json={"version": newer})).status_code == 403
assert (await viewer.post("/api/system/updater/rollback")).status_code == 403
finally:
await viewer.aclose()
assert status.call_count == 0 and route.call_count == 0
@pytest.mark.asyncio
async def test_anonymous_is_rejected(anon_client):
assert (await anon_client.get("/api/system/updater")).status_code == 401
assert (await anon_client.post("/api/system/updater/rollback")).status_code == 401
@pytest.mark.asyncio
async def test_update_request_is_audited(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
assert (await client.post("/api/system/updater/update", json={"version": newer})).status_code == 202
events = (await client.get("/api/audit?kind=system")).json()["items"]
assert {(e["action"], e["status"]) for e in events} >= {("update", "requested"), ("update", "accepted")}
+3
View File
@@ -16,3 +16,6 @@ services:
proxy:
build: ./proxy
updater:
build: ./updater
+33
View File
@@ -120,6 +120,9 @@ services:
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
UPDATE_CHECK_INTERVAL_HOURS: ${UPDATE_CHECK_INTERVAL_HOURS:-12}
# Optionaler Updater-Dienst (Profil "updater", siehe unten). Ohne ihn
# zeigt das Webinterface nur an, wie man ihn aktiviert.
UPDATER_URL: ${UPDATER_URL:-http://updater:8080}
volumes:
- secrets:/data/opnsense-secrets
- backups:/data/opnsense-backups
@@ -133,6 +136,9 @@ services:
# Gleiches Volume wie der postgres-backup-Service: hier landet das
# automatische Sicherheits-Backup vor einer Schema-Migration.
- postgres_backups:/backups
# Zugriffstoken des Updaters (nur lesen). Leer, solange der Updater
# nicht aktiviert ist.
- updater_state:/updater:ro
# Bound to loopback only by default — reachable from this host, not
# the network. Set BIND_ADDRESS in .env (e.g. to 0.0.0.0, or a
# specific interface IP) for network-wide access instead of editing
@@ -195,7 +201,34 @@ services:
- caddy_data:/data
- caddy_config:/config
# ── Optional: Updates aus dem Webinterface ──────────────────────────
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=updater in
# .env und `docker compose up -d`. Dies ist der einzige Container mit
# Zugriff auf den Docker-Socket (= root-gleiche Rechte auf diesem Host): er
# hat keine veröffentlichten Ports, ist nur mit seinem Token ansprechbar
# und kann nur "aktualisieren"/"zurückrollen" (updater/server.py). Das
# Backend selbst bekommt den Socket nie. Nur für Release-Images
# (OPNEXUS_IMAGE_REPO gesetzt).
updater:
profiles: ["updater"]
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-updater:${OPNEXUS_VERSION:-latest}
restart: unless-stopped
logging: *default-logging
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Das Verzeichnis mit docker-compose.yml und .env (der Updater schreibt
# nur OPNEXUS_VERSION und .opnexus-update-state hinein).
- .:/project
- updater_state:/state
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
volumes:
updater_state:
postgres_data:
postgres_backups:
secrets:
+210 -55
View File
@@ -157,6 +157,56 @@ Verkehrsunterbrechung auf dem falschen Knoten.
---
## Einrichtungsassistent und Passwort-Reset
**Einrichtungsassistent** (`/setup`, System › Einrichtung). Vier Schritte mit
kurzen Erklärungen, damit eine frische Installation ohne Handbuch funktioniert:
1. **Willkommen:** was der Assistent tut, Sprache (Deutsch/Englisch). Er liest
nur von der Firewall; geändert wird dort erst, wenn du selbst etwas ausrollst.
2. **Konto:** eigenes Passwort setzen (optional, empfohlen, wenn OPNexus beim
Start eines erzeugt hat) und Zwei-Faktor-Anmeldung einrichten (derselbe
Baustein wie in den Einstellungen).
3. **Firewall:** Anleitung für den API-Key in OPNsense (System › Access › Users,
Benutzer, „API keys“, `apikey.txt`), dann Name, Adresse, Key und Secret
eintragen, Zertifikat abrufen und den Fingerabdruck bestätigen (dasselbe
Festsetzen wie beim Hinzufügen eines Hosts). Nach dem Anlegen zeigt der
Schritt sofort, ob die Firewall antwortet, und bei „offline“ was man prüft.
Weitere Firewalls, auch die Knoten eines HA-Paars, lassen sich im selben
Schritt nacheinander eintragen.
4. **Fertig:** Zusammenfassung (Firewalls erreichbar, Zwei-Faktor an/aus) und
Verweise auf das, was danach sinnvoll ist: Regeln, Benachrichtigungen,
Updates, Datenbank-Sicherung.
*Wann er sich öffnet:* nach der Anmeldung auf dem Dashboard, solange es **keinen
Host** gibt und dieser Benutzer den Assistenten weder abgeschlossen noch
übersprungen hat. Der Merker liegt pro Benutzer in `user_preferences.setup_done`
(Migration `0005`, `PUT /api/settings {"setup_done": true}`). `read_only`-Benutzer
bekommen ihn nicht angeboten. Wieder öffnen geht über System › Einrichtung, die
Einstellungen und die Schnellsuche (Strg/⌘+K).
**Passwort-Reset** (`opnexus password [BENUTZER] [--yes]`). Setzt für das
Admin-Konto (Standard: `ADMIN_USERNAME`; gibt es das nicht und genau einen Admin,
dieser) ein neues Zufallspasswort, gibt es einmal aus und beendet alle Sitzungen
des Kontos. Eine Rückfrage kommt nur, wenn sich schon jemand angemeldet hat;
ohne Terminal ist `--yes` nötig. Dahinter steht
`docker compose exec backend python -m app.reset_password [BENUTZER]`, wie
`app.reset_2fa` ein Werkzeug mit Shell-Zugriff auf den Server als
Vertrauensgrenze. Eine eingeschaltete Zwei-Faktor-Anmeldung bleibt bestehen
(verloren: `app.reset_2fa`). Es gibt bewusst keine Anzeige des alten Passworts:
Gespeichert wird nur der Argon2id-Hash.
*Verifikation 2026-10-10:* Backend-Tests (`test_setup_assistant.py`: Merker
persistiert und bleibt bei anderen Einstellungen erhalten, Reset gibt Passwort
aus, beendet Sitzungen und tauscht den Hash, unbekannter Benutzer scheitert;
vollständige Suite 583 bestanden). Kommandozeile gegen einen Wegwerf-Stack:
frische Installation ohne Rückfrage, nach einer Anmeldung Rückfrage bzw.
`--yes`, ungültige Option/Benutzer mit Fehlercode. Browser (Chromium, 1440 und
375 px, Wegwerf-Stack mit Mock-Firewall): Anmeldung landet auf `/setup`,
Sprachwechsel, Passwortwechsel inklusive Fehlerfall, Host online und offline,
Zusammenfassung, Abschließen merkt sich den Zustand, keine Konsolenfehler, kein
horizontaler Überlauf.
## Barrierefreiheit (2026-10-04)
Version 1.8.0 „Harris“. Ziel: OPNexus per Tastatur und mit Screenreader
@@ -183,10 +233,6 @@ fand.
`aria-hidden`, Unternavigation als eigene Landmarke. Leere Tabellenköpfe
tragen einen nur für Screenreader sichtbaren Text („Aktionen“). Der
Host-Status (`StatusDot`) war fest deutsch und folgt jetzt der Sprache.
- **Prüfen.** `scripts/verify_a11y.mjs` wiederholt den axe-Lauf (nur lesend:
schreibende Anfragen werden im Browser lokal beantwortet, das Theme wird nur
in der Antwort überschrieben). Aufruf siehe Skriptkopf; Exit-Code ≠ 0 bei
Verstößen.
**Verifikation:** axe-core (WCAG 2.0/2.1/2.2 A + AA, Best Practices) auf allen
18 Seiten, Hell/Dunkel bei 1440 px und Hell bei 375 px: 0 Verstöße. Zusätzlich
@@ -379,14 +425,7 @@ unter `frontend/lib/i18n/` (`de.json`/`en.json`, ca. 1.400 Schlüssel).
Firewall-Daten (Aliase, Regelbeschreibungen) und die deutschen Suchbegriffe der
Befehlspalette (sie bleiben zusätzlich zu den englischen Seitentiteln nutzbar).
- **Prüfwerkzeuge:** `python3 scripts/check_i18n.py` (Katalog-Parität,
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel) und
`scripts/verify_i18n.mjs` (Browser: Umschalten über die Einstellungen, alle
lesenden Seiten samt „Neu“-Dialogen auf deutsche Restwörter prüfen, 375 px auf
horizontalen Überlauf; schreibende Requests werden abgefangen; `/updates` wird
ausgelassen, weil das Öffnen dort Update-Suchen auf den Firewalls startet).
Das Skript braucht `OPNEXUS_TEST_PASSWORD` und als `OPNEXUS_BASE_URL` den in
`FRONTEND_ORIGIN` konfigurierten Origin (sonst blockiert die CORS-Freigabe die
Anmeldung).
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel).
**Verifiziert 2026-09-19:** DE→EN→DE mit DB-Persistenz und Reload; 17 Seiten und
alle „Neu“-Dialoge im Englischen ohne deutsche Restwörter (Treffer nur aus
@@ -909,7 +948,7 @@ bestätigt, bevor mit dem Picker begonnen wurde).
## Restore, Stufe 1: Vorschau + manuelle Übergabe (2026-09-09)
Setzt `docs/restore-plan.md` (8. September) in seiner ersten Ausbaustufe um:
Erste Ausbaustufe des Restore-Assistenten (geplant am 8. September):
eine lokal gespeicherte OPNsense-Konfigurationssicherung (`backup_store.py`,
bereits vorhandenes "Backups"-Panel) prüfen, sicher als Vorschau anzeigen und
den Wechsel auf das OPNsense-Webinterface dokumentiert übergeben — **OPNexus
@@ -2096,7 +2135,7 @@ Details: `docs/verification/log-search-*`.
Tabelle werden durch einen Datenbank-Trigger blockiert; Datenbankadministratoren
können technisch weiterhin das Schema ändern. Keine rückwirkende Rekonstruktion
früherer Aktionen. XML-Backups, Passwörter und API-Secrets werden nicht protokolliert.
- **Restore**: separat ausgearbeiteter [Restore-Plan](restore-plan.md).
- **Restore**: separat ausgearbeitet, siehe Abschnitt „Restore, Stufe 1“.
Es gibt keine implementierte Restore-Ausführung. Der Plan unterscheidet lokale
OPNexus-XML-Dateien von bereits auf der Firewall vorhandenen Revisionen.
@@ -2415,9 +2454,12 @@ in die Backend-Logs geschrieben:
docker compose logs backend | grep -A5 "generated password"
```
Danach in `.env` ein eigenes `ADMIN_PASSWORD` setzen (empfohlen) oder das
Passwort über die künftige "Passwort ändern"-Funktion (`POST
/api/auth/change-password`, aktuell nur per API, kein UI-Formular) ändern.
Ist das Passwort verloren oder nie gesehen worden, setzt `opnexus password`
(im Repo-Checkout `scripts/opnexus password`) ein **neues** Zufallspasswort und
zeigt es an; siehe "Einrichtungsassistent und Passwort-Reset" unten. Danach
das Passwort im Einrichtungsassistenten oder unter Einstellungen › Konto
ändern, oder in `.env` ein eigenes `ADMIN_PASSWORD` setzen (gilt nur beim
Anlegen des ersten Kontos).
Login-Versuche sind pro (IP, Benutzername) und pro IP rate-limitiert
(Standard: 5 Fehlversuche / 5 Minuten Sperre).
@@ -2520,37 +2562,6 @@ Release-Seite angehängt (GitHub und Forgejo haben dafür verschiedene APIs, die
CI lädt es deshalb nicht hoch). Anwender brauchen damit nur Docker und diese
vier Dateien.
**Ein Release veröffentlichen:** Versionsdateien und `CHANGELOG.md` anpassen,
committen, dann `git tag v1.9.1 && git push origin v1.9.1`. Der Release-
Workflow prüft per `scripts/check_version.sh`, dass Tag, `version.py`,
`version.ts` und CHANGELOG zusammenpassen, lässt die Tests laufen und pusht
erst dann die Tags `1.9.1`, `1.9` und `1` (Vorabversionen wie `v1.10.0-rc1`
nur mit vollem Tag).
**Entwicklungs-Repo und öffentliches Repo (seit 2026-10-05):** Entwickelt wird
im privaten Repo (volle Historie). Öffentlich ist `git.opnexus.dev/opnexus/opnexus`,
und dort landet pro Release nur ein sauberer Snapshot-Commit — nie die
Entwicklungs-Historie (frühere Lizenzstände, interne Übergaben,
Live-Verifikation mit echten Adressen). Ablauf eines Releases:
```bash
git tag v1.11.0 # im Entwicklungs-Repo, nach check_version.sh
scripts/publish_public.sh v1.11.0 # Trockenlauf: Snapshot nach .public-repo/ prüfen
scripts/publish_public.sh v1.11.0 --push # Snapshot + Tag ins öffentliche Repo
scripts/build_release_images.sh v1.11.0 --push # Images bauen + in die Registry (ohne CI-Runner)
scripts/make_update_feed.sh v1.11.0 # erst jetzt: Feed für updates.opnexus.dev
```
`publish_public.sh` exportiert den Tag (nicht den Working Tree), lässt
`.claude/`, `docs/HANDOFF-*`, `docs/verification*` und `.env` weg und bricht
ab, wenn im Export interne Adressen/Hostnamen (Muster stehen im Skript) oder
Schlüsselblöcke stehen — ein Sicherheitsnetz, kein Ersatz fürs Durchsehen.
Folge: Verweise auf `docs/verification*` laufen im öffentlichen Repo ins Leere
(siehe Hinweis oben im Handbuch). Hinter dem Cloudflare-Proxy (Free) sind
Uploads auf 100 MB pro Request begrenzt; der Push des 1.10.0-rc1 ging
problemlos durch, bei größeren Layern die Registry-Domain auf „DNS only“
stellen oder aus dem LAN pushen.
**Registry ist reine Konfiguration**, nicht Teil der Workflow-Datei —
Repository-Variablen `REGISTRY` (Default `ghcr.io`) und `IMAGE_NAMESPACE`
(Default: Repo-Besitzer), Secrets `REGISTRY_USERNAME`/`REGISTRY_TOKEN`. Für
@@ -2566,8 +2577,8 @@ GitHub/Forgejo `releases/latest`, den man in `.env` einträgt:
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
```
Das ist der offizielle Feed des Projekts (statische JSON-Datei aus
`website/latest.json`, siehe `website/README.md`). Jeder andere Feed im
Das ist der offizielle Feed des Projekts (eine statische JSON-Datei, ausgeliefert als
`updates.opnexus.dev/latest.json`). Jeder andere Feed im
selben Format funktioniert ebenso, etwa `…/api/v1/repos/<owner>/opnexus/releases/latest`
auf einer eigenen Forgejo/GitHub-Instanz.
@@ -2578,7 +2589,78 @@ einzelnes GET mit dem User-Agent `OPNexus/<Version>` hinaus, ohne Instanz- oder
Nutzerdaten. Ein unerreichbarer Feed bleibt unauffällig (der letzte bekannte
Stand bleibt, Neuversuch frühestens nach 15 Minuten); Vorabversionen
(`-rc1`) werden nie vorgeschlagen. Der Hinweis lässt sich pro Version
wegklicken und installiert nichts — dafür gibt es `opnexus update`.
wegklicken und installiert nichts — dafür gibt es den optionalen Updater
(nächster Abschnitt) oder `opnexus update`.
#### Update aus dem Webinterface (optional)
Statt `opnexus update` auf dem Server kann ein Administrator OPNexus auch in
den **Einstellungen › „OPNexus aktualisieren“** aktualisieren: Version und
Release-Notes ansehen, „Auf X.Y.Z aktualisieren“ klicken, bestätigen,
Fortschritt verfolgen. Die Seite überbrückt den kurzen Neustart der Dienste
selbst und lädt sich danach neu. Ein fehlgeschlagenes Update rollt wie auf der
Kommandozeile automatisch zurück und zeigt das Ergebnis samt Protokoll an;
„Auf Vorversion zurücksetzen“ macht das letzte Update rückgängig (mit Hinweis,
ob dabei Daten verloren gehen — nur wenn das Update die Datenbank migriert hat).
**Das ist bewusst abgeschaltet und muss aktiviert werden**, weil es Docker-Zugriff
braucht. Dafür gibt es einen eigenen kleinen Dienst, den **Updater**
(`updater/`, Compose-Profil `updater`):
```bash
# .env auf dem Server
COMPOSE_PROFILES=updater
```
```bash
docker compose pull && docker compose up -d --no-build
```
Sicherheitsmodell:
- Der Updater ist der **einzige** Container mit dem Docker-Socket. Das
entspricht root-gleichen Rechten auf dem Host — deshalb ist er optional und
winzig gehalten (`updater/server.py`, nur Standardbibliothek). Das Backend
bekommt den Socket nie.
- Er hat **keinen veröffentlichten Port** und ist nur aus dem Compose-Netz
erreichbar, nur mit einem Token, den er beim ersten Start selbst erzeugt
und in sein Volume `updater_state` schreibt (das Backend hängt es
schreibgeschützt ein). Von außen kommt man nicht heran.
- Er kann **nur** „aktualisieren auf X.Y.Z“, „zurückrollen“ und „Status“ — es
gibt keinen Weg, einen eigenen Befehl auszuführen. Eine Zielversion muss
eine stabile `X.Y.Z` sein, neuer als die laufende, mit derselben
Hauptversion.
- Das Backend leitet nur Admin-Anfragen weiter und nimmt als Ziel
ausschließlich die Version an, die der Release-Feed (`UPDATE_CHECK_URL`)
als neueste stabile meldet. Jede Anfrage steht im Audit-Log (Typ
„OPNexus-System“). Nur-Lese-Konten sehen den Abschnitt nicht.
- **Hauptversions-Sprünge** (z. B. 1.x → 2.0) lassen sich bewusst nicht per
Klick auslösen: Release-Notes lesen, dann auf dem Server `opnexus update 2.0.0`.
Voraussetzungen und Grenzen:
- Nur für **Release-Images** (`OPNEXUS_IMAGE_REPO` gesetzt) und mit aktivem
Release-Feed (`UPDATE_CHECK_URL`). Installationen, die lokal aus dem
Quellcode bauen, aktualisieren weiter mit `git pull && docker compose up -d --build`;
die Einstellungen sagen das an.
- Der Updater arbeitet im Verzeichnis mit `docker-compose.yml` und `.env`
(als `/project` eingehängt, schreibend — er setzt `OPNEXUS_VERSION` und
legt `.opnexus-update-state` ab) und führt dieselbe Logik aus wie
`opnexus update`: Images zuerst ziehen, wechseln, auf „gesund“ warten,
sonst Rollback. Ein Update wechselt die Images, aber **nicht** die
Compose-Datei; braucht eine Version neue Compose-Einträge, steht das in
den Release-Notes und ist dann einmal von Hand zu tun.
- Der Updater erneuert sich nach einem erfolgreichen Update selbst (aus einem
kurzlebigen Hilfs-Container heraus); schlägt das fehl, läuft er weiter und
wird beim nächsten `docker compose up -d` erneuert.
- Läuft kein Updater, ist die Weboberfläche trotzdem voll nutzbar — der
Abschnitt erklärt dann nur, wie man ihn aktiviert.
**Verifikation 1.15.0:** Update, Rollback mit Datenbank-Wiederherstellung und ein kaputtes
Update mit automatischem Rollback liefen über die Oberfläche gegen einen isolierten Stack mit
echten Release-Images und lokaler Registry (inklusive Selbsterneuerung des Updaters und
Neuladen der Seite); Backend-API-Tests, Updater-Tests ohne Docker und die Browser-Prüfung
(1440 px hell/dunkel, 375 px, alle Zustände, axe ohne Verstöße) sind grün.
#### Update und Rollback mit `opnexus`
@@ -3129,10 +3211,83 @@ Vergabe von DHCP-Leases (Kea ist auf den Test-Firewalls deaktiviert).
**Bekannte Grenzen:** Übernommene Regeln werden je Firewall einzeln übernommen. Wer
eine übernommene Regel zusätzlich auf einen weiteren Host als Ziel setzt, legt dort
eine zweite, doppelte Regel an (die Vorschau zeigt „create“). Keine Multi-Host-Vorlagen für Netzwerkobjekte, kein
Rolling-Apply auf CARP-Paaren (die Vorschau weist auf den Partner hin), keine
LAGG/Bridge/GRE/VXLAN-Verwaltung, keine dnsmasq-Bereiche, keine Kea-DDNS-Felder,
Gateway-Gruppen nicht enthalten.
eine zweite, doppelte Regel an (die Vorschau zeigt „create“). Keine Multi-Host-Vorlagen
für Netzwerkobjekte, kein automatisches Rolling-Apply auf CARP-Paaren (nur das
Übertragen auf den Partner per Klick), keine Kea-DDNS-Felder.
### Erweiterung 1.13.0: Geräte, Host-System, Abgleich im Cluster
Alles über dieselbe Engine (Vorschau, Backup, Anwenden, Zurücklesen, Audit, Guard):
- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Ein LAGG oder eine Bridge, die
die Verwaltungs-Schnittstelle als Mitglied enthält, braucht die Bestätigung
(`management_member`). Beim Anlegen einer Bridge sind die Mitglieder Schnittstellen-
Kennungen (opt1, opt2), bei einem LAGG Gerätenamen.
- **Gateways › Gateway-Gruppen**, **DNS › Weiterleitungen** (Unbound-Forwards) und
**DHCP › Bereiche** (dnsmasq).
- **Host-System** (neue Gruppe): **Tunables** (jede Änderung braucht die Bestätigung; die
eingebauten Vorgaben lassen sich nicht bearbeiten, nur per „Überschreiben“ mit einem
eigenen Eintrag übersteuern), **Cron**, **Syslog** (entfernte Log-Ziele) und
**Snapshots** (ZFS: anlegen und löschen; das aktive Boot-Environment lässt sich nicht
löschen, das Aktivieren mit Neustart gibt es bewusst nicht). Snapshots haben eine
eigene, flache API und laufen deshalb über `services/snapshot_service.py`, nicht über
die Engine.
- **Netzwerk › Abgleich:** vergleicht die Knoten jedes Clusters (Schnittstellen,
VLANs, VIPs, Gateways, Routen, DNS, DHCP). Adressen, CARP-Advskew/-Passwort und das
aktive Standard-Gateway sind pro Knoten verschieden und werden nicht verglichen; eine
Kategorie, die ein Knoten nicht liefern kann, wird übersprungen statt als Abweichung
gemeldet. Dazu ein neuer Alarm „Netzwerk-Abweichung im Cluster“
(`network_drift`, Einstellungen › Benachrichtigungen, Migration 0004), der sich nach
der Korrektur selbst auflöst und bei einem nicht erreichbaren Knoten offene Meldungen
stehen lässt.
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts auf einem Cluster-Knoten
bietet der Ergebnis-Schritt „Auf <Partner> übertragen“ an (Dialog mit denselben
Werten, mit eigener Vorschau, eigenem Backup und eigenem Guard). Empfohlene
Reihenfolge: zuerst der Backup-Knoten, dann der Master.
- **Kopfzeile:** Bei acht Gruppen zeigt die Hauptnavigation die Namen ab 1150 px Breite,
darunter nur Icons mit Tooltip, damit Suche und Abmelden in einer Zeile bleiben.
**Verifikation 1.13.0 (live auf test-fw-1/-2/-3):** je anlegen, ändern, in der Liste
sehen und löschen mit Backup, Apply und Rücklesen für Tunable, Cron-Job, Syslog-Ziel,
Gateway-Gruppe, Loopback, GRE, DNS-Forward und dnsmasq-Bereich auf test-fw-3, Bridge
auf test-fw-1 (mit zwei dafür zugewiesenen, unbenutzten VLANs) und LAGG auf test-fw-3;
Snapshots anlegen/löschen auf test-fw-1 und -3 (der aktive wird abgelehnt). Der Abgleich
und sein Alarm wurden mit einem nur auf einem Knoten vorhandenen VLAN geprüft
(Meldung geöffnet, nach dem Löschen aufgelöst). Browser: Übertragen auf den Partner,
Abgleich vor und nach dem Angleichen, Überschreiben und Löschen eines Tunables,
Snapshot anlegen/löschen, alle neuen Seiten in Deutsch und Englisch (Dark Mode),
axe-core über die ganze App ohne Verstöße, Kopfzeile in einer Zeile ab 768 px.
### Erweiterung 1.14.0: Diagnose, VXLAN, GIF
- **Netzwerk › Geräte** kennt jetzt auch **VXLAN** und **GIF** (gleiche Engine wie LAGG, Bridge
und GRE: Vorschau, Backup, Anwenden, Zurücklesen, Audit).
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung (TCP) und DNS-Abfrage, die die
ausgewählte Firewall selbst ausführt (`/api/hosts/{id}/diagnostics/{ping|traceroute|portprobe|dns}`,
`services/diagnostics_service.py`). Sicherheitsmerkmale:
- nur für Admins (alles sind POSTs, die Nur-Lesen-Rolle bekommt 403),
- das Ziel wird vor dem Senden streng geprüft (nur Hostname-/IP-Zeichen, Quelladresse nur als IP,
Anzahl 1–10, Port 1–65535); die Firewall prüft zusätzlich selbst und lehnt Ungültiges mit 422 ab,
- jeder Lauf steht mit Tool und Ziel im Audit-Log (`kind=diagnostic`), Ergebnisse werden nicht gespeichert,
- höchstens vier Abfragen gleichzeitig pro OPNexus-Prozess,
- die Port-Prüfung gibt nie den Banner/Payload der Gegenstelle weiter, nur „offen/nicht erreichbar“,
- ein Ping läuft als kurzer Job und wird **immer** wieder gestoppt und entfernt, auch wenn das Abfragen
der Zwischenstände scheitert (live geprüft: danach keine Jobs auf den Firewalls).
Ein Port-Test auf einen geschlossenen oder blockierten Port dauert bei OPNsense 10 Sekunden; die
Diagnose-Aufrufe haben deshalb ein Zeitlimit von 40 s.
- **Bewusst nicht gebaut: Packet-Capture.** Mitschnitte enthalten Nutzdaten fremder Verbindungen und
Zugangsdaten im Klartext; das gehört nicht in eine Verwaltungsoberfläche ohne eigenes Aufbewahrungs-
und Berechtigungskonzept.
- **Fehlerkorrektur:** Die neue Route `/hostsystem` beginnt mit `/hosts`; die Navigation markierte
dadurch „Übersicht › Hosts“ und der Seitentitel zeigte „Hosts“. Der Pfadvergleich prüft jetzt auf
die ganze Seite oder Unterseiten.
**Verifikation 1.14.0 (live):** VXLAN und GIF auf test-fw-3 (anlegen, ändern, auflisten, löschen, Geräteliste
danach unverändert). Alle vier Diagnose-Werkzeuge auf test-fw-1 und -3 gegen das Gateway im Management-Netz
(Ping 4/4 und 3/3 verloren zu einer nicht erreichbaren Adresse, Traceroute, Port 443 offen und Port 1 nach
10 s „nicht erreichbar“, DNS mit mehreren Record-Typen, ungültige Eingabe → 422). Browser (1440 und 375 px):
alle vier Werkzeuge, VXLAN und GIF über die Geräteseite, Englisch/Dark Mode ohne deutsche Reste,
axe-core über die ganze App ohne Verstöße. 560 Backend-Tests grün.
## Definition of Done — Stand
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+1 -1
View File
@@ -5,7 +5,7 @@ import { api, AuditEvent, AuditPage } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Input, Select } from "@/components/ui/Field";
import { useLanguage } from "@/components/LanguageProvider";
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification"];
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification","system"];
const actionKeys = ["deploy","confirm","rollback","remove","retry-rollback","delete","create","edit","reboot","update"];
const statusKeys = ["requested","accepted","rejected","interrupted","success","pending","error","reverted","revert_failed","removed","submitted","acknowledged","starting","unknown","denied"];
export default function AuditHistory() {
+13 -2
View File
@@ -18,6 +18,7 @@ import {
ShieldAlert,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useLanguage } from "@/components/LanguageProvider";
import { labelFor, type Language } from "@/lib/i18n";
@@ -110,6 +111,7 @@ function certProblemLabel(context: Record<string, unknown>, t: (key: string, par
export default function DashboardPage() {
const { language, t, errorMessage } = useLanguage();
const router = useRouter();
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
@@ -133,6 +135,15 @@ export default function DashboardPage() {
.then(async (data) => {
if (!active) return;
setSummary(data);
// Fresh install: nothing to manage yet and the setup assistant was
// never finished or skipped, so open it instead of an empty dashboard.
if (data.hosts.length === 0) {
Promise.all([api.me(), api.settings()])
.then(([me, prefs]) => {
if (active && me.role !== "read_only" && !prefs.setup_done) router.replace("/setup");
})
.catch(() => {});
}
const reachableHosts = data.hosts.filter((h) => h.status !== "offline" && h.status !== "disabled");
const results = await Promise.all(
reachableHosts.map(async (host) => {
@@ -164,7 +175,7 @@ export default function DashboardPage() {
return () => {
active = false;
};
}, [errorMessage]);
}, [errorMessage, router]);
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
const securityWarningsLoading = problems === null || firmwareWarnings === null;
@@ -212,7 +223,7 @@ export default function DashboardPage() {
{summary === null ? (
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
) : summary.hosts.length === 0 ? (
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link>} />
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<span className="flex flex-wrap justify-center gap-x-4 gap-y-1"><Link href="/setup" className="text-sm font-medium text-sky-700 hover:underline">{t("dashboard.startSetup")}</Link><Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link></span>} />
) : (
<ul className="divide-y divide-slate-100">
{summary.hosts.map((host) => (
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { NetworkChangeModal } from "@/components/NetworkChangeModal";
import { NetworkObjectSection, ObjectChange } from "@/components/NetworkObjectSection";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { FieldGroup, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { api, Host, HostSnapshot, NetworkKind } from "@/lib/api";
import { useReadOnly } from "@/lib/useReadOnly";
import { HostDiagnostics } from "@/components/HostDiagnostics";
import { HardDrive, Plus, Trash2 } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
const HOST_KEY = "opnexus-network-host"; // shared with the network pages: one firewall stays selected
const KIND_BY_TAB: Record<string, NetworkKind> = { tunables: "tunable", cron: "cron_job", syslog: "syslog_destination" };
/** Settings that live on the firewall itself: tunables, cron jobs, remote syslog targets and ZFS snapshots. */
export default function HostSystemPage() {
const { t, errorMessage } = useLanguage();
const { tab } = useParams<{ tab: string }>();
const section = ["tunables", "cron", "syslog", "snapshots", "diagnostics"].includes(tab) ? tab : "tunables";
const readOnly = useReadOnly();
const [hosts, setHosts] = useState<Host[] | null>(null);
const [hostId, setHostId] = useState("");
const [error, setError] = useState<string | null>(null);
const [change, setChange] = useState<ObjectChange | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
api
.listHosts()
.then((list) => {
setHosts(list);
let remembered = "";
try { remembered = sessionStorage.getItem(HOST_KEY) ?? ""; } catch { /* storage may be blocked */ }
setHostId(list.find((h) => h.id === remembered)?.id ?? list[0]?.id ?? "");
})
.catch((e) => setError(errorMessage(e, "network.loadFailed")));
}, [errorMessage]);
if (hosts && hosts.length === 0) {
return (
<div>
<h1 className="mb-6 text-2xl font-semibold text-slate-900">{t(`nav.items.hostsystem-${section}`)}</h1>
<EmptyState icon={HardDrive} title={t("drift.noHosts")} />
</div>
);
}
const host = hosts?.find((h) => h.id === hostId);
const kind = KIND_BY_TAB[section];
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-slate-900">{t(`nav.items.hostsystem-${section}`)}</h1>
{hosts && (
<label className="flex items-center gap-2 text-sm text-slate-600">
{t("network.host")}
<select
value={hostId}
onChange={(e) => {
setHostId(e.target.value);
try { sessionStorage.setItem(HOST_KEY, e.target.value); } catch { /* ignore */ }
}}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm"
>
{hosts.map((h) => (
<option key={h.id} value={h.id}>{h.name}</option>
))}
</select>
</label>
)}
</div>
<p className="mb-4 text-sm text-slate-600">{t(`hostsystem.intro.${section}`)}</p>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
{!hostId && !error && <Skeleton className="h-48 w-full" />}
{hostId && (
<Card className="overflow-x-auto">
{kind ? (
<NetworkObjectSection
hostId={hostId}
kind={kind}
title={t(`nav.items.hostsystem-${section}`)}
reloadKey={reloadKey}
readOnly={readOnly}
onChange={setChange}
/>
) : section === "diagnostics" ? (
<HostDiagnostics hostId={hostId} readOnly={readOnly} />
) : (
<Snapshots hostId={hostId} readOnly={readOnly} />
)}
</Card>
)}
{change && host && (
<NetworkChangeModal
key={`${hostId}:${change.kind}:${change.action}:${change.uuid ?? ""}`}
hostId={hostId}
hostName={host.name}
kind={change.kind}
action={change.action}
uuid={change.uuid}
preset={change.preset}
onClose={() => setChange(null)}
onDone={() => setReloadKey((k) => k + 1)}
/>
)}
</div>
);
}
function Snapshots({ hostId, readOnly }: { hostId: string; readOnly: boolean }) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [data, setData] = useState<{ supported: boolean; rows: HostSnapshot[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
function load() {
setData(null);
api.listSnapshots(hostId).then(setData).catch((e) => setError(errorMessage(e, "network.loadFailed")));
}
useEffect(load, [hostId]); // eslint-disable-line react-hooks/exhaustive-deps
async function create(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setFormError(null);
try {
await api.createSnapshot(hostId, name.trim(), note.trim());
toast.show(t("hostsystem.snapshots.created", { name: name.trim() }), "success");
setShowCreate(false);
setName("");
setNote("");
load();
} catch (err) {
setFormError(errorMessage(err, "hostsystem.snapshots.createFailed"));
} finally {
setBusy(false);
}
}
async function remove(row: HostSnapshot) {
if (!confirm(t("hostsystem.snapshots.deleteConfirm", { name: row.name }))) return;
try {
await api.deleteSnapshot(hostId, row.uuid);
toast.show(t("hostsystem.snapshots.deleted", { name: row.name }), "success");
load();
} catch (err) {
toast.show(errorMessage(err, "hostsystem.snapshots.deleteFailed"), "danger");
}
}
return (
<div>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
<h2 className="text-sm font-semibold text-slate-700">{t("nav.items.hostsystem-snapshots")}</h2>
<Button variant="outline" disabled={readOnly || data?.supported === false} onClick={() => setShowCreate(true)}>
<Plus size={16} />
{t("hostsystem.snapshots.create")}
</Button>
</div>
{error && <p className="px-5 py-3 text-sm text-red-600">{error}</p>}
{!data && !error && <p className="px-5 py-4 text-sm text-slate-500">{t("common.loading")}</p>}
{data && !data.supported && <p className="px-5 py-4 text-sm text-slate-500">{t("hostsystem.snapshots.unsupported")}</p>}
{data && data.supported && (
<Table>
<THead>
<TRow>
<TH>{t("hostsystem.snapshots.name")}</TH>
<TH>{t("hostsystem.snapshots.state")}</TH>
<TH>{t("hostsystem.snapshots.size")}</TH>
<TH>{t("hostsystem.snapshots.createdAt")}</TH>
<TH><span className="sr-only">{t("common.actions")}</span></TH>
</TRow>
</THead>
<TBody>
{data.rows.map((row) => (
<TRow key={row.uuid}>
<TD className="font-mono text-xs">{row.name}</TD>
<TD>{row.active.includes("N") ? t("hostsystem.snapshots.activeNow") : row.active.includes("R") ? t("hostsystem.snapshots.activeNext") : "–"}</TD>
<TD>{row.size || "–"}</TD>
<TD>{row.created || "–"}</TD>
<TD>
<fieldset disabled={readOnly} className="contents">
<div className="flex justify-end">
{(row.active === "-" || row.active === "") && (
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => remove(row)}>
<Trash2 size={14} />
</Button>
)}
</div>
</fieldset>
</TD>
</TRow>
))}
</TBody>
</Table>
)}
{showCreate && (
<Modal title={t("hostsystem.snapshots.create")} onClose={() => setShowCreate(false)}>
<form onSubmit={create} className="space-y-4">
<p className="text-sm text-slate-600">{t("hostsystem.snapshots.createHint")}</p>
<FieldGroup label={t("hostsystem.snapshots.name")} hint={t("hostsystem.snapshots.nameHint")}>
<Input required value={name} onChange={(e) => setName(e.target.value)} pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" autoComplete="off" />
</FieldGroup>
<FieldGroup label={t("hostsystem.snapshots.note")}>
<Input value={note} onChange={(e) => setNote(e.target.value)} maxLength={200} />
</FieldGroup>
{formError && <p role="alert" className="text-sm text-red-600">{formError}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setShowCreate(false)}>{t("common.cancel")}</Button>
<Button type="submit" disabled={busy} loading={busy}>{t("hostsystem.snapshots.create")}</Button>
</div>
</form>
</Modal>
)}
</div>
);
}
+57 -7
View File
@@ -7,6 +7,8 @@ import { EmptyState } from "@/components/ui/EmptyState";
import { Skeleton } from "@/components/ui/Skeleton";
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { NetworkChangeModal } from "@/components/NetworkChangeModal";
import { NetworkCompare } from "@/components/NetworkCompare";
import { NetworkObjectSection } from "@/components/NetworkObjectSection";
import { Button } from "@/components/ui/Button";
import { api, Host, HostNetwork, NetworkAction, NetworkKind } from "@/lib/api";
import { useReadOnly } from "@/lib/useReadOnly";
@@ -14,12 +16,28 @@ import { AlertTriangle, Cable, Pencil, Plus, Trash2 } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
type Section = "interfaces" | "vlans" | "vips" | "gateways" | "routes" | "dns" | "dhcp";
type Change = { kind: NetworkKind; action: NetworkAction; uuid?: string; preset?: Record<string, string> };
type Section = "interfaces" | "vlans" | "vips" | "gateways" | "routes" | "dns" | "dhcp" | "devices" | "compare";
// hostId/hostName are set when the dialog targets another node than the one on screen (transfer to a cluster partner).
type Change = { kind: NetworkKind; action: NetworkAction; uuid?: string; preset?: Record<string, string>; hostId?: string; hostName?: string };
type OpenChange = (change: Change) => void;
const HOST_KEY = "opnexus-network-host";
// Extra generic object lists shown below the main table of a page (and the whole "devices" page).
const EXTRA_SECTIONS: Partial<Record<Section, { kind: NetworkKind; titleKey: string }[]>> = {
gateways: [{ kind: "gateway_group", titleKey: "network.kinds.gateway_group" }],
dns: [{ kind: "dns_forward", titleKey: "network.kinds.dns_forward" }],
dhcp: [{ kind: "dhcp_range", titleKey: "network.kinds.dhcp_range" }],
devices: [
{ kind: "lagg", titleKey: "network.kinds.lagg" },
{ kind: "bridge", titleKey: "network.kinds.bridge" },
{ kind: "loopback", titleKey: "network.kinds.loopback" },
{ kind: "gre", titleKey: "network.kinds.gre" },
{ kind: "gif", titleKey: "network.kinds.gif" },
{ kind: "vxlan", titleKey: "network.kinds.vxlan" },
],
};
const SECTION_KIND: Partial<Record<Section, NetworkKind>> = { interfaces: "interface", vlans: "vlan", vips: "vip", gateways: "gateway", routes: "route", dns: "dns_host" };
function statusTone(status: string): BadgeTone {
@@ -37,7 +55,7 @@ export default function NetworkPage() {
const [error, setError] = useState<string | null>(null);
// The sub-navigation in the header picks the section; the path is the single source of truth.
const { tab } = useParams<{ tab: string }>();
const section: Section = (["interfaces", "vlans", "vips", "gateways", "routes", "dns", "dhcp"].includes(tab) ? tab : "interfaces") as Section;
const section: Section = (["interfaces", "vlans", "vips", "gateways", "routes", "dns", "dhcp", "devices", "compare"].includes(tab) ? tab : "interfaces") as Section;
const [change, setChange] = useState<Change | null>(null);
const readOnly = useReadOnly();
const [reloadKey, setReloadKey] = useState(0);
@@ -57,7 +75,7 @@ export default function NetworkPage() {
}, [errorMessage]);
useEffect(() => {
if (!hostId) return;
if (!hostId || section === "compare") return;
let cancelled = false;
setError(null);
// Switching host clears the view; a reload after a change keeps it so an open dialog is not torn down.
@@ -72,7 +90,23 @@ export default function NetworkPage() {
return () => {
cancelled = true;
};
}, [hostId, errorMessage, reloadKey]);
}, [hostId, errorMessage, reloadKey, section]);
if (section === "compare") {
return (
<div>
<h1 className="mb-6 text-2xl font-semibold text-slate-900">{t("nav.items.network-compare")}</h1>
<NetworkCompare />
</div>
);
}
const modalHostId = change?.hostId ?? hostId;
const modalHost = hosts?.find((h) => h.id === modalHostId);
const current = hosts?.find((h) => h.id === modalHostId);
const peers = current?.cluster_name
? (hosts ?? []).filter((h) => h.cluster_name === current.cluster_name && h.id !== current.id).map((h) => ({ id: h.id, name: h.name }))
: [];
if (hosts && hosts.length === 0) {
return (
@@ -134,13 +168,29 @@ export default function NetworkPage() {
{section === "dns" && <DnsTable network={network} onChange={setChange} />}
{section === "dhcp" && <DhcpSections network={network} onChange={setChange} />}
</fieldset>
{(EXTRA_SECTIONS[section] ?? []).map((extra) => (
<NetworkObjectSection
key={extra.kind}
hostId={hostId}
kind={extra.kind}
title={t(extra.titleKey)}
reloadKey={reloadKey}
readOnly={readOnly}
onChange={setChange}
/>
))}
</Card>
</>
)}
{change && (
<NetworkChangeModal
hostId={hostId}
hostName={hosts?.find((h) => h.id === hostId)?.name ?? ""}
key={`${modalHostId}:${change.kind}:${change.action}:${change.uuid ?? ""}`}
hostId={modalHostId}
hostName={modalHost?.name ?? ""}
peers={peers}
onTransfer={(peer, fields) =>
setChange({ kind: change.kind, action: "add", preset: fields, hostId: peer.id, hostName: peer.name })
}
kind={change.kind}
action={change.action}
uuid={change.uuid}
+22 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { TwoFactorSection } from "@/components/TwoFactorSection";
import { OpnexusUpdateSection } from "@/components/OpnexusUpdateSection";
import { InfoTip, Tooltip } from "@/components/ui/Tooltip";
import { useEffect, useState } from "react";
import { api, AppSettings, LogSettings, NotificationSettings, SmtpSecurity, Theme } from "@/lib/api";
@@ -11,6 +12,21 @@ import { useReadOnly } from "@/lib/useReadOnly";
import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
import { translate } from "@/lib/i18n";
function SetupAssistantSection() {
const { t } = useLanguage();
const readOnly = useReadOnly();
if (readOnly) return null;
return (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-5">
<div>
<h2 className="text-lg font-medium">{t("settings.setup.title")}</h2>
<p className="mt-1 text-sm text-slate-600">{t("settings.setup.intro")}</p>
</div>
<Button href="/setup" variant="outline">{t("settings.setup.open")}</Button>
</section>
);
}
function NotificationsSection() {
const { t, errorMessage } = useLanguage();
const readOnly = useReadOnly();
@@ -42,6 +58,7 @@ function NotificationsSection() {
const [maintenanceUnknown, setMaintenanceUnknown] = useState(true);
const [carpHealth, setCarpHealth] = useState(true);
const [natDrift, setNatDrift] = useState(true);
const [networkDrift, setNetworkDrift] = useState(true);
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
const [pollInterval, setPollInterval] = useState(300);
@@ -63,6 +80,7 @@ function NotificationsSection() {
setMaintenanceUnknown(data.maintenance_unknown_enabled);
setCarpHealth(data.carp_health_enabled);
setNatDrift(data.nat_drift_enabled);
setNetworkDrift(data.network_drift_enabled);
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
setPollInterval(data.poll_interval_seconds);
setWebhookSecret("");
@@ -102,6 +120,7 @@ function NotificationsSection() {
maintenance_unknown_enabled: maintenanceUnknown,
carp_health_enabled: carpHealth,
nat_drift_enabled: natDrift,
network_drift_enabled: networkDrift,
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
poll_interval_seconds: pollInterval,
});
@@ -129,7 +148,7 @@ function NotificationsSection() {
}
return (
<section className="space-y-5 rounded-xl border border-slate-200 bg-white p-5">
<section id="notifications" className="scroll-mt-4 space-y-5 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 text-lg font-medium">{t("settings.notify.title")}<InfoTip text={t("settings.notify.intro", { seconds: pollInterval })} /></h2>
<form onSubmit={save} className="space-y-5">
@@ -232,6 +251,7 @@ function NotificationsSection() {
[t("settings.notify.event.maintenanceUnknown"), maintenanceUnknown, setMaintenanceUnknown],
[t("settings.notify.event.carpHealth"), carpHealth, setCarpHealth],
[t("settings.notify.event.natDrift"), natDrift, setNatDrift],
[t("settings.notify.event.networkDrift"), networkDrift, setNetworkDrift],
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
].map(([label, value, setter]) => (
<label key={label as string} className="flex items-center gap-2 text-sm">
@@ -372,5 +392,5 @@ export default function SettingsPage(){
async function saveTheme(value:Theme){setSaving(true);setError('');setNotice('');const old=theme;setTheme(value);try{setSettings(await api.saveSettings({theme:value,language}));setNotice(t('settings.appearanceSaved'));}catch(e){setTheme(old);setError(errorMessage(e));}finally{setSaving(false);}}
async function saveLanguage(value:"de"|"en"){setSaving(true);setError('');setNotice('');const old=language;setLanguage(value);try{setSettings(await api.saveSettings({theme,language:value}));setNotice(translate(value,'settings.language.saved'));}catch(e){setLanguage(old);setError(errorMessage(e));}finally{setSaving(false);}}
async function changePassword(e:React.FormEvent){e.preventDefault();setError('');setNotice('');if(password!==repeat){setError(t('settings.passwordMismatch'));return;}setSaving(true);try{await api.changePassword(current,password);setCurrent('');setPassword('');setRepeat('');setNotice(t('settings.passwordChanged'));}catch(e){setError(errorMessage(e,'settings.passwordFailed'));}finally{setSaving(false);}}
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{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 className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><TwoFactorSection/><NotificationsSection/><LogIngestSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{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 className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SetupWizard } from "@/components/SetupWizard";
export default function SetupPage() {
return <SetupWizard />;
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { APP_CODENAME, APP_VERSION } from "@/lib/version";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useState } from "react";
import { navGroups, navLabelKey } from "@/components/Nav";
import { matchesNav, navGroups, navLabelKey } from "@/components/Nav";
/**
* Single-role auth guard — there's exactly one protected "area" (everything
@@ -40,7 +40,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
// The app is a client-rendered SPA with one static metadata title; screen reader users rely on
// the title (and its change) to know a navigation happened and where they are. A rendered
// <title> is hoisted into <head> by React 19 and wins over the static metadata one.
const item = navGroups.flatMap((g) => g.items).find((i) => pathname?.startsWith(i.href));
const item = navGroups.flatMap((g) => g.items).find((i) => matchesNav(pathname, i.href));
const pageName = item ? t(navLabelKey(item.href)) : pathname === "/login" ? t("login.signIn") : pathname?.startsWith("/about") ? t("settings.about") : null;
const title = <title>{pageName ? pageName.includes("OPNexus") ? pageName : `${pageName} · OPNexus` : "OPNexus"}</title>;
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { Tabs } from "@/components/ui/Tabs";
import { api, DnsLookupResult, PingResult, PortprobeResult, TracerouteResult } from "@/lib/api";
import { useState } from "react";
type Tool = "ping" | "traceroute" | "portprobe" | "dns";
type Result = { tool: "ping"; data: PingResult } | { tool: "traceroute"; data: TracerouteResult } | { tool: "portprobe"; data: PortprobeResult } | { tool: "dns"; data: DnsLookupResult };
/**
* Network probes run FROM the selected firewall. Results are only shown, never stored; every run is
* recorded in the audit log by the backend (target and tool).
*/
export function HostDiagnostics({ hostId, readOnly }: { hostId: string; readOnly: boolean }) {
const { t, errorMessage } = useLanguage();
const [tool, setTool] = useState<Tool>("ping");
const [target, setTarget] = useState("");
const [ipproto, setIpproto] = useState("inet");
const [source, setSource] = useState("");
const [count, setCount] = useState("4");
const [packetsize, setPacketsize] = useState("");
const [protocol, setProtocol] = useState("icmp");
const [port, setPort] = useState("443");
const [server, setServer] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<Result | null>(null);
async function run(e: React.FormEvent) {
e.preventDefault();
setRunning(true);
setError(null);
setResult(null);
try {
const src = source.trim() || undefined;
if (tool === "ping")
setResult({ tool, data: await api.runPing(hostId, { target: target.trim(), ipproto, count: Number(count), packetsize: packetsize ? Number(packetsize) : null, source: src }) });
else if (tool === "traceroute") setResult({ tool, data: await api.runTraceroute(hostId, { target: target.trim(), ipproto, protocol, source: src }) });
else if (tool === "portprobe") setResult({ tool, data: await api.runPortprobe(hostId, { target: target.trim(), port: Number(port), ipproto, source: src }) });
else setResult({ tool, data: await api.runDnsLookup(hostId, { name: target.trim(), server: server.trim() || undefined }) });
} catch (err) {
setError(errorMessage(err, "hostsystem.diag.failed"));
} finally {
setRunning(false);
}
}
const tabs = (["ping", "traceroute", "portprobe", "dns"] as Tool[]).map((k) => ({ key: k, label: t(`hostsystem.diag.tools.${k}`) }));
return (
<div>
<Tabs items={tabs} active={tool} onChange={(k) => { setTool(k as Tool); setResult(null); setError(null); }} className="overflow-x-auto px-3 [&>button]:whitespace-nowrap" />
<form onSubmit={run} className="space-y-4 p-5">
<fieldset disabled={readOnly} className="contents">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label={tool === "dns" ? t("hostsystem.diag.name") : t("hostsystem.diag.target")}>
<Input required value={target} onChange={(e) => setTarget(e.target.value)} autoComplete="off" placeholder={tool === "dns" ? "example.com" : "192.0.2.1"} />
</FieldGroup>
{tool === "portprobe" && (
<FieldGroup label={t("hostsystem.diag.port")}>
<Input required type="number" min={1} max={65535} value={port} onChange={(e) => setPort(e.target.value)} />
</FieldGroup>
)}
{tool === "ping" && (
<FieldGroup label={t("hostsystem.diag.count")}>
<Input required type="number" min={1} max={10} value={count} onChange={(e) => setCount(e.target.value)} />
</FieldGroup>
)}
{tool === "traceroute" && (
<FieldGroup label={t("hostsystem.diag.protocol")}>
<Select value={protocol} onChange={(e) => setProtocol(e.target.value)}>
<option value="icmp">ICMP</option>
<option value="udp">UDP</option>
</Select>
</FieldGroup>
)}
{tool === "dns" ? (
<FieldGroup label={t("hostsystem.diag.server")} hint={t("hostsystem.diag.serverHint")}>
<Input value={server} onChange={(e) => setServer(e.target.value)} autoComplete="off" />
</FieldGroup>
) : (
<>
<FieldGroup label={t("hostsystem.diag.ipproto")}>
<Select value={ipproto} onChange={(e) => setIpproto(e.target.value)}>
<option value="inet">IPv4</option>
<option value="inet6">IPv6</option>
</Select>
</FieldGroup>
<FieldGroup label={t("hostsystem.diag.source")} hint={t("hostsystem.diag.sourceHint")}>
<Input value={source} onChange={(e) => setSource(e.target.value)} autoComplete="off" />
</FieldGroup>
</>
)}
{tool === "ping" && (
<FieldGroup label={t("hostsystem.diag.packetsize")}>
<Input type="number" min={1} max={9000} value={packetsize} onChange={(e) => setPacketsize(e.target.value)} />
</FieldGroup>
)}
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={running || !target.trim()} loading={running}>
{running ? t("hostsystem.diag.running") : t("hostsystem.diag.run")}
</Button>
<span className="text-xs text-slate-500">{t("hostsystem.diag.auditNote")}</span>
</div>
</fieldset>
</form>
{error && <p role="alert" className="px-5 pb-4 text-sm text-red-600">{error}</p>}
{result && (
<div className="border-t border-slate-200 px-5 py-4" aria-live="polite">
{result.tool === "ping" && <PingView r={result.data} />}
{result.tool === "traceroute" && <TracerouteView r={result.data} />}
{result.tool === "portprobe" && <PortView r={result.data} />}
{result.tool === "dns" && <DnsView r={result.data} />}
</div>
)}
</div>
);
}
function PingView({ r }: { r: PingResult }) {
const { t } = useLanguage();
const ok = r.received > 0;
const ms = (v: number | null) => (v === null || v === undefined ? "–" : `${v} ms`);
return (
<div className="space-y-2 text-sm">
<Badge tone={ok ? "success" : "danger"}>{ok ? t("hostsystem.diag.reachable") : t("hostsystem.diag.unreachable")}</Badge>
<p>{t("hostsystem.diag.pingSummary", { sent: r.sent, received: r.received, loss: r.loss })}</p>
<p className="font-mono text-xs text-slate-600">min {ms(r.min)} · avg {ms(r.avg)} · max {ms(r.max)} · σ {ms(r.stddev)}</p>
{r.error && <p className="text-amber-700">{r.error}</p>}
</div>
);
}
function TracerouteView({ r }: { r: TracerouteResult }) {
const { t } = useLanguage();
return (
<div className="space-y-2">
<p className="font-mono text-xs text-slate-600">{r.notice}</p>
{r.hops.length === 0 ? <p className="text-sm text-slate-500">{t("network.empty")}</p> : (
<Table>
<THead><TRow><TH>{t("hostsystem.diag.hop")}</TH><TH>{t("network.col.address")}</TH><TH>{t("hostsystem.diag.hostname")}</TH><TH>AS</TH><TH>{t("hostsystem.diag.time")}</TH></TRow></THead>
<TBody>
{r.hops.map((h) => (
<TRow key={h.ttl + h.address}><TD>{h.ttl}</TD><TD className="font-mono text-xs">{h.address || "*"}</TD><TD className="font-mono text-xs">{h.host}</TD><TD>{h.as}</TD><TD className="font-mono text-xs">{h.probes}</TD></TRow>
))}
</TBody>
</Table>
)}
</div>
);
}
function PortView({ r }: { r: PortprobeResult }) {
const { t } = useLanguage();
return (
<div className="space-y-2 text-sm">
<Badge tone={r.open ? "success" : "danger"}>{r.open ? t("hostsystem.diag.open", { port: r.port }) : t("hostsystem.diag.closed", { port: r.port })}</Badge>
<p className="font-mono text-xs text-slate-600">{r.message}</p>
</div>
);
}
function DnsView({ r }: { r: DnsLookupResult }) {
const { t } = useLanguage();
const types = Object.keys(r.records);
if (types.length === 0) return <p className="text-sm text-slate-500">{t("hostsystem.diag.noRecords", { name: r.name })}</p>;
return (
<div className="space-y-3">
{types.map((type) => (
<div key={type}>
<p className="text-sm font-medium">{type} <span className="font-normal text-slate-500">· {r.records[type].server} · {r.records[type].query_time}</span></p>
<ul className="font-mono text-xs text-slate-700">
{r.records[type].answers.map((a, i) => (<li key={i}>{a.replace(/\t/g, " ")}</li>))}
</ul>
</div>
))}
</div>
);
}
+40 -7
View File
@@ -8,9 +8,17 @@ import {
FolderTree,
Globe,
GitBranch,
HardDrive,
Activity,
Clock,
Camera,
Sliders,
GitCompare,
Boxes,
KeyRound,
LayoutDashboard,
Layers,
ListChecks,
LogOut,
Network,
RefreshCw,
@@ -74,7 +82,21 @@ export const navGroups: NavGroup[] = [
{ href: "/network/gateways", label: "Gateways", icon: Router, keywords: ["Standard-Gateway", "Uplink"] },
{ href: "/network/routes", label: "Routen", icon: Route, keywords: ["Routing", "Statische Routen"] },
{ href: "/network/dns", label: "DNS", icon: Globe, keywords: ["Unbound", "Host-Override", "Hostnamen"] },
{ href: "/network/dhcp", label: "DHCP", icon: Server, keywords: ["Kea", "dnsmasq", "Reservierung", "Statische Zuweisung", "Subnetz"] },
{ href: "/network/dhcp", label: "DHCP", icon: Server, keywords: ["Kea", "dnsmasq", "Reservierung", "Statische Zuweisung", "Subnetz", "Bereich", "Range"] },
{ href: "/network/devices", label: "Geräte", icon: Boxes, keywords: ["LAGG", "Link Aggregation", "Bridge", "Brücke", "Loopback", "GRE", "Tunnel"] },
{ href: "/network/compare", label: "Abgleich", icon: GitCompare, keywords: ["Vergleich", "Cluster", "HA", "Abweichung", "Drift", "Unterschiede"] },
],
},
{
key: "hostsystem",
label: "Host-System",
icon: HardDrive,
items: [
{ href: "/hostsystem/tunables", label: "Tunables", icon: Sliders, keywords: ["sysctl", "Kernel", "kTLS", "Systemparameter"] },
{ href: "/hostsystem/cron", label: "Cron", icon: Clock, keywords: ["Zeitplan", "Aufgaben", "Jobs", "geplante Aufgaben"] },
{ href: "/hostsystem/syslog", label: "Syslog", icon: ScrollText, keywords: ["Remote-Logging", "Log-Ziel", "Logserver"] },
{ href: "/hostsystem/snapshots", label: "Snapshots", icon: Camera, keywords: ["ZFS", "Boot-Umgebung", "Sicherung vor Änderung"] },
{ href: "/hostsystem/diagnostics", label: "Diagnose", icon: Activity, keywords: ["Ping", "Traceroute", "Port prüfen", "Portprobe", "DNS-Abfrage", "Erreichbarkeit", "Fehlersuche"] },
],
},
{
@@ -141,6 +163,12 @@ export const navGroups: NavGroup[] = [
icon: Users,
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
},
{
href: "/setup",
label: "Einrichtung",
icon: ListChecks,
keywords: ["Assistent", "Wizard", "Setup", "Erste Schritte", "Onboarding", "Firewall verbinden", "API-Key", "Einführung"],
},
],
},
];
@@ -150,13 +178,18 @@ export function navLabelKey(href: string): string {
return `nav.items.${href.slice(1).replace(/\//g, "-")}`;
}
/** True when pathname is the nav item's page or below it ("/hostsystem/x" must NOT match "/hosts"). */
export function matchesNav(pathname: string | null | undefined, href: string): boolean {
return !!pathname && (pathname === href || pathname.startsWith(href + "/"));
}
export function Nav() {
const { t } = useLanguage();
const pathname = usePathname();
const router = useRouter();
const [readOnly, setReadOnly] = useState(false);
const activeGroup = navGroups.find((g) => g.items.some((i) => pathname?.startsWith(i.href))) ?? null;
const activeGroup = navGroups.find((g) => g.items.some((i) => matchesNav(pathname, i.href))) ?? null;
useEffect(() => {
api.me().then((state) => setReadOnly(state.role === "read_only")).catch(() => {});
@@ -169,11 +202,11 @@ export function Nav() {
return (
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-4 gap-y-2 px-3 py-3 sm:px-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3 sm:px-6">
<Link href="/about" title={t("nav.aboutTitle")} className="shrink-0">
<Logo className="h-6 w-auto sm:h-7" />
</Link>
<nav aria-label={t("nav.mainNavigation")} className="order-3 flex w-full min-w-0 gap-0.5 overflow-x-auto sm:order-none sm:w-auto sm:gap-1">
<nav aria-label={t("nav.mainNavigation")} className="order-3 flex w-full min-w-0 gap-0.5 overflow-x-auto sm:order-none sm:w-auto">
{navGroups.map(({ key, icon: Icon, items }) => {
const active = activeGroup?.key === key;
return (
@@ -184,12 +217,12 @@ export function Nav() {
aria-label={t(`nav.groups.${key}`)}
aria-current={active ? "true" : undefined}
className={clsx(
"flex shrink-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors sm:px-3",
"flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors",
active ? "bg-brand-50 text-brand-600" : "text-slate-600 hover:bg-slate-100"
)}
>
<Icon size={16} aria-hidden="true" />
<span className="hidden sm:inline">{t(`nav.groups.${key}`)}</span>
<span className="hidden min-[1150px]:inline">{t(`nav.groups.${key}`)}</span>
</Link>
);
})}
@@ -227,7 +260,7 @@ export function Nav() {
<div className="border-t border-slate-100 bg-slate-50">
<nav aria-label={t("nav.subNavigation")} className="mx-auto flex max-w-6xl items-center gap-0.5 overflow-x-auto px-3 py-1.5 sm:px-6 sm:gap-1">
{activeGroup.items.map(({ href, icon: Icon }) => {
const active = pathname?.startsWith(href);
const active = matchesNav(pathname, href);
return (
<Link
key={href}
+122 -1
View File
@@ -114,6 +114,110 @@ const FIELDS: Record<NetworkKind, FieldDef[]> = {
{ name: "next_server", type: "text" },
{ name: "description", type: "text" },
],
tunable: [
{ name: "tunable", type: "text" },
{ name: "value", type: "text" },
{ name: "descr", type: "text" },
],
cron_job: [
{ name: "enabled", type: "check" },
{ name: "minutes", type: "text" },
{ name: "hours", type: "text" },
{ name: "days", type: "text" },
{ name: "months", type: "text" },
{ name: "weekdays", type: "text" },
{ name: "who", type: "text" },
{ name: "command", type: "select" },
{ name: "parameters", type: "text" },
{ name: "description", type: "text" },
],
syslog_destination: [
{ name: "enabled", type: "check" },
{ name: "transport", type: "select" },
{ name: "hostname", type: "text" },
{ name: "port", type: "text" },
{ name: "program", type: "text" },
{ name: "level", type: "text" },
{ name: "facility", type: "text" },
{ name: "rfc5424", type: "check" },
{ name: "description", type: "text" },
],
gateway_group: [
{ name: "name", type: "text" },
{ name: "item", type: "text" },
{ name: "item2", type: "text" },
{ name: "item3", type: "text" },
{ name: "item4", type: "text" },
{ name: "item5", type: "text" },
{ name: "trigger", type: "select" },
{ name: "poolopts", type: "select" },
{ name: "descr", type: "text" },
],
lagg: [
{ name: "members", type: "text" },
{ name: "proto", type: "select" },
{ name: "primary_member", type: "text" },
{ name: "lagghash", type: "select" },
{ name: "mtu", type: "text" },
{ name: "descr", type: "text" },
],
bridge: [
{ name: "members", type: "text" },
{ name: "proto", type: "select" },
{ name: "enablestp", type: "check" },
{ name: "linklocal", type: "check" },
{ name: "descr", type: "text" },
],
loopback: [
{ name: "deviceId", type: "text" },
{ name: "description", type: "text" },
],
gre: [
{ name: "local-addr", type: "text" },
{ name: "remote-addr", type: "text" },
{ name: "tunnel-local-addr", type: "text" },
{ name: "tunnel-remote-addr", type: "text" },
{ name: "tunnel-remote-net", type: "text" },
{ name: "descr", type: "text" },
],
dns_forward: [
{ name: "enabled", type: "check" },
{ name: "type", type: "select" },
{ name: "domain", type: "text" },
{ name: "server", type: "text" },
{ name: "port", type: "text" },
{ name: "forward_tcp_upstream", type: "check" },
{ name: "forward_first", type: "check" },
{ name: "description", type: "text" },
],
dhcp_range: [
{ name: "interface", type: "select" },
{ name: "start_addr", type: "text" },
{ name: "end_addr", type: "text" },
{ name: "subnet_mask", type: "text" },
{ name: "lease_time", type: "text" },
{ name: "domain_type", type: "select" },
{ name: "domain", type: "text" },
{ name: "description", type: "text" },
],
gif: [
{ name: "local-addr", type: "text" },
{ name: "remote-addr", type: "text" },
{ name: "tunnel-local-addr", type: "text" },
{ name: "tunnel-remote-addr", type: "text" },
{ name: "tunnel-remote-net", type: "text" },
{ name: "descr", type: "text" },
],
vxlan: [
{ name: "deviceId", type: "text" },
{ name: "vxlanid", type: "text" },
{ name: "vxlanlocal", type: "text" },
{ name: "vxlanlocalport", type: "text" },
{ name: "vxlanremote", type: "text" },
{ name: "vxlanremoteport", type: "text" },
{ name: "vxlangroup", type: "text" },
{ name: "vxlandev", type: "select" },
],
route: [
{ name: "network", type: "text" },
{ name: "gateway", type: "select" },
@@ -130,6 +234,9 @@ interface Props {
uuid?: string;
/** Values to start a new object with (e.g. the device being assigned). */
preset?: Record<string, string>;
/** Other nodes of the same cluster; after an add the same object can be created there too. */
peers?: { id: string; name: string }[];
onTransfer?: (peer: { id: string; name: string }, fields: Record<string, string>) => void;
onClose: () => void;
onDone: () => void;
}
@@ -142,7 +249,7 @@ function isVisible(f: FieldDef, values: Record<string, string>): boolean {
return wanted.includes(values[f.when.field] ?? "");
}
export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, preset, onClose, onDone }: Props) {
export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, preset, peers, onTransfer, onClose, onDone }: Props) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [step, setStep] = useState<Step>(action === "del" ? "review" : "form");
@@ -155,6 +262,7 @@ export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, prese
const [acknowledged, setAcknowledged] = useState(false);
const [typedHost, setTypedHost] = useState("");
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
// The form starts from what the firewall itself offers for a new object (defaults + valid choices).
@@ -212,6 +320,7 @@ export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, prese
setError(null);
try {
const fields = action === "del" ? {} : changedFields();
setSent(fields);
const res = await api.executeNetworkChange(hostId, { kind, action, uuid, fields }, plan.token, acknowledged);
setResult(res);
setStep("done");
@@ -363,6 +472,18 @@ export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, prese
</p>
)}
<p className="text-xs text-slate-500">{t("network.change.backupId", { id: result.backup_id })}</p>
{action === "add" && peers && peers.length > 0 && onTransfer && (
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
<p className="text-sm text-slate-600">{t("network.change.transferHint")}</p>
<div className="flex flex-wrap gap-2">
{peers.map((peer) => (
<Button key={peer.id} type="button" variant="outline" onClick={() => onTransfer(peer, sent)}>
{t("network.change.transfer", { host: peer.name })}
</Button>
))}
</div>
</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={onClose}>{t("common.close")}</Button>
</div>
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { Skeleton } from "@/components/ui/Skeleton";
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { api, ClusterNetworkDrift } from "@/lib/api";
import { CheckCircle2, Layers } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
const CATEGORY_TAB: Record<string, string> = {
interfaces: "interfaces", vlans: "vlans", vips: "vips", gateways: "gateways", routes: "routes",
dns_hosts: "dns", dhcp_hosts: "dhcp", kea_subnets: "dhcp", kea_reservations: "dhcp",
};
/** Network configuration differences between the nodes of every HA cluster. */
export function NetworkCompare() {
const { t, errorMessage } = useLanguage();
const [clusters, setClusters] = useState<ClusterNetworkDrift[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.getNetworkDrift()
.then((d) => setClusters(d.clusters))
.catch((e) => setError(errorMessage(e, "network.compare.loadFailed")));
}, [errorMessage]);
if (error) return <p className="text-sm text-red-600">{error}</p>;
if (!clusters) return <Skeleton className="h-48 w-full" />;
if (clusters.length === 0) return <EmptyState icon={Layers} title={t("network.compare.noClusters")} />;
return (
<div className="space-y-6">
<p className="text-sm text-slate-600">{t("network.compare.intro")}</p>
{clusters.map((c) => (
<Card key={c.name}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<h2 className="text-sm font-semibold text-slate-700">
{c.name} <span className="font-normal text-slate-500">({c.hosts.join(", ")})</span>
</h2>
{c.unreachable.length > 0 ? (
<Badge tone="warning">{t("network.compare.unreachable", { hosts: c.unreachable.join(", ") })}</Badge>
) : c.differences.length === 0 ? (
<Badge tone="success">
<CheckCircle2 size={12} aria-hidden /> {t("network.compare.same")}
</Badge>
) : (
<Badge tone="danger">{t("network.compare.count", { count: c.differences.length })}</Badge>
)}
</div>
{c.differences.length > 0 && (
<div className="overflow-x-auto">
<Table>
<THead>
<TRow>
<TH>{t("network.compare.category")}</TH>
<TH>{t("network.compare.object")}</TH>
<TH>{t("network.compare.state")}</TH>
{c.compared.map((h) => (
<TH key={h}>{h}</TH>
))}
</TRow>
</THead>
<TBody>
{c.differences.map((d) => (
<TRow key={`${d.category}:${d.key}`}>
<TD>
<Link className="text-brand-600 hover:underline" href={`/network/${CATEGORY_TAB[d.category] ?? "interfaces"}`}>
{t(`network.compare.categories.${d.category}`)}
</Link>
</TD>
<TD className="font-mono text-xs">{d.key}</TD>
<TD>
<Badge tone={d.status === "missing" ? "danger" : "warning"}>
{d.status === "missing" ? t("network.compare.missing") : t("network.compare.different")}
</Badge>
</TD>
{c.compared.map((h) => (
<TD key={h} className="font-mono text-xs">
{d.signatures[h] === undefined ? <span className="text-red-600">{t("network.compare.absent")}</span> : d.signatures[h] || "✓"}
</TD>
))}
</TRow>
))}
</TBody>
</Table>
</div>
)}
</Card>
))}
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
import { api, NetworkAction, NetworkKind, NetworkObjects } from "@/lib/api";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
export interface ObjectChange {
kind: NetworkKind;
action: NetworkAction;
uuid?: string;
preset?: Record<string, string>;
}
interface Props {
hostId: string;
kind: NetworkKind;
title: string;
reloadKey: number;
readOnly: boolean;
onChange: (change: ObjectChange) => void;
}
const FLAG_COLUMNS = new Set(["enabled"]);
/**
* One table of a generic object kind (tunables, cron jobs, LAGG, ...) with its add/edit/delete
* actions. The rows come from the firewall via the backend's reduced column list.
*/
export function NetworkObjectSection({ hostId, kind, title, reloadKey, readOnly, onChange }: Props) {
const { t, errorMessage } = useLanguage();
const [data, setData] = useState<NetworkObjects | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.listNetworkObjects(hostId, kind)
.then((d) => !cancelled && (setData(d), setError(null)))
.catch((e) => !cancelled && setError(errorMessage(e, "network.loadFailed")));
return () => {
cancelled = true;
};
}, [hostId, kind, reloadKey, errorMessage]);
// Built-in tunables carry a plain name as id (no UUID): the firewall creates an override instead of editing them.
const isBuiltIn = (uuid: string) => kind === "tunable" && !uuid.includes("-");
return (
<div>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
<h2 className="text-sm font-semibold text-slate-700">{title}</h2>
<Button variant="outline" disabled={readOnly || data?.available === false} onClick={() => onChange({ kind, action: "add" })}>
<Plus size={16} />
{t("network.rowAdd")}
</Button>
</div>
{error && <p className="px-5 py-3 text-sm text-red-600">{error}</p>}
{!data && !error && <p className="px-5 py-4 text-sm text-slate-500">{t("common.loading")}</p>}
{data && !data.available && <p className="px-5 py-4 text-sm text-slate-500">{t("network.objectsUnavailable")}</p>}
{data && data.available && data.rows.length === 0 && <p className="px-5 py-4 text-sm text-slate-500">{t("network.empty")}</p>}
{data && data.available && data.rows.length > 0 && (
<div className="overflow-x-auto">
<Table>
<THead>
<TRow>
{data.columns.map((c) => (
<TH key={c}>{t(`network.field.${c}`)}</TH>
))}
<TH>
<span className="sr-only">{t("common.actions")}</span>
</TH>
</TRow>
</THead>
<TBody>
{data.rows.map((row) => (
<TRow key={row.uuid}>
{data.columns.map((c) => (
<TD key={c} className={c === "description" || c === "descr" ? "" : "font-mono text-xs"}>
{FLAG_COLUMNS.has(c) ? (
<Badge tone={row[c] === "1" ? "success" : "neutral"}>{row[c] === "1" ? t("network.active") : t("common.statusDisabled")}</Badge>
) : (
row[c] || "–"
)}
</TD>
))}
<TD>
<fieldset disabled={readOnly} className="contents">
<div className="flex justify-end gap-1">
{isBuiltIn(row.uuid) ? (
<Button
variant="ghost"
onClick={() => onChange({ kind, action: "add", preset: { tunable: row.tunable ?? "", value: row.value ?? "" } })}
>
{t("network.override")}
</Button>
) : (
<>
<Button variant="ghost" aria-label={t("network.rowEdit")} onClick={() => onChange({ kind, action: "set", uuid: row.uuid })}>
<Pencil size={14} />
</Button>
<Button variant="dangerSubtle" aria-label={t("network.rowDelete")} onClick={() => onChange({ kind, action: "del", uuid: row.uuid })}>
<Trash2 size={14} />
</Button>
</>
)}
</div>
</fieldset>
</TD>
</TRow>
))}
</TBody>
</Table>
</div>
)}
</div>
);
}
@@ -0,0 +1,258 @@
"use client";
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, UpdaterStatus } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { useReadOnly } from "@/lib/useReadOnly";
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
const POLL_MS = 2000;
const RELOAD_DELAY_MS = 3500;
/**
* Settings card "OPNexus aktualisieren": zeigt die verfügbare Version und löst
* das Update über den optionalen Updater-Container aus (siehe updater/server.py).
* Das Backend selbst fasst den Host nie an. Während des Updates starten Backend
* und Proxy neu — die Karte fragt weiter nach und übersteht die Lücke.
*/
export function OpnexusUpdateSection() {
const { t, errorMessage, locale } = useLanguage();
const readOnly = useReadOnly();
const [status, setStatus] = useState<UpdaterStatus | null>(null);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState<"update" | "rollback" | null>(null);
const [busy, setBusy] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
// Wurde in dieser Sitzung ein laufender Auftrag gesehen? Nur dann ist ein
// "succeeded" Anlass, die Seite neu zu laden (neue Frontend-Version).
const sawRunning = useRef(false);
const reloadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(async () => {
try {
const next = await api.updaterStatus();
setStatus(next);
setReconnecting(false);
setError("");
if (next.job?.state === "running") sawRunning.current = true;
return next;
} catch (e) {
if (sawRunning.current) {
setReconnecting(true); // Dienste starten gerade neu — weiter versuchen
} else {
setError(errorMessage(e, "common.requestFailed"));
}
return null;
}
}, [errorMessage]);
useEffect(() => {
if (readOnly) return;
void load();
}, [load, readOnly]);
const running = status?.job?.state === "running" || reconnecting;
useEffect(() => {
if (!running) return;
const timer = setInterval(() => void load(), POLL_MS);
return () => clearInterval(timer);
}, [running, load]);
const job = status?.job ?? null;
const finishedNow = !!job && job.state === "succeeded" && sawRunning.current;
useEffect(() => {
if (!finishedNow) return;
reloadTimer.current = setTimeout(() => window.location.reload(), RELOAD_DELAY_MS);
return () => {
if (reloadTimer.current) clearTimeout(reloadTimer.current);
};
}, [finishedNow]);
if (readOnly) return null;
async function start(kind: "update" | "rollback") {
setBusy(true);
setError("");
try {
if (kind === "update" && status?.latest) await api.updaterUpdate(status.latest);
else if (kind === "rollback") await api.updaterRollback();
sawRunning.current = true;
setConfirm(null);
await load();
} catch (e) {
setConfirm(null);
setError(errorMessage(e, "settings.update.startFailed"));
} finally {
setBusy(false);
}
}
const phases = status?.phases ?? [];
const steps = phases.filter((p, i) => p !== phases[i - 1]);
const isRollbackJob = job?.action === "rollback";
const finishedAt = job?.finished_at ? new Date(job.finished_at).toLocaleString(locale) : "";
const releaseUrl = status?.release_url && /^https?:\/\//.test(status.release_url) ? status.release_url : null;
return (
<section id="opnexus-update" className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 text-lg font-medium">
{t("settings.update.title")}
<InfoTip text={t("settings.update.hint")} />
</h2>
{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
{!status && !error && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{status && (
<>
<dl className="grid grid-cols-2 gap-3 text-sm">
<dt>{t("settings.update.installed")}</dt>
<dd>{status.current}</dd>
<dt>{t("settings.update.latest")}</dt>
<dd className="flex flex-wrap items-center gap-2">
{status.latest ?? "—"}
{status.update_available && <Badge tone="info">{t("settings.update.badge")}</Badge>}
{releaseUrl && (
<a href={releaseUrl} target="_blank" rel="noopener noreferrer" className="font-medium text-brand-600 underline">
{t("settings.update.notes")}
</a>
)}
</dd>
</dl>
{!status.feed_enabled && <p className="text-sm text-slate-600">{t("settings.update.feedOff")}</p>}
{status.feed_enabled && status.feed_error && <p className="text-sm text-amber-700">{t("settings.update.feedError")}</p>}
{status.feed_enabled && !status.feed_error && !status.update_available && (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 size={16} aria-hidden="true" /> {t("settings.update.upToDate")}
{status.checked_at && <span className="text-slate-500">· {t("settings.update.checked", { time: new Date(status.checked_at).toLocaleString(locale) })}</span>}
</p>
)}
{!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" ? (
<>
<p className="font-medium">{t("settings.update.notEnabledTitle")}</p>
<p>{t("settings.update.notEnabledBody")}</p>
<p>{t("settings.update.notEnabledStep1")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">COMPOSE_PROFILES=updater</pre>
<p>{t("settings.update.notEnabledStep2")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">docker compose up -d</pre>
<p className="text-slate-500">{t("settings.update.notEnabledCli")}</p>
</>
) : (
<p>{t("settings.update.unreachable")}</p>
)}
</div>
)}
{status.available && status.mode === "local" && (
<p className="text-sm text-slate-600">{t("settings.update.localMode")}</p>
)}
{status.available && status.major_change && status.latest && (
<p className={clsx("flex items-start gap-2 rounded-lg border p-3 text-sm", badgeToneClasses.warning)}>
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
{t("settings.update.majorChange", { latest: status.latest })}
</p>
)}
{running && (
<div role="status" className={clsx("space-y-2 rounded-lg border p-3 text-sm", badgeToneClasses.info)}>
<p className="flex items-center gap-2 font-medium">
<Loader2 size={16} className="animate-spin motion-reduce:animate-none" aria-hidden="true" />
{isRollbackJob ? t("settings.update.rollingBack") : t("settings.update.running", { target: job?.target ?? status.latest ?? "" })}
</p>
{steps.length > 0 && (
<ol className="list-inside list-decimal text-xs">
{steps.map((step) => (
<li key={step}>{t(`settings.update.phase.${step}`)}</li>
))}
</ol>
)}
{reconnecting && <p className="text-xs">{t("settings.update.reconnecting")}</p>}
</div>
)}
{!running && job && job.state === "succeeded" && sawRunning.current && (
<p role="status" className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 size={16} aria-hidden="true" />
{isRollbackJob
? t("settings.update.result.succeededRollback", { version: status.current })
: t("settings.update.result.succeededUpdate", { version: status.current })}
</p>
)}
{!running && job?.state === "rolled_back" && (
<p role="alert" className={clsx("rounded-lg border p-3 text-sm", badgeToneClasses.warning)}>
{t("settings.update.result.rolledBack", { target: job.target ?? "" })}
{finishedAt && <span className="mt-1 block text-xs opacity-80">{t("settings.update.resultTime", { time: finishedAt })}</span>}
</p>
)}
{!running && job?.state === "failed" && (
<p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
{t("settings.update.result.failed")}
{finishedAt && <span className="mt-1 block text-xs opacity-80">{t("settings.update.resultTime", { time: finishedAt })}</span>}
</p>
)}
{status.log.length > 0 && (job?.state !== "succeeded" || running) && (
<details className="text-xs" open={!running && job?.state !== "succeeded"}>
<summary className="cursor-pointer text-slate-600">{t("settings.update.showLog")}</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-slate-50 p-3">{status.log.join("\n")}</pre>
</details>
)}
{status.can_update && status.latest && !running && (
<Button onClick={() => setConfirm("update")} disabled={busy}>
{t("settings.update.button", { latest: status.latest })}
</Button>
)}
{status.available && status.mode === "release" && status.rollback_available && !running && (
<div className="flex flex-wrap items-center gap-3 border-t border-slate-100 pt-3 text-sm">
<Button variant="outline" onClick={() => setConfirm("rollback")} disabled={busy}>
{t("settings.update.rollback.button")}
</Button>
<span className="text-slate-500">{t("settings.update.rollback.hint", { version: status.rollback_version ?? "—" })}</span>
</div>
)}
</>
)}
{confirm === "update" && status?.latest && (
<Modal title={t("settings.update.confirmTitle", { latest: status.latest })} onClose={() => setConfirm(null)}>
<div className="space-y-3 text-sm text-slate-700">
<p>{t("settings.update.confirmBody")}</p>
<p>{t("settings.update.confirmSafety")}</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void start("update")} loading={busy}>{t("settings.update.confirm")}</Button>
</div>
</div>
</Modal>
)}
{confirm === "rollback" && status && (
<Modal title={t("settings.update.rollback.confirmTitle", { version: status.rollback_version ?? "—" })} onClose={() => setConfirm(null)}>
<div className="space-y-3 text-sm text-slate-700">
<p className={status.rollback_loses_data ? clsx("rounded-lg border p-3", badgeToneClasses.warning) : ""}>
{status.rollback_loses_data ? t("settings.update.rollback.dataLoss") : t("settings.update.rollback.noDataLoss")}
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>{t("common.cancel")}</Button>
<Button variant={status.rollback_loses_data ? "danger" : "primary"} onClick={() => void start("rollback")} loading={busy}>
{t("settings.update.rollback.confirm")}
</Button>
</div>
</div>
</Modal>
)}
</section>
);
}
+462
View File
@@ -0,0 +1,462 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { TlsPinControl, TlsPinValue } from "@/components/TlsPinControl";
import { TwoFactorSection } from "@/components/TwoFactorSection";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { FieldGroup, Input } from "@/components/ui/Field";
import { StatusDot } from "@/components/ui/StatusDot";
import { useToast } from "@/components/ui/Toast";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, AppSettings, Host } from "@/lib/api";
import clsx from "clsx";
import { ArrowLeft, ArrowRight, Bell, Check, DownloadCloud, ExternalLink, Shield, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
const STEPS = ["welcome", "account", "firewall", "done"] as const;
type Step = (typeof STEPS)[number];
const emptyHostForm = { name: "", base_url: "", api_key: "", api_secret: "", cluster_name: "" };
const noPin: TlsPinValue = { pem: null, insecure: false, clear: false };
function Stepper({ step, onGo }: { step: number; onGo: (index: number) => void }) {
const { t } = useLanguage();
return (
<nav aria-label={t("setup.stepsLabel")}>
<ol className="flex flex-wrap gap-2">
{STEPS.map((key, index) => {
const current = index === step;
const done = index < step;
return (
<li key={key}>
<button
type="button"
aria-current={current ? "step" : undefined}
onClick={() => onGo(index)}
className={clsx(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500",
current ? "border-brand-500 bg-brand-500 text-white" : done ? "border-slate-300 text-slate-700 hover:bg-slate-100" : "border-slate-200 text-slate-500 hover:bg-slate-100"
)}
>
<span
aria-hidden="true"
className={clsx("flex h-5 w-5 items-center justify-center rounded-full text-xs", current ? "bg-white/25" : done ? "bg-emerald-100 text-emerald-700" : "bg-slate-100")}
>
{done ? <Check size={12} /> : index + 1}
</span>
{t(`setup.steps.${key}`)}
</button>
</li>
);
})}
</ol>
</nav>
);
}
function WelcomeStep({ settings, onSettings }: { settings: AppSettings | null; onSettings: (s: AppSettings) => void }) {
const { t, errorMessage, language, setLanguage } = useLanguage();
const [error, setError] = useState("");
async function pick(value: "de" | "en") {
const previous = language;
setLanguage(value);
setError("");
try {
onSettings(await api.saveSettings({ language: value }));
} catch (e) {
setLanguage(previous);
setError(errorMessage(e, "common.saveFailed"));
}
}
return (
<div className="space-y-5">
<p className="text-sm text-slate-600">{t("setup.welcome.intro")}</p>
<ul className="space-y-2 text-sm text-slate-700">
{(["account", "firewall", "done"] as const).map((key) => (
<li key={key} className="flex items-start gap-2">
<Check size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
<span>{t(`setup.welcome.points.${key}`)}</span>
</li>
))}
</ul>
<p className="flex items-start gap-2 rounded-xl bg-slate-50 p-3 text-sm text-slate-600">
<ShieldCheck size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
{t("setup.welcome.readOnlyNote")}
</p>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-slate-700">{t("settings.language.label")}</legend>
<div role="radiogroup" aria-label={t("settings.language.label")} className="flex flex-wrap gap-3">
{(["de", "en"] as const).map((value) => (
<label
key={value}
className={clsx("flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2.5 text-sm", language === value ? "border-sky-500 bg-sky-50 text-sky-700" : "border-slate-200")}
>
<input type="radio" name="setup-language" checked={language === value} disabled={!settings} onChange={() => void pick(value)} />
{t(`settings.language.${value}`)}
</label>
))}
</div>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
</fieldset>
</div>
);
}
function AccountStep() {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [current, setCurrent] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [busy, setBusy] = useState(false);
const [changed, setChanged] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (password !== repeat) {
setError(t("settings.passwordMismatch"));
return;
}
setBusy(true);
try {
await api.changePassword(current, password);
setCurrent("");
setPassword("");
setRepeat("");
setChanged(true);
toast.show(t("settings.passwordChanged"), "success");
} catch (err) {
setError(errorMessage(err, "settings.passwordFailed"));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<section aria-labelledby="setup-password" className="space-y-3">
<h3 id="setup-password" className="flex items-center gap-2 text-base font-semibold text-slate-900">
{t("setup.account.passwordTitle")}
{changed && <Badge tone="success"><Check size={12} />{t("setup.account.passwordDone")}</Badge>}
</h3>
<p className="text-sm text-slate-600">{t("setup.account.passwordIntro")}</p>
<form onSubmit={submit} className="max-w-md space-y-3">
<FieldGroup label={t("settings.currentPassword")} hint={t("setup.account.currentHint")}>
<Input type="password" autoComplete="current-password" required value={current} onChange={(e) => setCurrent(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("settings.newPassword")} hint={t("setup.account.newHint")}>
<Input type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={(e) => setPassword(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("settings.repeatPassword")}>
<Input type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={(e) => setRepeat(e.target.value)} />
</FieldGroup>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<Button type="submit" variant="secondary" loading={busy}>{t("settings.changePassword")}</Button>
</form>
</section>
<section aria-labelledby="setup-2fa" className="space-y-3">
<h3 id="setup-2fa" className="text-base font-semibold text-slate-900">{t("setup.account.twofaTitle")}</h3>
<p className="text-sm text-slate-600">{t("setup.account.twofaIntro")}</p>
<TwoFactorSection />
</section>
</div>
);
}
function FirewallStep({ hosts, onHostsChanged }: { hosts: Host[] | null; onHostsChanged: () => Promise<void> }) {
const { t, ts, errorMessage } = useLanguage();
const toast = useToast();
const [form, setForm] = useState(emptyHostForm);
const [pin, setPin] = useState<TlsPinValue>(noPin);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [checking, setChecking] = useState<string | null>(null);
const firstHost = !hosts || hosts.length === 0;
async function create(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError("");
try {
const created = await api.createHost({
name: form.name.trim(),
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
api_secret: form.api_secret.trim(),
verify_tls: !pin.insecure,
tls_ca_pem: pin.pem,
enabled: true,
cluster_name: form.cluster_name.trim() || null,
policy_hierarchy_group_id: null,
});
toast.show(t("hosts.created", { name: created.name }), "success");
setForm(emptyHostForm);
setPin(noPin);
await onHostsChanged();
} catch (err) {
setError(errorMessage(err, "hosts.createFailed"));
} finally {
setSaving(false);
}
}
async function recheck(id: string) {
setChecking(id);
try {
await onHostsChanged();
} finally {
setChecking(null);
}
}
return (
<div className="space-y-6">
<p className="text-sm text-slate-600">{t("setup.firewall.intro")}</p>
{hosts && hosts.length > 0 && (
<section aria-labelledby="setup-hosts" className="space-y-2">
<h3 id="setup-hosts" className="text-base font-semibold text-slate-900">{t("setup.firewall.connected")}</h3>
<ul className="divide-y divide-slate-100 rounded-xl border border-slate-200">
{hosts.map((host) => (
<li key={host.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
<Link href={`/hosts/${host.id}`} className="font-medium text-sky-700 hover:underline">{host.name}</Link>
<span className="text-slate-500">{host.base_url}</span>
<span className="ml-auto flex items-center gap-3">
<StatusDot status={host.status} />
{host.status === "offline" && (
<Button type="button" variant="outline" loading={checking === host.id} onClick={() => void recheck(host.id)}>
{t("setup.firewall.recheck")}
</Button>
)}
</span>
{host.status === "offline" && (
<div className="basis-full space-y-0.5 text-xs">
{host.status_detail && <p className="text-slate-500">{ts(host.status_detail).slice(0, 160)}</p>}
<p className="text-flame-700">{t("setup.firewall.offlineHelp")}</p>
</div>
)}
</li>
))}
</ul>
</section>
)}
<section aria-labelledby="setup-key" className="space-y-3 rounded-xl border border-slate-200 p-4">
<h3 id="setup-key" className="text-base font-semibold text-slate-900">{t("setup.firewall.keyTitle")}</h3>
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-slate-700">
{(["menu", "user", "key", "rights"] as const).map((key) => (
<li key={key}>{t(`setup.firewall.keySteps.${key}`)}</li>
))}
</ol>
<p className="flex items-start gap-1.5 text-xs text-slate-500">
<DownloadCloud size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
{t("setup.firewall.keyFileNote")}
</p>
</section>
<form onSubmit={create} className="space-y-4" aria-labelledby="setup-add">
<h3 id="setup-add" className="text-base font-semibold text-slate-900">
{firstHost ? t("setup.firewall.addFirst") : t("setup.firewall.addAnother")}
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup label="Name" hint={t("setup.firewall.nameHint")}>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="fw-primary" />
</FieldGroup>
<FieldGroup label={t("hosts.baseUrl")} hint={t("setup.firewall.urlHint")}>
<Input required value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} placeholder="https://192.168.1.1" />
</FieldGroup>
<FieldGroup label="API-Key">
<Input required autoComplete="off" value={form.api_key} onChange={(e) => setForm({ ...form, api_key: e.target.value })} />
</FieldGroup>
<FieldGroup label="API-Secret">
<Input required type="password" autoComplete="off" value={form.api_secret} onChange={(e) => setForm({ ...form, api_secret: e.target.value })} />
</FieldGroup>
<FieldGroup label={t("hosts.clusterOptional")} hint={t("setup.firewall.clusterHint")}>
<Input value={form.cluster_name} onChange={(e) => setForm({ ...form, cluster_name: e.target.value })} placeholder={t("hosts.clusterPlaceholder")} />
</FieldGroup>
</div>
<TlsPinControl baseUrl={form.base_url} value={pin} onChange={setPin} />
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" loading={saving}>{saving ? t("hosts.saving") : t("setup.firewall.connect")}</Button>
<span className="text-xs text-slate-500">{t("setup.firewall.connectNote")}</span>
</div>
</form>
</div>
);
}
function DoneStep({ hosts, settings }: { hosts: Host[] | null; settings: AppSettings | null }) {
const { t } = useLanguage();
const [twofa, setTwofa] = useState<boolean | null>(null);
useEffect(() => {
api.twoFactorStatus().then((s) => setTwofa(s.enabled)).catch(() => setTwofa(null));
}, []);
const online = hosts?.filter((h) => h.status === "online").length ?? 0;
const total = hosts?.length ?? 0;
const seconds = settings?.deploy_confirm_window_seconds ?? 0;
const next: { href: string; icon: typeof Shield; key: string; params?: Record<string, string | number> }[] = [
{ href: "/rules", icon: Shield, key: "rules", params: { seconds } },
{ href: "/settings#notifications", icon: Bell, key: "notify" },
{ href: "/settings#opnexus-update", icon: DownloadCloud, key: "update" },
];
return (
<div className="space-y-6">
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<Badge tone={total > 0 && online === total ? "success" : total > 0 ? "warning" : "neutral"}>
{total > 0 ? t("setup.done.hostsOnline", { online, total }) : t("setup.done.noHosts")}
</Badge>
</li>
<li className="flex items-center gap-2">
<Badge tone={twofa ? "success" : "neutral"}>{twofa ? t("setup.done.twofaOn") : t("setup.done.twofaOff")}</Badge>
</li>
</ul>
<section aria-labelledby="setup-next" className="space-y-3">
<h3 id="setup-next" className="text-base font-semibold text-slate-900">{t("setup.done.nextTitle")}</h3>
<ul className="grid gap-3 sm:grid-cols-3">
{next.map(({ href, icon: Icon, key, params }) => (
<li key={key}>
<Link href={href} className="flex h-full flex-col gap-1 rounded-xl border border-slate-200 p-4 text-sm transition-colors hover:border-brand-500 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500">
<span className="flex items-center gap-2 font-medium text-slate-900"><Icon size={16} aria-hidden="true" />{t(`setup.done.next.${key}.title`)}</span>
<span className="text-slate-600">{t(`setup.done.next.${key}.text`, params)}</span>
</Link>
</li>
))}
</ul>
<p className="text-sm text-slate-600">
{t("setup.done.backupBefore")} <code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs">./opnexus backup</code>
<InfoTip text={t("setup.done.backupHelp")} size={13} className="ml-1 align-middle" />
</p>
<p className="text-sm text-slate-500">
<a href="https://opnexus.dev/docs/cli/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-sky-700 hover:underline">
{t("setup.done.docs")} <ExternalLink size={13} aria-hidden="true" />
</a>
</p>
</section>
</div>
);
}
export function SetupWizard() {
const { t } = useLanguage();
const router = useRouter();
const [index, setIndex] = useState(0);
const [role, setRole] = useState<string | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [hosts, setHosts] = useState<Host[] | null>(null);
const [finishing, setFinishing] = useState(false);
const [error, setError] = useState("");
const headingRef = useRef<HTMLHeadingElement>(null);
const mounted = useRef(false);
async function loadHosts() {
try {
setHosts(await api.listHosts());
} catch {
setHosts([]);
}
}
useEffect(() => {
api.me().then((s) => setRole(s.role ?? "admin")).catch(() => setRole("admin"));
api.settings().then(setSettings).catch(() => {});
void loadHosts();
}, []);
// Move focus to the step heading on navigation so screen-reader users hear
// where they are; skipped on the very first render.
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
headingRef.current?.focus();
}, [index]);
async function leave(markDone: boolean) {
setFinishing(true);
setError("");
try {
if (markDone && !settings?.setup_done) await api.saveSettings({ setup_done: true });
router.push("/dashboard");
} catch {
setError(t("common.saveFailed"));
setFinishing(false);
}
}
const step: Step = STEPS[index];
const last = index === STEPS.length - 1;
if (role === "read_only") {
return (
<div className="mx-auto max-w-3xl space-y-3">
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
<p className="text-sm text-slate-600">{t("setup.readOnly")}</p>
<Button href="/dashboard" variant="outline">{t("setup.toDashboard")}</Button>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
<p className="mt-1 text-sm text-slate-500">{t("setup.stepOf", { current: index + 1, total: STEPS.length })}</p>
</div>
<Button type="button" variant="ghost" loading={finishing} onClick={() => void leave(true)}>
{t("setup.skip")}
</Button>
</div>
<Stepper step={index} onGo={setIndex} />
<Card className="space-y-5 p-5 sm:p-6">
<div>
<h2 ref={headingRef} tabIndex={-1} className="text-xl font-semibold text-slate-900 outline-none">
{t(`setup.${step}.title`)}
</h2>
</div>
{step === "welcome" && <WelcomeStep settings={settings} onSettings={setSettings} />}
{step === "account" && <AccountStep />}
{step === "firewall" && <FirewallStep hosts={hosts} onHostsChanged={loadHosts} />}
{step === "done" && <DoneStep hosts={hosts} settings={settings} />}
</Card>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="outline" disabled={index === 0} onClick={() => setIndex(index - 1)}>
<ArrowLeft size={16} aria-hidden="true" />
{t("setup.back")}
</Button>
{last ? (
<Button type="button" loading={finishing} onClick={() => void leave(true)}>
{t("setup.finish")}
</Button>
) : (
<Button type="button" onClick={() => setIndex(index + 1)}>
{t("setup.next")}
<ArrowRight size={16} aria-hidden="true" />
</Button>
)}
</div>
</div>
);
}
+4 -1
View File
@@ -65,7 +65,10 @@ export function UpdateBanner() {
<div className="flex-1">
<p className="font-medium">{t("updateBanner.title", { latest: check.latest, current: check.current })}</p>
<p className="text-xs">
{t("updateBanner.how")}
{t("updateBanner.how")}{" "}
<a href="/settings#opnexus-update" className="font-medium underline">
{t("updateBanner.settings")}
</a>
{releaseUrl && (
<>
{" "}
+122 -4
View File
@@ -224,7 +224,72 @@ export interface NetRoute {
disabled: boolean;
}
export type NetworkKind = "vlan" | "vip" | "gateway" | "route" | "interface" | "dns_host" | "dhcp_host" | "kea_subnet" | "kea_reservation";
export type NetworkKind =
| "vlan" | "vip" | "gateway" | "route" | "interface" | "dns_host" | "dhcp_host" | "kea_subnet" | "kea_reservation"
| "tunable" | "cron_job" | "syslog_destination" | "gateway_group" | "lagg" | "bridge" | "loopback" | "gre" | "gif" | "vxlan"
| "dns_forward" | "dhcp_range";
export interface PingResult {
target: string;
sent: number;
received: number;
loss: string;
min: number | null;
avg: number | null;
max: number | null;
stddev: number | null;
error: string | null;
}
export interface TracerouteResult {
target: string;
notice: string;
hops: { ttl: string; address: string; host: string; as: string; probes: string }[];
}
export interface PortprobeResult {
target: string;
port: number;
open: boolean;
message: string;
}
export interface DnsLookupResult {
name: string;
records: Record<string, { answers: string[]; query_time: string; server: string }>;
}
export interface NetworkObjects {
available: boolean;
columns: string[];
rows: Record<string, string>[];
}
export interface NetworkDifference {
category: string;
key: string;
status: "missing" | "different";
present_on: string[];
missing_from: string[];
signatures: Record<string, string>;
}
export interface ClusterNetworkDrift {
name: string;
hosts: string[];
compared: string[];
unreachable: string[];
differences: NetworkDifference[];
}
export interface HostSnapshot {
uuid: string;
name: string;
active: string;
size: string;
created: string;
mountpoint: string;
}
export type NetworkAction = "add" | "set" | "del";
export interface NetworkChangeRequest {
@@ -996,10 +1061,44 @@ export interface VersionCheck {
error: string | null;
}
export interface UpdaterJob {
id: string;
action: "update" | "rollback";
target: string | null;
state: "running" | "succeeded" | "failed" | "rolled_back";
started_at: string;
finished_at: string | null;
exit_code: number | null;
from_version: string | null;
}
export interface UpdaterStatus {
/** false = Updater nicht aktiviert/erreichbar (`reason` sagt warum). */
available: boolean;
reason: "not_enabled" | "unreachable" | "unauthorized" | null;
mode: "release" | "local" | null;
current: string;
latest: string | null;
update_available: boolean;
release_url: string | null;
feed_enabled: boolean;
feed_error: string | null;
checked_at: string | null;
can_update: boolean;
major_change: boolean;
job: UpdaterJob | null;
phases: string[];
log: string[];
services: Record<string, string>;
rollback_available: boolean;
rollback_loses_data: boolean;
rollback_version: string | null;
}
export const api = {
search: (q: string, options?: RequestInit) => request<SearchHit[]>(`/search?${new URLSearchParams({ q })}`, options),
settings: () => request<AppSettings>("/settings"),
saveSettings: (partial:{theme?:Theme;language?:Language}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
saveSettings: (partial:{theme?:Theme;language?:Language;setup_done?:boolean}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
notificationSettings: () => request<NotificationSettings>("/notifications/settings"),
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
logSettings: () => request<LogSettings>("/logs/settings"),
@@ -1028,6 +1127,9 @@ export const api = {
audit: (filters: Record<string,string | number> = {}) => request<AuditPage>(`/audit?${new URLSearchParams(Object.entries(filters).filter(([,v])=>v!=="").map(([k,v])=>[k,String(v)]))}`),
dashboard: () => request<DashboardSummary>("/dashboard"),
versionCheck: () => request<VersionCheck>("/system/version-check"),
updaterStatus: () => request<UpdaterStatus>("/system/updater"),
updaterUpdate: (version: string) => request<{ job: UpdaterJob }>("/system/updater/update", { method: "POST", body: JSON.stringify({ version }) }),
updaterRollback: () => request<{ job: UpdaterJob }>("/system/updater/rollback", { method: "POST" }),
getOutboundMode: (id: string) => request<OutboundMode>(`/outbound-nat/hosts/${id}/mode`),
listPortForwards: () => request<PortForward[]>(`/port-forward`),
createPortForward: (data: PortForwardCreate) => request<PortForward>(`/port-forward`, { method: "POST", body: JSON.stringify(data) }),
@@ -1071,6 +1173,20 @@ export const api = {
deleteHost: (id: string) => request<void>(`/hosts/${id}`, { method: "DELETE" }),
getLiveRules: (id: string) => request<LiveRule[]>(`/hosts/${id}/live-rules`),
getHostNetwork: (id: string) => request<HostNetwork>(`/hosts/${id}/network`),
runPing: (id: string, data: { target: string; ipproto: string; count: number; packetsize?: number | null; source?: string }) =>
request<PingResult>(`/hosts/${id}/diagnostics/ping`, { method: "POST", body: JSON.stringify(data) }),
runTraceroute: (id: string, data: { target: string; ipproto: string; protocol: string; source?: string }) =>
request<TracerouteResult>(`/hosts/${id}/diagnostics/traceroute`, { method: "POST", body: JSON.stringify(data) }),
runPortprobe: (id: string, data: { target: string; port: number; ipproto: string; source?: string }) =>
request<PortprobeResult>(`/hosts/${id}/diagnostics/portprobe`, { method: "POST", body: JSON.stringify(data) }),
runDnsLookup: (id: string, data: { name: string; server?: string }) =>
request<DnsLookupResult>(`/hosts/${id}/diagnostics/dns`, { method: "POST", body: JSON.stringify(data) }),
listNetworkObjects: (id: string, kind: NetworkKind) => request<NetworkObjects>(`/hosts/${id}/network/objects/${kind}`),
getNetworkDrift: () => request<{ clusters: ClusterNetworkDrift[] }>("/network/drift"),
listSnapshots: (id: string) => request<{ supported: boolean; rows: HostSnapshot[] }>(`/hosts/${id}/snapshots`),
createSnapshot: (id: string, name: string, note: string) =>
request<HostSnapshot>(`/hosts/${id}/snapshots`, { method: "POST", body: JSON.stringify({ name, note }) }),
deleteSnapshot: (id: string, uuid: string) => request<void>(`/hosts/${id}/snapshots/${uuid}`, { method: "DELETE" }),
getNetworkItem: (id: string, kind: NetworkKind, uuid: string) => request<Record<string, string>>(`/hosts/${id}/network/item/${kind}/${uuid}`),
getNetworkTemplate: (id: string, kind: NetworkKind) => request<NetworkTemplate>(`/hosts/${id}/network/template/${kind}`),
previewNetworkChange: (id: string, data: NetworkChangeRequest) =>
@@ -1411,7 +1527,7 @@ export interface CarpSwitchState {
export interface CarpSwitchPreview {preview_id:string;expires_at:string;action:"carp_enter"|"carp_leave";state:CarpSwitchState;}
export type Theme="light"|"dark"|"system";
export interface AppSettings {username:string;theme:Theme;language:Language;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
export interface AppSettings {username:string;theme:Theme;language:Language;setup_done:boolean;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
export type SmtpSecurity = "none" | "starttls" | "ssl";
@@ -1434,6 +1550,7 @@ export interface NotificationSettings {
maintenance_unknown_enabled: boolean;
carp_health_enabled: boolean;
nat_drift_enabled: boolean;
network_drift_enabled: boolean;
carp_auto_revert_failed_enabled: boolean;
poll_interval_seconds: number;
updated_at: string;
@@ -1460,6 +1577,7 @@ export interface NotificationSettingsUpdate {
maintenance_unknown_enabled?: boolean;
carp_health_enabled?: boolean;
nat_drift_enabled?: boolean;
network_drift_enabled?: boolean;
carp_auto_revert_failed_enabled?: boolean;
poll_interval_seconds?: number;
}
@@ -1483,7 +1601,7 @@ export interface LogSettingsUpdate {
export type ProblemType =
| "host_offline" | "cert_expiry" | "rule_drift" | "revert_failed" | "maintenance_unknown"
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed";
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift";
export interface CarpEvent {
id: string;
+339 -14
View File
@@ -62,7 +62,8 @@
"maintenanceUnknown": "Wartungsergebnis unklar",
"carpHealth": "CARP-Zustand kritisch (Split-Brain / kein Master)",
"natDrift": "NAT-Drift im Cluster",
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen"
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
"networkDrift": "Netzwerk-Abweichung im Cluster"
},
"pollInterval": "Prüfintervall (Sekunden)",
"pollHint": "Mindestens 60s, um die Firewalls nicht unnötig zu belasten."
@@ -109,7 +110,66 @@
"about": "Über OPNexus",
"version": "Version",
"development": "Entwicklung",
"changelogNote": "Änderungsverlauf und Versionsschema stehen in {file}."
"changelogNote": "Änderungsverlauf und Versionsschema stehen in {file}.",
"update": {
"title": "OPNexus aktualisieren",
"hint": "Lädt die neuen Images, wechselt die Dienste und prüft, ob alles gesund ist. Schlägt das fehl, wird automatisch auf die vorherige Version zurückgerollt.",
"installed": "Installiert",
"latest": "Neueste Version",
"upToDate": "OPNexus ist auf dem neuesten Stand.",
"notes": "Release-Notes",
"checked": "Zuletzt geprüft: {time}",
"feedOff": "Die Versionsprüfung ist aus. Trage UPDATE_CHECK_URL in der .env des Servers ein, damit OPNexus neue Versionen erkennt.",
"feedError": "Der Release-Feed ist gerade nicht erreichbar.",
"notEnabledTitle": "Updates aus dem Webinterface sind nicht aktiviert.",
"notEnabledBody": "Dafür läuft ein eigener kleiner Dienst (Updater) mit Zugriff auf Docker — er ist bewusst optional. Aktivieren:",
"notEnabledStep1": "In der .env auf dem Server eintragen:",
"notEnabledStep2": "Dann ausführen:",
"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.",
"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?",
"confirmBody": "OPNexus lädt die neuen Images und startet sich neu. Die Oberfläche ist dabei etwa ein bis zwei Minuten nicht erreichbar; die verwalteten Firewalls sind nicht betroffen.",
"confirmSafety": "Vor einer Datenbank-Migration wird automatisch ein Backup angelegt. Wird die neue Version nicht gesund, setzt OPNexus selbst auf die vorherige Version zurück.",
"confirm": "Jetzt aktualisieren",
"startFailed": "Update konnte nicht gestartet werden",
"running": "Update auf {target} läuft …",
"rollingBack": "Rollback läuft …",
"reconnecting": "Verbindung wird neu aufgebaut — beim Neustart der Dienste ist das normal.",
"phase": {
"pulling": "Neue Images laden",
"switching": "Dienste wechseln",
"waiting": "Auf gesunde Dienste warten",
"stopping": "Dienste anhalten",
"restoring": "Datenbank zurückspielen",
"rolled_back": "Zurückgerollt",
"done": "Fertig"
},
"result": {
"succeededUpdate": "OPNexus läuft jetzt auf {version}. Die Seite wird neu geladen …",
"succeededRollback": "OPNexus wurde auf {version} zurückgesetzt. Die Seite wird neu geladen …",
"rolledBack": "Das Update auf {target} ist fehlgeschlagen. OPNexus wurde automatisch auf die vorherige Version zurückgesetzt.",
"failed": "Der Vorgang ist fehlgeschlagen. Protokoll und Serverzustand prüfen (auf dem Server: opnexus status)."
},
"showLog": "Protokoll",
"rollback": {
"button": "Auf Vorversion zurücksetzen",
"hint": "Macht das letzte Update rückgängig (zurück auf {version}).",
"confirmTitle": "Auf {version} zurücksetzen?",
"dataLoss": "Das Update hat die Datenbank migriert. Beim Zurücksetzen wird der Stand vor dem Update wiederhergestellt: Änderungen seit dem Update und die Log-Historie gehen verloren.",
"noDataLoss": "Die Datenbank wurde nicht migriert — es gehen keine Daten verloren.",
"confirm": "Zurücksetzen"
},
"badge": "Update verfügbar",
"resultTime": "Ergebnis vom {time}"
},
"setup": {
"title": "Einrichtungsassistent",
"intro": "Führt Schritt für Schritt durch Passwort, Zwei-Faktor-Anmeldung und das Verbinden einer Firewall.",
"open": "Assistent öffnen"
}
},
"nav": {
"aboutTitle": "Über OPNexus — Fleet-Status & Versionen",
@@ -126,7 +186,8 @@
"vpn": "VPN",
"certificates": "Zertifikate",
"monitoring": "Monitoring",
"system": "System"
"system": "System",
"hostsystem": "Host-System"
},
"items": {
"dashboard": "Dashboard",
@@ -152,7 +213,15 @@
"audit": "Audit",
"updates": "Update Center",
"settings": "Einstellungen",
"users": "Benutzer"
"users": "Benutzer",
"network-devices": "Geräte",
"network-compare": "Abgleich",
"hostsystem-tunables": "Tunables",
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnose",
"setup": "Einrichtung"
}
},
"dashboard": {
@@ -164,6 +233,7 @@
"fleetHealth": "Fleet-/CARP-Gesundheit",
"noHosts": "Noch keine Hosts angelegt",
"toHosts": "Zu den Hosts",
"startSetup": "Einrichtungsassistent starten",
"nothingPending": "Nichts offen",
"nothingPendingDescription": "Keine ausstehenden Bestätigungen, Fehler oder unklaren Wartungsvorgänge.",
"securityAndExpiryWarnings": "Sicherheits- & Ablauf-Warnungen",
@@ -500,7 +570,13 @@
"NETWORK_BACKUP_FAILED": "Backup von „{host}“ vor der Änderung fehlgeschlagen – es wurde nichts geändert",
"NETWORK_CHANGE_BLOCKED": "Die Änderung ist nicht möglich ({reasons})",
"NETWORK_FIELD_INVALID": "Ungültiges Feld: {field}",
"NETWORK_ITEM_NOT_FOUND": "Das Objekt existiert auf der Firewall nicht (mehr)"
"NETWORK_ITEM_NOT_FOUND": "Das Objekt existiert auf der Firewall nicht (mehr)",
"UPDATER_UNAVAILABLE": "Der Updater ist nicht aktiviert oder nicht erreichbar.",
"UPDATER_BUSY": "Es läuft bereits ein Update oder Rollback.",
"UPDATER_REJECTED": "Der Updater hat die Anfrage abgelehnt ({reason}).",
"UPDATE_NOT_OFFERED": "Diese Version wird nicht zur Installation angeboten — nur die neueste stabile Version.",
"UPDATE_MAJOR_CHANGE": "Hauptversions-Sprünge bitte bewusst auf dem Server mit „opnexus update“ ausführen.",
"ADMIN_REQUIRED": "Diese Funktion erfordert die Rolle „Admin“."
},
"workflow": {
"attention": "Fehler prüfen",
@@ -552,8 +628,9 @@
"updateBanner": {
"title": "OPNexus {latest} ist verfügbar (installiert: {current}).",
"notes": "Release-Notes",
"how": "Aktualisieren auf dem Server mit „opnexus update“.",
"dismiss": "Hinweis für diese Version ausblenden"
"how": "Aktualisieren in den Einstellungen oder auf dem Server mit „opnexus update“.",
"dismiss": "Hinweis für diese Version ausblenden",
"settings": "Zu den Einstellungen"
},
"acme": {
"externalCaWarning": "Kontaktiert die echte Zertifizierungsstelle (z. B. Let's Encrypt) über das Internet — keine rein lokale Änderung. Zählt gegen deren Rate-Limits (z. B. Ausstellungen pro Domain und Woche). Für Tests eine *_test-CA (z. B. letsencrypt_test) verwenden.",
@@ -1443,7 +1520,8 @@
"wireguard_tunnel": "WireGuard",
"certificate": "Zertifikate",
"acme": "ACME",
"notification": "Benachrichtigungen"
"notification": "Benachrichtigungen",
"system": "OPNexus-System"
},
"action": {
"deploy": "Push",
@@ -1817,7 +1895,19 @@
"dns_host": "DNS-Eintrag",
"dhcp_host": "DHCP-Host (dnsmasq)",
"kea_subnet": "DHCP-Subnetz (Kea)",
"kea_reservation": "DHCP-Reservierung (Kea)"
"kea_reservation": "DHCP-Reservierung (Kea)",
"tunable": "Tunable",
"cron_job": "Cron-Job",
"syslog_destination": "Syslog-Ziel",
"gateway_group": "Gateway-Gruppen",
"lagg": "LAGG",
"bridge": "Bridge",
"loopback": "Loopback",
"gre": "GRE-Tunnel",
"dns_forward": "DNS-Weiterleitungen",
"dhcp_range": "DHCP-Bereiche (dnsmasq)",
"gif": "GIF-Tunnel",
"vxlan": "VXLAN"
},
"field": {
"if": "Eltern-Schnittstelle",
@@ -1894,7 +1984,66 @@
"domain_name_servers": "DNS-Server",
"domain_name": "Domain-Name",
"ntp_servers": "NTP-Server"
}
},
"tunable": "Tunable",
"value": "Wert",
"default_value": "Standardwert",
"minutes": "Minute",
"hours": "Stunde",
"days": "Tag",
"months": "Monat",
"weekdays": "Wochentag",
"who": "Benutzer",
"command": "Befehl",
"parameters": "Parameter",
"transport": "Transport",
"program": "Programme",
"level": "Level",
"facility": "Facility",
"rfc5424": "RFC 5424",
"rfc5424Hint": "RFC-5424-Format verwenden",
"item": "Gateways (Stufe 1)",
"item2": "Gateways (Stufe 2)",
"item3": "Gateways (Stufe 3)",
"item4": "Gateways (Stufe 4)",
"item5": "Gateways (Stufe 5)",
"trigger": "Auslöser",
"poolopts": "Pool-Optionen",
"members": "Mitglieder",
"primary_member": "Primäres Mitglied",
"lacp_fast_timeout": "LACP Fast-Timeout",
"lagghash": "Hash-Verfahren",
"laggif": "Gerät",
"bridgeif": "Gerät",
"linklocal": "Link-Local",
"linklocalHint": "Link-Local-Adresse automatisch vergeben",
"enablestp": "STP",
"enablestpHint": "Spanning Tree aktivieren",
"deviceId": "Geräte-Nr.",
"local-addr": "Lokale Adresse",
"remote-addr": "Gegenstelle",
"tunnel-local-addr": "Tunnel-Adresse lokal",
"tunnel-remote-addr": "Tunnel-Adresse Gegenstelle",
"tunnel-remote-net": "Tunnel-Präfix",
"greif": "Gerät",
"type": "Typ",
"verify": "Zertifikat prüfen",
"forward_tcp_upstream": "TCP zum Upstream",
"forward_tcp_upstreamHint": "Anfragen per TCP weiterleiten",
"forward_first": "Zuerst weiterleiten",
"forward_firstHint": "Erst weiterleiten, danach selbst auflösen",
"start_addr": "Von",
"end_addr": "Bis",
"subnet_mask": "Subnetzmaske",
"domain_type": "Domain-Art",
"vxlanid": "VNI",
"vxlanlocal": "Lokale Adresse",
"vxlanlocalport": "Lokaler Port",
"vxlanremote": "Gegenstelle",
"vxlanremoteport": "Port der Gegenstelle",
"vxlangroup": "Multicast-Gruppe",
"vxlandev": "Gerät",
"gifif": "Gerät"
},
"risks": {
"management_interface": "Diese Änderung betrifft {device}, die Schnittstelle, über die OPNexus die Firewall erreicht. Die Verbindung kann abbrechen.",
@@ -1905,19 +2054,23 @@
"management_gateway": "{name} hängt an der Schnittstelle, über die OPNexus die Firewall erreicht.",
"default_route": "Eine Standardroute ({network}) verändert den Weg für fast den gesamten Verkehr.",
"route_covers_management": "Die Route {network} deckt die Adresse der Firewall ab. Die Antworten an OPNexus können einen anderen Weg nehmen.",
"interface_rules_removed": "Beim Entfernen löscht OPNsense selbst {count} Regel(n), die diese Schnittstelle nutzen: {names}"
"interface_rules_removed": "Beim Entfernen löscht OPNsense selbst {count} Regel(n), die diese Schnittstelle nutzen: {names}",
"management_member": "{device} ist die Schnittstelle, über die OPNexus die Firewall erreicht, und gehört zu diesem Gerät. Die Verbindung kann abbrechen.",
"tunable_change": "Tunables ändern das Verhalten des gesamten Systems; manche greifen erst nach einem Neustart und manche können die Firewall unbrauchbar machen."
},
"warnings": {
"carp_change": "CARP-Änderungen können einen Wechsel zwischen Master und Backup auslösen.",
"cluster_peers": "Diese Firewall gehört zu einem Cluster. Dieselbe Änderung muss ggf. auch auf {hosts} erfolgen. VIPs und Gateways werden nicht automatisch synchronisiert.",
"core_interface": "{name} ist eine Kern-Schnittstelle der Firewall. Änderungen wirken sich auf alle Regeln und Dienste darauf aus."
"core_interface": "{name} ist eine Kern-Schnittstelle der Firewall. Änderungen wirken sich auf alle Regeln und Dienste darauf aus.",
"gateway_group_in_use": "Regeln, die diese Gateway-Gruppe nutzen, verlieren ihr Ziel."
},
"blocks": {
"api_unavailable": "Diese Firewall stellt dafür keine API bereit ({section}).",
"nothing_to_change": "Es gibt nichts zu ändern.",
"interface_api_unavailable": "Diese OPNsense-Version kann Schnittstellen nicht per API konfigurieren (ab ca. 26.7.6).",
"interface_changes_pending": "Auf der Firewall warten bereits vorgemerkte Schnittstellen-Änderungen auf das Anwenden. Zuerst dort prüfen und anwenden oder verwerfen.",
"interface_locked": "Die Schnittstelle ist gesperrt. Zuerst die Sperre aufheben, dann löschen."
"interface_locked": "Die Schnittstelle ist gesperrt. Zuerst die Sperre aufheben, dann löschen.",
"tunable_default_row": "Das ist eine Vorgabe der Firewall. Zum Ändern einen eigenen Eintrag mit „Überschreiben“ anlegen."
},
"change": {
"title": {
@@ -1948,7 +2101,9 @@
"after": "Nachher"
},
"invalidValue": "Ungültiger Wert.",
"unreachable": "Die Änderung wurde angewendet, aber „{host}“ antwortet unter der bisherigen Adresse nicht mehr. Wurde die Verwaltungs-Adresse geändert, die neue Adresse unter Hosts › Bearbeiten eintragen. Das Backup vor der Änderung bleibt gespeichert."
"unreachable": "Die Änderung wurde angewendet, aber „{host}“ antwortet unter der bisherigen Adresse nicht mehr. Wurde die Verwaltungs-Adresse geändert, die neue Adresse unter Hosts › Bearbeiten eintragen. Das Backup vor der Änderung bleibt gespeichert.",
"transferHint": "Dieselbe Änderung auch auf den Partner im Cluster anwenden? Zuerst den Backup-Knoten, dann den Master.",
"transfer": "Auf {host} übertragen"
},
"rowAdd": "Hinzufügen",
"rowEdit": "Bearbeiten",
@@ -1960,6 +2115,176 @@
"dnsmasq": "Hosts (dnsmasq: statisches DHCP und DNS)",
"keaSubnets": "Subnetze (Kea)",
"keaReservations": "Reservierungen (Kea)"
},
"objectsUnavailable": "Diese OPNsense-Version bietet dafür keine API.",
"override": "Überschreiben",
"compare": {
"loadFailed": "Der Abgleich konnte nicht geladen werden.",
"noClusters": "Kein Cluster mit mindestens zwei Hosts vorhanden.",
"intro": "Vergleicht die Netzwerk-Konfiguration der Knoten eines Clusters. Adressen, CARP-Advskew und das aktive Standard-Gateway dürfen sich unterscheiden und werden nicht verglichen.",
"unreachable": "Nicht erreichbar: {hosts}",
"same": "Identisch",
"count": "{count} Abweichung(en)",
"category": "Bereich",
"object": "Objekt",
"state": "Zustand",
"missing": "Fehlt",
"different": "Weicht ab",
"absent": "fehlt",
"categories": {
"interfaces": "Schnittstelle",
"vlans": "VLAN",
"vips": "Virtuelle IP",
"gateways": "Gateway",
"routes": "Route",
"dns_hosts": "DNS-Eintrag",
"dhcp_hosts": "DHCP-Host",
"kea_subnets": "DHCP-Subnetz",
"kea_reservations": "DHCP-Reservierung"
}
}
},
"hostsystem": {
"intro": {
"tunables": "Systemparameter der Firewall (sysctl). Die Werte der Firewall selbst bleiben unberührt, eigene Einträge überschreiben sie.",
"cron": "Zeitgesteuerte Aufgaben, die die Firewall selbst ausführt.",
"syslog": "Entfernte Log-Ziele, an die die Firewall ihre Meldungen schickt.",
"snapshots": "ZFS-Snapshots der Firewall als zusätzliches Sicherheitsnetz vor größeren Änderungen. Aktivieren (mit Neustart) geschieht bewusst nur in OPNsense selbst.",
"diagnostics": "Netzwerk-Abfragen, die die ausgewählte Firewall selbst ausführt (Erreichbarkeit, Wege, Ports, Namensauflösung). Ergebnisse werden nur angezeigt; jeder Lauf steht mit Ziel im Audit-Log."
},
"snapshots": {
"create": "Snapshot anlegen",
"createHint": "Der Snapshot hält den aktuellen Systemzustand fest. Er ersetzt kein Konfigurations-Backup.",
"name": "Name",
"nameHint": "Buchstaben, Ziffern, Punkt, Unterstrich und Bindestrich.",
"note": "Notiz",
"state": "Zustand",
"size": "Größe",
"createdAt": "Erstellt",
"activeNow": "aktiv",
"activeNext": "aktiv nach Neustart",
"unsupported": "Diese Firewall unterstützt keine Snapshots (kein ZFS).",
"created": "Snapshot „{name}“ angelegt.",
"createFailed": "Snapshot konnte nicht angelegt werden.",
"deleted": "Snapshot „{name}“ gelöscht.",
"deleteFailed": "Snapshot konnte nicht gelöscht werden.",
"deleteConfirm": "Snapshot „{name}“ wirklich löschen?"
},
"diag": {
"tools": {
"ping": "Ping",
"traceroute": "Traceroute",
"portprobe": "Port prüfen",
"dns": "DNS-Abfrage"
},
"target": "Ziel (Host oder IP)",
"name": "Name",
"ipproto": "IP-Version",
"source": "Quelladresse (optional)",
"sourceHint": "Eigene Adresse der Firewall, von der aus abgefragt wird. Leer = automatisch.",
"count": "Anzahl (1–10)",
"packetsize": "Paketgröße (optional)",
"protocol": "Protokoll",
"port": "Port",
"server": "DNS-Server (optional)",
"serverHint": "Leer = die Resolver der Firewall.",
"run": "Ausführen",
"running": "Läuft…",
"auditNote": "Wird im Audit-Log festgehalten.",
"failed": "Die Abfrage ist fehlgeschlagen.",
"reachable": "Erreichbar",
"unreachable": "Nicht erreichbar",
"pingSummary": "{sent} gesendet, {received} empfangen, {loss} Verlust",
"hop": "Hop",
"hostname": "Name",
"time": "Zeit",
"open": "Port {port} offen",
"closed": "Port {port} nicht erreichbar",
"noRecords": "Keine Antwort für „{name}“."
}
},
"setup": {
"title": "Einrichtungsassistent",
"skip": "Assistent überspringen",
"stepsLabel": "Einrichtungsschritte",
"stepOf": "Schritt {current} von {total}",
"back": "Zurück",
"next": "Weiter",
"finish": "Einrichtung abschließen",
"readOnly": "Der Assistent richtet Konto und Firewalls ein und ist deshalb Administratoren vorbehalten.",
"toDashboard": "Zum Dashboard",
"steps": {
"welcome": "Willkommen",
"account": "Konto",
"firewall": "Firewall",
"done": "Fertig"
},
"welcome": {
"title": "Willkommen bei OPNexus",
"intro": "OPNexus verwaltet deine OPNsense-Firewalls zentral: Regeln, NAT, VPN, Zertifikate, Updates und HA-Cluster an einem Ort. Dieser Assistent richtet das Nötigste in wenigen Minuten ein. Du kannst ihn jederzeit überspringen und später unter System › Einrichtung wieder öffnen.",
"points": {
"account": "Konto absichern: eigenes Passwort und Zwei-Faktor-Anmeldung.",
"firewall": "Erste Firewall verbinden: API-Key anlegen, Zertifikat prüfen, fertig.",
"done": "Überblick, was als Nächstes sinnvoll ist."
},
"readOnlyNote": "Der Assistent liest nur von deiner Firewall. Geändert wird dort erst, wenn du später selbst etwas ausrollst."
},
"account": {
"title": "Konto absichern",
"passwordTitle": "Eigenes Passwort",
"passwordDone": "Geändert",
"passwordIntro": "Hast du beim Start kein ADMIN_PASSWORD gesetzt, hat OPNexus ein Zufallspasswort erzeugt. Setze jetzt ein eigenes. Dieser Schritt ist optional.",
"currentHint": "Das Passwort, mit dem du dich gerade angemeldet hast. Verloren? Auf dem Server: ./opnexus password",
"newHint": "Mindestens 10 Zeichen. Ein Passwortmanager ist am bequemsten.",
"twofaTitle": "Zwei-Faktor-Anmeldung (empfohlen)",
"twofaIntro": "OPNexus kann Firewalls verändern. Ein zweiter Faktor aus einer Authenticator-App schützt das Konto auch dann, wenn das Passwort bekannt wird."
},
"firewall": {
"title": "Erste Firewall verbinden",
"intro": "OPNexus spricht über die REST-API mit deiner OPNsense. Dafür braucht es einen API-Key. Das Anlegen dauert eine Minute.",
"connected": "Verbundene Firewalls",
"recheck": "Erneut prüfen",
"offlineHelp": "Prüfe Adresse und API-Key und ob die Firewall den Zugriff auf die Weboberfläche von diesem Server aus erlaubt.",
"keyTitle": "So legst du den API-Key in OPNsense an",
"keySteps": {
"menu": "In OPNsense System › Access › Users öffnen.",
"user": "Einen eigenen Benutzer (z. B. „opnexus“) anlegen oder einen vorhandenen bearbeiten.",
"key": "Beim Benutzer unter „API keys“ auf das Plus klicken. Der Browser lädt eine Datei apikey.txt herunter.",
"rights": "Dem Benutzer ausreichende Rechte geben, am einfachsten die Gruppe „admin“. Mit weniger Rechten schlagen einzelne Funktionen fehl."
},
"keyFileNote": "In apikey.txt steht der Key hinter key=, das Secret hinter secret=. Das Secret gibt es nur in dieser Datei.",
"addFirst": "Firewall eintragen",
"addAnother": "Weitere Firewall eintragen",
"nameHint": "Frei wählbar, nur zur Anzeige. Bei einem HA-Paar z. B. fw-a und fw-b.",
"urlHint": "Adresse der OPNsense-Weboberfläche, mit https:// und gegebenenfalls Port, z. B. https://192.168.1.1:8443.",
"clusterHint": "Besteht die Firewall aus einem HA-Paar? Trage beide Knoten als eigene Firewall ein und gib bei beiden denselben Cluster-Namen an.",
"connect": "Verbinden",
"connectNote": "OPNexus prüft sofort, ob die Firewall antwortet."
},
"done": {
"title": "Fertig",
"hostsOnline": "{online} von {total} Firewalls erreichbar",
"noHosts": "Noch keine Firewall verbunden",
"twofaOn": "Zwei-Faktor aktiv",
"twofaOff": "Zwei-Faktor nicht aktiv",
"nextTitle": "Das kannst du als Nächstes tun",
"next": {
"rules": {
"title": "Regeln anlegen",
"text": "Neue Regeln rollst du mit Bestätigung aus: Bestätigst du nicht innerhalb von {seconds} Sekunden, rollt OPNexus automatisch zurück."
},
"notify": {
"title": "Benachrichtigungen",
"text": "Per Webhook oder E-Mail melden lassen, wenn eine Firewall ausfällt oder ein Zertifikat abläuft."
},
"update": {
"title": "Updates",
"text": "Hinweis auf neue OPNexus-Versionen und Updates per Klick einrichten."
}
},
"backupBefore": "Eine Sicherung der OPNexus-Datenbank erstellst du auf dem Server mit",
"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"
}
}
}
+339 -14
View File
@@ -62,7 +62,8 @@
"maintenanceUnknown": "Maintenance outcome unclear",
"carpHealth": "CARP state critical (split-brain / no master)",
"natDrift": "NAT drift in the cluster",
"carpAutoRevertFailed": "CARP auto-revert failed"
"carpAutoRevertFailed": "CARP auto-revert failed",
"networkDrift": "Network differences in the cluster"
},
"pollInterval": "Check interval (seconds)",
"pollHint": "At least 60s, so the firewalls are not loaded unnecessarily."
@@ -109,7 +110,66 @@
"about": "About OPNexus",
"version": "Version",
"development": "Development",
"changelogNote": "The change history and version scheme are in {file}."
"changelogNote": "The change history and version scheme are in {file}.",
"update": {
"title": "Update OPNexus",
"hint": "Pulls the new images, switches the services and checks that everything is healthy. If that fails, OPNexus rolls back to the previous version automatically.",
"installed": "Installed",
"latest": "Latest version",
"upToDate": "OPNexus is up to date.",
"notes": "Release notes",
"checked": "Last checked: {time}",
"feedOff": "The version check is off. Set UPDATE_CHECK_URL in the server's .env so OPNexus can detect new versions.",
"feedError": "The release feed is currently unreachable.",
"notEnabledTitle": "Updates from the web interface are not enabled.",
"notEnabledBody": "They need a small separate service (the updater) with access to Docker — deliberately optional. To enable it:",
"notEnabledStep1": "Add to the .env on the server:",
"notEnabledStep2": "Then run:",
"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.",
"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}?",
"confirmBody": "OPNexus pulls the new images and restarts itself. The interface is unavailable for about one to two minutes; the managed firewalls are not affected.",
"confirmSafety": "A backup is taken automatically before a database migration. If the new version does not become healthy, OPNexus rolls back to the previous version on its own.",
"confirm": "Update now",
"startFailed": "Could not start the update",
"running": "Updating to {target} …",
"rollingBack": "Rolling back …",
"reconnecting": "Reconnecting — this is normal while the services restart.",
"phase": {
"pulling": "Pulling new images",
"switching": "Switching services",
"waiting": "Waiting for healthy services",
"stopping": "Stopping services",
"restoring": "Restoring the database",
"rolled_back": "Rolled back",
"done": "Done"
},
"result": {
"succeededUpdate": "OPNexus is now running {version}. Reloading the page …",
"succeededRollback": "OPNexus was rolled back to {version}. Reloading the page …",
"rolledBack": "The update to {target} failed. OPNexus was rolled back to the previous version automatically.",
"failed": "The operation failed. Check the log and the server state (on the server: opnexus status)."
},
"showLog": "Log",
"rollback": {
"button": "Roll back to previous version",
"hint": "Undoes the last update (back to {version}).",
"confirmTitle": "Roll back to {version}?",
"dataLoss": "The update migrated the database. Rolling back restores the state from before the update: changes made since the update and the log history are lost.",
"noDataLoss": "The database was not migrated — no data is lost.",
"confirm": "Roll back"
},
"badge": "Update available",
"resultTime": "Result from {time}"
},
"setup": {
"title": "Setup assistant",
"intro": "Walks you step by step through password, two-factor sign-in and connecting a firewall.",
"open": "Open assistant"
}
},
"nav": {
"aboutTitle": "About OPNexus — fleet status & versions",
@@ -126,7 +186,8 @@
"vpn": "VPN",
"certificates": "Certificates",
"monitoring": "Monitoring",
"system": "System"
"system": "System",
"hostsystem": "Host system"
},
"items": {
"dashboard": "Dashboard",
@@ -152,7 +213,15 @@
"audit": "Audit",
"updates": "Update Center",
"settings": "Settings",
"users": "Users"
"users": "Users",
"network-devices": "Devices",
"network-compare": "Compare",
"hostsystem-tunables": "Tunables",
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnostics",
"setup": "Setup"
}
},
"dashboard": {
@@ -164,6 +233,7 @@
"fleetHealth": "Fleet/CARP health",
"noHosts": "No hosts yet",
"toHosts": "Go to hosts",
"startSetup": "Start the setup assistant",
"nothingPending": "Nothing pending",
"nothingPendingDescription": "No pending confirmations, errors, or unclear maintenance operations.",
"securityAndExpiryWarnings": "Security & expiry warnings",
@@ -500,7 +570,13 @@
"NETWORK_BACKUP_FAILED": "Backup of “{host}” before the change failed – nothing was changed",
"NETWORK_CHANGE_BLOCKED": "The change is not possible ({reasons})",
"NETWORK_FIELD_INVALID": "Invalid field: {field}",
"NETWORK_ITEM_NOT_FOUND": "The object does not exist on the firewall (any more)"
"NETWORK_ITEM_NOT_FOUND": "The object does not exist on the firewall (any more)",
"UPDATER_UNAVAILABLE": "The updater is not enabled or not reachable.",
"UPDATER_BUSY": "An update or rollback is already running.",
"UPDATER_REJECTED": "The updater rejected the request ({reason}).",
"UPDATE_NOT_OFFERED": "This version is not offered for installation — only the latest stable version.",
"UPDATE_MAJOR_CHANGE": "Please run major version jumps deliberately on the server with “opnexus update”.",
"ADMIN_REQUIRED": "This function requires the “Admin” role."
},
"workflow": {
"attention": "Check errors",
@@ -552,8 +628,9 @@
"updateBanner": {
"title": "OPNexus {latest} is available (installed: {current}).",
"notes": "Release notes",
"how": "Update on the server with “opnexus update”.",
"dismiss": "Hide this notice for this version"
"how": "Update in the settings or on the server with “opnexus update”.",
"dismiss": "Hide this notice for this version",
"settings": "Go to settings"
},
"acme": {
"externalCaWarning": "Contacts the real certificate authority (e.g. Let's Encrypt) over the internet — not a purely local change. Counts against its rate limits (e.g. issuances per domain per week). Use a *_test CA (e.g. letsencrypt_test) for testing.",
@@ -1443,7 +1520,8 @@
"wireguard_tunnel": "WireGuard",
"certificate": "Certificates",
"acme": "ACME",
"notification": "Notifications"
"notification": "Notifications",
"system": "OPNexus system"
},
"action": {
"deploy": "Push",
@@ -1817,7 +1895,19 @@
"dns_host": "DNS entry",
"dhcp_host": "DHCP host (dnsmasq)",
"kea_subnet": "DHCP subnet (Kea)",
"kea_reservation": "DHCP reservation (Kea)"
"kea_reservation": "DHCP reservation (Kea)",
"tunable": "Tunable",
"cron_job": "Cron job",
"syslog_destination": "Syslog target",
"gateway_group": "Gateway groups",
"lagg": "LAGG",
"bridge": "Bridge",
"loopback": "Loopback",
"gre": "GRE tunnel",
"dns_forward": "DNS forwards",
"dhcp_range": "DHCP ranges (dnsmasq)",
"gif": "GIF tunnel",
"vxlan": "VXLAN"
},
"field": {
"if": "Parent interface",
@@ -1894,7 +1984,66 @@
"domain_name_servers": "DNS servers",
"domain_name": "Domain name",
"ntp_servers": "NTP servers"
}
},
"tunable": "Tunable",
"value": "Value",
"default_value": "Default value",
"minutes": "Minute",
"hours": "Hour",
"days": "Day",
"months": "Month",
"weekdays": "Weekday",
"who": "User",
"command": "Command",
"parameters": "Parameters",
"transport": "Transport",
"program": "Programs",
"level": "Level",
"facility": "Facility",
"rfc5424": "RFC 5424",
"rfc5424Hint": "Use RFC 5424 format",
"item": "Gateways (tier 1)",
"item2": "Gateways (tier 2)",
"item3": "Gateways (tier 3)",
"item4": "Gateways (tier 4)",
"item5": "Gateways (tier 5)",
"trigger": "Trigger",
"poolopts": "Pool options",
"members": "Members",
"primary_member": "Primary member",
"lacp_fast_timeout": "LACP fast timeout",
"lagghash": "Hash algorithm",
"laggif": "Device",
"bridgeif": "Device",
"linklocal": "Link-local",
"linklocalHint": "Assign a link-local address automatically",
"enablestp": "STP",
"enablestpHint": "Enable spanning tree",
"deviceId": "Device no.",
"local-addr": "Local address",
"remote-addr": "Remote address",
"tunnel-local-addr": "Tunnel address (local)",
"tunnel-remote-addr": "Tunnel address (remote)",
"tunnel-remote-net": "Tunnel prefix",
"greif": "Device",
"type": "Type",
"verify": "Verify certificate",
"forward_tcp_upstream": "TCP to upstream",
"forward_tcp_upstreamHint": "Forward queries over TCP",
"forward_first": "Forward first",
"forward_firstHint": "Forward first, then resolve locally",
"start_addr": "From",
"end_addr": "To",
"subnet_mask": "Subnet mask",
"domain_type": "Domain type",
"vxlanid": "VNI",
"vxlanlocal": "Local address",
"vxlanlocalport": "Local port",
"vxlanremote": "Remote address",
"vxlanremoteport": "Remote port",
"vxlangroup": "Multicast group",
"vxlandev": "Device",
"gifif": "Device"
},
"risks": {
"management_interface": "This change affects {device}, the interface OPNexus uses to reach the firewall. The connection may drop.",
@@ -1905,19 +2054,23 @@
"management_gateway": "{name} sits on the interface OPNexus uses to reach the firewall.",
"default_route": "A default route ({network}) changes the path for almost all traffic.",
"route_covers_management": "The route {network} covers the firewall's own address. Replies to OPNexus may take a different path.",
"interface_rules_removed": "Removing it makes OPNsense itself delete {count} rule(s) that use this interface: {names}"
"interface_rules_removed": "Removing it makes OPNsense itself delete {count} rule(s) that use this interface: {names}",
"management_member": "{device} is the interface OPNexus uses to reach the firewall, and it is a member of this device. The connection may drop.",
"tunable_change": "Tunables change how the whole system behaves; some only apply after a reboot and some can make the firewall unusable."
},
"warnings": {
"carp_change": "CARP changes can trigger a switch between master and backup.",
"cluster_peers": "This firewall is part of a cluster. The same change may be needed on {hosts} as well. VIPs and gateways are not synchronised automatically.",
"core_interface": "{name} is a core interface of the firewall. Changes affect every rule and service on it."
"core_interface": "{name} is a core interface of the firewall. Changes affect every rule and service on it.",
"gateway_group_in_use": "Rules that use this gateway group lose their target."
},
"blocks": {
"api_unavailable": "This firewall does not offer an API for this ({section}).",
"nothing_to_change": "There is nothing to change.",
"interface_api_unavailable": "This OPNsense version cannot configure interfaces through its API (from about 26.7.6).",
"interface_changes_pending": "Interface changes are already staged on the firewall and waiting to be applied. Check and apply or discard them there first.",
"interface_locked": "The interface is locked. Remove the lock first, then delete."
"interface_locked": "The interface is locked. Remove the lock first, then delete.",
"tunable_default_row": "This is a built-in default of the firewall. To change it, create your own entry with “Override”."
},
"change": {
"title": {
@@ -1948,7 +2101,9 @@
"after": "After"
},
"invalidValue": "Invalid value.",
"unreachable": "The change was applied, but “{host}” no longer answers on its previous address. If the management address was changed, enter the new one under Hosts › Edit. The backup taken before the change is kept."
"unreachable": "The change was applied, but “{host}” no longer answers on its previous address. If the management address was changed, enter the new one under Hosts › Edit. The backup taken before the change is kept.",
"transferHint": "Apply the same change to the partner in the cluster as well? Backup node first, then the master.",
"transfer": "Transfer to {host}"
},
"rowAdd": "Add",
"rowEdit": "Edit",
@@ -1960,6 +2115,176 @@
"dnsmasq": "Hosts (dnsmasq: static DHCP and DNS)",
"keaSubnets": "Subnets (Kea)",
"keaReservations": "Reservations (Kea)"
},
"objectsUnavailable": "This OPNsense version offers no API for this.",
"override": "Override",
"compare": {
"loadFailed": "Could not load the comparison.",
"noClusters": "No cluster with at least two hosts.",
"intro": "Compares the network configuration of a cluster's nodes. Addresses, CARP advskew and the active default gateway may differ and are not compared.",
"unreachable": "Unreachable: {hosts}",
"same": "Identical",
"count": "{count} difference(s)",
"category": "Area",
"object": "Object",
"state": "State",
"missing": "Missing",
"different": "Differs",
"absent": "missing",
"categories": {
"interfaces": "Interface",
"vlans": "VLAN",
"vips": "Virtual IP",
"gateways": "Gateway",
"routes": "Route",
"dns_hosts": "DNS entry",
"dhcp_hosts": "DHCP host",
"kea_subnets": "DHCP subnet",
"kea_reservations": "DHCP reservation"
}
}
},
"hostsystem": {
"intro": {
"tunables": "System parameters of the firewall (sysctl). The firewall's own values stay untouched; your own entries override them.",
"cron": "Scheduled tasks the firewall runs itself.",
"syslog": "Remote log targets the firewall sends its messages to.",
"snapshots": "ZFS snapshots of the firewall as an extra safety net before bigger changes. Activating one (with a reboot) is deliberately left to OPNsense itself.",
"diagnostics": "Network queries the selected firewall runs itself (reachability, paths, ports, name resolution). Results are only shown; every run is recorded with its target in the audit log."
},
"snapshots": {
"create": "Create snapshot",
"createHint": "The snapshot captures the current system state. It does not replace a configuration backup.",
"name": "Name",
"nameHint": "Letters, digits, dot, underscore and hyphen.",
"note": "Note",
"state": "State",
"size": "Size",
"createdAt": "Created",
"activeNow": "active",
"activeNext": "active after reboot",
"unsupported": "This firewall does not support snapshots (no ZFS).",
"created": "Snapshot “{name}” created.",
"createFailed": "Could not create the snapshot.",
"deleted": "Snapshot “{name}” deleted.",
"deleteFailed": "Could not delete the snapshot.",
"deleteConfirm": "Really delete snapshot “{name}”?"
},
"diag": {
"tools": {
"ping": "Ping",
"traceroute": "Traceroute",
"portprobe": "Port check",
"dns": "DNS lookup"
},
"target": "Target (host or IP)",
"name": "Name",
"ipproto": "IP version",
"source": "Source address (optional)",
"sourceHint": "The firewall's own address to query from. Empty = automatic.",
"count": "Count (1–10)",
"packetsize": "Packet size (optional)",
"protocol": "Protocol",
"port": "Port",
"server": "DNS server (optional)",
"serverHint": "Empty = the firewall's resolvers.",
"run": "Run",
"running": "Running…",
"auditNote": "Recorded in the audit log.",
"failed": "The query failed.",
"reachable": "Reachable",
"unreachable": "Unreachable",
"pingSummary": "{sent} sent, {received} received, {loss} loss",
"hop": "Hop",
"hostname": "Name",
"time": "Time",
"open": "Port {port} open",
"closed": "Port {port} not reachable",
"noRecords": "No answer for “{name}”."
}
},
"setup": {
"title": "Setup assistant",
"skip": "Skip assistant",
"stepsLabel": "Setup steps",
"stepOf": "Step {current} of {total}",
"back": "Back",
"next": "Next",
"finish": "Finish setup",
"readOnly": "The assistant sets up the account and firewalls, so it is reserved for administrators.",
"toDashboard": "Go to dashboard",
"steps": {
"welcome": "Welcome",
"account": "Account",
"firewall": "Firewall",
"done": "Done"
},
"welcome": {
"title": "Welcome to OPNexus",
"intro": "OPNexus manages your OPNsense firewalls in one place: rules, NAT, VPN, certificates, updates and HA clusters. This assistant sets up the essentials in a few minutes. You can skip it at any time and reopen it later under System › Setup.",
"points": {
"account": "Secure the account: your own password and two-factor sign-in.",
"firewall": "Connect your first firewall: create an API key, check the certificate, done.",
"done": "An overview of what makes sense next."
},
"readOnlyNote": "The assistant only reads from your firewall. Nothing there changes until you roll something out yourself later."
},
"account": {
"title": "Secure the account",
"passwordTitle": "Your own password",
"passwordDone": "Changed",
"passwordIntro": "If you did not set ADMIN_PASSWORD at startup, OPNexus generated a random password. Set your own now. This step is optional.",
"currentHint": "The password you just signed in with. Lost it? On the server: ./opnexus password",
"newHint": "At least 10 characters. A password manager is the most convenient way.",
"twofaTitle": "Two-factor sign-in (recommended)",
"twofaIntro": "OPNexus can change firewalls. A second factor from an authenticator app protects the account even if the password becomes known."
},
"firewall": {
"title": "Connect your first firewall",
"intro": "OPNexus talks to your OPNsense through its REST API, which needs an API key. Creating one takes a minute.",
"connected": "Connected firewalls",
"recheck": "Check again",
"offlineHelp": "Check the address and API key, and that the firewall allows web interface access from this server.",
"keyTitle": "How to create the API key in OPNsense",
"keySteps": {
"menu": "In OPNsense open System › Access › Users.",
"user": "Create a dedicated user (e.g. “opnexus”) or edit an existing one.",
"key": "Under “API keys” on that user click the plus. Your browser downloads a file called apikey.txt.",
"rights": "Give the user sufficient privileges, most simply the “admin” group. With fewer privileges individual features fail."
},
"keyFileNote": "In apikey.txt the key follows key= and the secret follows secret=. The secret exists only in this file.",
"addFirst": "Add a firewall",
"addAnother": "Add another firewall",
"nameHint": "Free choice, display only. For an HA pair e.g. fw-a and fw-b.",
"urlHint": "Address of the OPNsense web interface, with https:// and the port if needed, e.g. https://192.168.1.1:8443.",
"clusterHint": "Is the firewall an HA pair? Add both nodes as separate firewalls and give both the same cluster name.",
"connect": "Connect",
"connectNote": "OPNexus checks right away whether the firewall answers."
},
"done": {
"title": "Done",
"hostsOnline": "{online} of {total} firewalls reachable",
"noHosts": "No firewall connected yet",
"twofaOn": "Two-factor on",
"twofaOff": "Two-factor off",
"nextTitle": "What you can do next",
"next": {
"rules": {
"title": "Create rules",
"text": "New rules are rolled out with a confirmation: if you do not confirm within {seconds} seconds, OPNexus rolls back automatically."
},
"notify": {
"title": "Notifications",
"text": "Get told by webhook or email when a firewall goes down or a certificate expires."
},
"update": {
"title": "Updates",
"text": "Set up the notice about new OPNexus versions and one-click updates."
}
},
"backupBefore": "You back up the OPNexus database on the server with",
"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"
}
}
}
+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.12.0";
export const APP_CODENAME = "Condor";
export const APP_VERSION = "1.16.0";
export const APP_CODENAME = "Hobby";
export const APP_MAINTAINER = "Julian Boldt";
+3 -3
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Baut die drei Release-Images (backend, frontend, proxy) aus dem Stand eines
# Baut die Release-Images (backend, frontend, proxy, updater) aus dem Stand eines
# Tags und pusht sie optional — für den Fall, dass (noch) kein CI-Runner
# existiert. Tags und Namen entsprechen .github/workflows/release.yml:
# <REPO>/opnexus-{backend,frontend,proxy}:X.Y.Z, :X.Y, :X (+ :latest bei stabilen Versionen)
# <REPO>/opnexus-{backend,frontend,proxy,updater}:X.Y.Z, :X.Y, :X (+ :latest bei stabilen Versionen)
#
# scripts/build_release_images.sh v1.10.0 # nur bauen (Architektur dieses Hosts)
# docker login git.opnexus.dev
@@ -35,7 +35,7 @@ if [[ "$version" != *-* ]]; then # nur stabile Versionen bekommen Kurz-Tags un
tags+=("$major.$minor" "$major" latest)
fi
for svc in backend frontend proxy; do
for svc in backend frontend proxy updater; do
image="$repo/opnexus-$svc"
targs=(); for t in "${tags[@]}"; do targs+=(-t "$image:$t"); done
echo "==> $image (${tags[*]})"
+7 -1
View File
@@ -38,11 +38,17 @@ OPNexus $version
1. cp .env.example .env und POSTGRES_PASSWORD (und was sonst nötig ist) eintragen
2. docker compose pull && docker compose up -d --no-build
3. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
3. Anmelden: ./opnexus password (zeigt das Admin-Passwort, wenn ADMIN_PASSWORD leer war)
dann im Browser öffnen — der Einrichtungsassistent führt durch den Rest
4. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
./opnexus update 1.10.1 (feste Version)
Zurück: ./opnexus rollback
Sichern: ./opnexus backup
Optional: Updates per Klick im Webinterface (Einstellungen › "OPNexus aktualisieren"):
in .env COMPOSE_PROFILES=updater und UPDATE_CHECK_URL setzen, dann
docker compose up -d --no-build (Hinweise zur Sicherheit: Handbuch)
Vollständige Anleitung: README.md im Repository, Abschnitt "Release-Images & CI".
TXT
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env bash
# opnexus — Update, Rollback und Backup für eine OPNexus-Installation aus
# fertigen Release-Images (siehe README, "Release-Images & CI").
#
# opnexus status Version, Container, Datenbank-Revision
# opnexus update [VERSION] Neue Images ziehen, wechseln, auf "gesund" warten;
# bei Fehler automatisch zurückrollen
# opnexus rollback Letztes Update rückgängig machen
# opnexus backup Sofort-Backup der OPNexus-Datenbank
#
# Läuft im Verzeichnis mit docker-compose.yml und .env (oder OPNEXUS_DIR=...).
# Braucht: docker (compose v2), bash. OPNEXUS_IMAGE_REPO muss in .env stehen.
set -euo pipefail
here="$(dirname "$(readlink -f "$0")")"
# Im Release-Bundle liegt das Skript neben docker-compose.yml, im Repo in scripts/.
if [ -f "$here/docker-compose.yml" ]; then default_dir="$here"; else default_dir="$here/.."; fi
cd "${OPNEXUS_DIR:-$default_dir}"
ENV_FILE=".env"
STATE_FILE=".opnexus-update-state"
SERVICES=(backend frontend proxy)
TIMEOUT="${OPNEXUS_UPDATE_TIMEOUT:-240}"
c() { docker compose "$@" 2> >(grep -v 'level=warning' >&2); }
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
die() { printf '\033[31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" "$ENV_FILE" 2>/dev/null | tail -n1; }
DB_USER="$(env_get POSTGRES_USER)"; DB_USER="${DB_USER:-opnexus}"
DB_NAME="$(env_get POSTGRES_DB)"; DB_NAME="${DB_NAME:-opnexus}"
env_set() {
if grep -q "^$1=" "$ENV_FILE" 2>/dev/null; then
sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"
else
printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"
fi
}
# Postgres-Client-Tools aus dem Backend-Image (Client 17): Die Pre-Upgrade-
# Dumps schreibt das Backend mit pg_dump 17 — das kann pg_restore 16 (Sidecar)
# NICHT lesen. Also Dump UND Restore immer mit diesen Tools.
pgtool() {
local tool="$1"; shift
# Passwort über die Umgebung durchreichen (`-e PGPASSWORD` ohne Wert), nicht als
# Argument — sonst stünde es in der Prozessliste.
PGPASSWORD="$(env_get POSTGRES_PASSWORD)" c run --rm --no-deps -T -e PGPASSWORD --entrypoint "$tool" backend "$@"
}
psql_db() { c exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -Atq "$@"; }
db_revision() {
psql_db -c "SELECT version_num FROM alembic_version" 2>/dev/null || true
}
running_version() {
c exec -T backend python -c 'from app.version import APP_VERSION; print(APP_VERSION)' 2>/dev/null || true
}
service_state() { # healthy | unhealthy | starting | running | (leer)
local h
h=$(c ps "$1" --format '{{.Health}}' 2>/dev/null || true)
if [ -n "$h" ]; then echo "$h"; else c ps "$1" --format '{{.State}}' 2>/dev/null || true; fi
}
wait_healthy() {
local deadline=$((SECONDS + TIMEOUT)) all svc st
while [ "$SECONDS" -lt "$deadline" ]; do
all=1
for svc in "${SERVICES[@]}"; do
st=$(service_state "$svc")
case "$st" in healthy|running) ;; *) all=0 ;; esac
done
[ "$all" = 1 ] && return 0
sleep 3
done
return 1
}
require_release_mode() {
[ -f docker-compose.yml ] || die "docker-compose.yml nicht gefunden (OPNEXUS_DIR setzen?)"
[ -f "$ENV_FILE" ] || die "$ENV_FILE nicht gefunden"
[ -n "$(env_get OPNEXUS_IMAGE_REPO)" ] || die "OPNEXUS_IMAGE_REPO ist in $ENV_FILE nicht gesetzt — dieses Kommando arbeitet mit fertigen Release-Images (lokale Builds: git pull && docker compose up -d --build)."
}
cmd_status() {
require_release_mode
echo "Image-Repo: $(env_get OPNEXUS_IMAGE_REPO)"
echo "OPNEXUS_VERSION: $(env_get OPNEXUS_VERSION)"
echo "Laufende Version: $(running_version)"
echo "DB-Revision: $(db_revision)"
for svc in "${SERVICES[@]}"; do echo "$svc: $(service_state "$svc")"; done
[ -f "$STATE_FILE" ] && { echo; echo "Letztes Update (für 'rollback'):"; sed 's/^/ /' "$STATE_FILE"; }
return 0
}
cmd_backup() {
local name="manual-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$name"
say "Backup geschrieben: /backups/$name (Volume postgres_backups)"
}
cmd_update() {
require_release_mode
local target="${1:-$(env_get OPNEXUS_VERSION)}"
target="${target:-latest}"
# Landet in .env und in Image-Tags: nur Zeichen, die ein Tag enthalten darf.
[[ "$target" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die "Ungültige Version '$target' (erlaubt: Buchstaben, Ziffern, . _ -)."
local prev_setting prev_version prev_rev started
prev_setting=$(env_get OPNEXUS_VERSION)
prev_version=$(running_version)
[ -n "$prev_version" ] || die "Backend läuft nicht oder antwortet nicht — erst reparieren, dann updaten."
prev_rev=$(db_revision)
say "Aktuell: $prev_version (DB-Revision ${prev_rev:-keine}) — Ziel: $target"
say "Ziehe neue Images (am laufenden System ändert sich dabei nichts) …"
OPNEXUS_VERSION="$target" c pull "${SERVICES[@]}" || die "Pull fehlgeschlagen — nichts wurde verändert."
started=$(date -u +%Y%m%dT%H%M%SZ)
cat > "$STATE_FILE" <<STATE
started=$started
prev_version=$prev_version
prev_setting=$prev_setting
prev_db_revision=$prev_rev
target=$target
STATE
env_set OPNEXUS_VERSION "$target"
say "Wechsle auf $target (das Backend sichert die Datenbank selbst vor einer Migration) …"
# `up` selbst scheitert, sobald das Backend nicht gesund wird (depends_on) —
# das ist der Fehlerfall, nicht das Ende des Skripts.
if c up -d --no-build && wait_healthy; then
say "Fertig: läuft jetzt auf $(running_version) (DB-Revision $(db_revision))."
return 0
fi
printf '\033[31mUpdate nicht gesund geworden.\033[0m Backend-Log:\n' >&2
c logs --tail 25 backend >&2 || true
if [ "${OPNEXUS_NO_AUTO_ROLLBACK:-}" = 1 ]; then
die "Automatisches Zurückrollen ist aus. Manuell: opnexus rollback"
fi
say "Rolle automatisch zurück …"
cmd_rollback
die "Update auf $target fehlgeschlagen und zurückgerollt auf $prev_version."
}
latest_pre_upgrade_dump() {
c exec -T postgres-backup sh -c 'ls -1t /backups/pre-upgrade-*.dump 2>/dev/null | head -n1' | tr -d '\r'
}
cmd_rollback() {
require_release_mode
[ -f "$STATE_FILE" ] || die "Kein Update-Zustand ($STATE_FILE) — es gibt nichts zurückzurollen."
# shellcheck disable=SC1090
. "./$STATE_FILE"
[ -n "${prev_version:-}" ] || die "$STATE_FILE ist unvollständig."
# Rollback target = the exact image tag that was configured before the update.
# The version the backend REPORTS only matches the tag for final releases: an
# rc image reports its final version (rc1 images say 1.11.0), so rolling back
# to that would land on a different, newer image. Floating tags ("latest",
# "1.11") cannot be restored by name, so those fall back to the reported version.
if [[ "${prev_setting:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
prev_version="$prev_setting"
fi
say "Rolle zurück auf $prev_version …"
c stop "${SERVICES[@]}"
local now_rev dump
now_rev=$(db_revision)
if [ "$now_rev" != "$prev_db_revision" ]; then
dump=$(latest_pre_upgrade_dump)
[ -n "$dump" ] || die "Datenbank wurde migriert ($prev_db_revision → $now_rev), aber es gibt kein pre-upgrade-Backup. Backend bleibt gestoppt."
# Nur ein Backup, das nach dem Start DIESES Updates entstanden ist, darf
# zurückgespielt werden — ein älteres würde stillschweigend Daten verwerfen.
dump_ts=$(echo "$dump" | sed -E 's/.*-([0-9]{8})-([0-9]{6})\.dump$/\1\2/')
[ "$dump_ts" -ge "${started//[-:TZ]/}" ] 2>/dev/null \
|| die "Neuestes pre-upgrade-Backup ($dump) ist älter als dieses Update (SKIP_MIGRATION_BACKUP gesetzt?). Backend bleibt gestoppt — manuell eingreifen."
say "Datenbank wurde migriert ($prev_db_revision → $now_rev): spiele $dump zurück."
say "Hinweis: Log-Historie und Ressourcen-Messwerte sind im Backup nicht enthalten und danach leer; alles andere steht auf dem Stand vor dem Update."
# Erst prüfen, dass das Backup lesbar ist — BEVOR irgendetwas gelöscht wird.
pgtool pg_restore --list "$dump" > /dev/null \
|| die "Backup $dump ist nicht lesbar. Datenbank unverändert, Backend bleibt gestoppt."
local safety="pre-rollback-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$safety"
say "Sicherheitskopie des jetzigen Zustands: /backups/$safety"
reset_schema() {
pgtool psql -h postgres -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
-c 'DROP SCHEMA public CASCADE' -c 'CREATE SCHEMA public' 2>&1 | grep -v '^NOTICE\|^DETAIL\|^drop cascades' || true
}
reset_schema
if ! pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "$dump"; then
say "Zurückspielen fehlgeschlagen — stelle den Zustand vor dem Rollback aus /backups/$safety wieder her …"
reset_schema
pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "/backups/$safety" \
|| die "Auch die Wiederherstellung schlug fehl. Backups liegen in /backups ($dump, $safety). Backend bleibt gestoppt."
die "Rollback der Datenbank fehlgeschlagen; Zustand vor dem Rollback wiederhergestellt. Backend bleibt gestoppt."
fi
else
say "Datenbank-Revision unverändert ($now_rev) — kein Zurückspielen nötig."
fi
env_set OPNEXUS_VERSION "$prev_version"
c up -d --no-build || true
wait_healthy || die "Nach dem Rollback sind nicht alle Dienste gesund — 'docker compose logs' prüfen."
say "Fertig: läuft wieder auf $(running_version) (DB-Revision $(db_revision)). OPNEXUS_VERSION in $ENV_FILE ist jetzt fest auf $prev_version gesetzt."
rm -f "$STATE_FILE"
}
case "${1:-}" in
status) cmd_status ;;
update) shift; cmd_update "${1:-}" ;;
rollback) cmd_rollback ;;
backup) cmd_backup ;;
*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
+1
View File
@@ -0,0 +1 @@
../updater/opnexus
+2
View File
@@ -0,0 +1,2 @@
__pycache__
*.pyc
+18
View File
@@ -0,0 +1,18 @@
# Optionaler Updater-Dienst (Compose-Profil "updater"). Er enthält Docker-CLI
# und Compose, damit das Webinterface ein Update auslösen kann, ohne dass das
# Backend je den Docker-Socket sieht (siehe server.py und Handbuch).
FROM python:3.12-alpine
RUN apk add --no-cache bash docker-cli docker-cli-compose
COPY opnexus /usr/local/bin/opnexus
COPY server.py /app/server.py
RUN chmod 0755 /usr/local/bin/opnexus
WORKDIR /app
ENV PYTHONUNBUFFERED=1 OPNEXUS_DIR=/project UPDATER_STATE_DIR=/state
HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=5s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"
CMD ["python", "/app/server.py"]
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env bash
# opnexus — Update, Rollback und Backup für eine OPNexus-Installation aus
# fertigen Release-Images (siehe README, "Release-Images & CI").
#
# opnexus status Version, Container, Datenbank-Revision
# opnexus update [VERSION] Neue Images ziehen, wechseln, auf "gesund" warten;
# bei Fehler automatisch zurückrollen
# opnexus rollback Letztes Update rückgängig machen
# opnexus backup Sofort-Backup der OPNexus-Datenbank
# opnexus password [NAME] Neues Zufallspasswort für den Admin setzen und
# anzeigen (--yes: ohne Rückfrage)
# opnexus status --porcelain Zustand als key=value-Zeilen (für den Updater)
#
# Läuft im Verzeichnis mit docker-compose.yml und .env (oder OPNEXUS_DIR=...).
# Braucht: docker (compose v2), bash. OPNEXUS_IMAGE_REPO muss in .env stehen.
#
# Exit-Codes von `update`: 0 = läuft auf der neuen Version, 3 = Update ist
# fehlgeschlagen und wurde sauber zurückgerollt, 1 = sonstiger Fehler.
#
# Umgebung (nur für den Updater-Container, siehe updater/server.py):
# OPNEXUS_UPDATER=1 `up` fasst nur backend/frontend/proxy an — der
# Updater-Container selbst wird nie aus seinem
# eigenen Lauf heraus neu gestartet.
# OPNEXUS_PHASE_FILE=PATH hängt bei jedem Schritt eine Zeile "phase" an.
set -euo pipefail
here="$(dirname "$(readlink -f "$0")")"
# Im Release-Bundle liegt das Skript neben docker-compose.yml, im Repo in scripts/.
if [ -f "$here/docker-compose.yml" ]; then default_dir="$here"; else default_dir="$here/.."; fi
cd "${OPNEXUS_DIR:-$default_dir}"
ENV_FILE=".env"
STATE_FILE=".opnexus-update-state"
SERVICES=(backend frontend proxy)
TIMEOUT="${OPNEXUS_UPDATE_TIMEOUT:-240}"
c() { docker compose "$@" 2> >(grep -v 'level=warning' >&2); }
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
# Schritt für den Updater-Container (Fortschrittsanzeige im Webinterface).
phase() { [ -z "${OPNEXUS_PHASE_FILE:-}" ] || echo "$1" >> "$OPNEXUS_PHASE_FILE"; }
die() { printf '\033[31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" "$ENV_FILE" 2>/dev/null | tail -n1; }
DB_USER="$(env_get POSTGRES_USER)"; DB_USER="${DB_USER:-opnexus}"
DB_NAME="$(env_get POSTGRES_DB)"; DB_NAME="${DB_NAME:-opnexus}"
env_set() {
if grep -q "^$1=" "$ENV_FILE" 2>/dev/null; then
sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"
else
printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"
fi
}
# Postgres-Client-Tools aus dem Backend-Image (Client 17): Die Pre-Upgrade-
# Dumps schreibt das Backend mit pg_dump 17 — das kann pg_restore 16 (Sidecar)
# NICHT lesen. Also Dump UND Restore immer mit diesen Tools.
pgtool() {
local tool="$1"; shift
# Passwort über die Umgebung durchreichen (`-e PGPASSWORD` ohne Wert), nicht als
# Argument — sonst stünde es in der Prozessliste.
PGPASSWORD="$(env_get POSTGRES_PASSWORD)" c run --rm --no-deps -T -e PGPASSWORD --entrypoint "$tool" backend "$@"
}
# Der Updater-Container ist ein optionaler Dienst (Compose-Profil "updater"):
# nur wenn er aktiv ist, wird sein Image mitgezogen — sonst würde ein Update
# daran scheitern, dass es für eine ältere Version noch kein Updater-Image gibt.
updater_enabled() {
local profiles
profiles="${COMPOSE_PROFILES:-$(env_get COMPOSE_PROFILES)}"
[[ ",$profiles," == *",updater,"* ]]
}
# Welche Dienste `up` anfasst: im Updater-Container nur die Anwendung, auf dem
# Host (CLI) alles — dort darf sich auch der Updater selbst erneuern.
up_targets() {
if [ -n "${OPNEXUS_UPDATER:-}" ]; then printf '%s\n' "${SERVICES[@]}"; fi
}
psql_db() { c exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -Atq "$@"; }
db_revision() {
psql_db -c "SELECT version_num FROM alembic_version" 2>/dev/null || true
}
running_version() {
c exec -T backend python -c 'from app.version import APP_VERSION; print(APP_VERSION)' 2>/dev/null || true
}
service_state() { # healthy | unhealthy | starting | running | (leer)
local h
h=$(c ps "$1" --format '{{.Health}}' 2>/dev/null || true)
if [ -n "$h" ]; then echo "$h"; else c ps "$1" --format '{{.State}}' 2>/dev/null || true; fi
}
wait_healthy() {
local deadline=$((SECONDS + TIMEOUT)) all svc st
while [ "$SECONDS" -lt "$deadline" ]; do
all=1
for svc in "${SERVICES[@]}"; do
st=$(service_state "$svc")
case "$st" in healthy|running) ;; *) all=0 ;; esac
done
[ "$all" = 1 ] && return 0
sleep 3
done
return 1
}
require_release_mode() {
[ -f docker-compose.yml ] || die "docker-compose.yml nicht gefunden (OPNEXUS_DIR setzen?)"
[ -f "$ENV_FILE" ] || die "$ENV_FILE nicht gefunden"
[ -n "$(env_get OPNEXUS_IMAGE_REPO)" ] || die "OPNEXUS_IMAGE_REPO ist in $ENV_FILE nicht gesetzt — dieses Kommando arbeitet mit fertigen Release-Images (lokale Builds: git pull && docker compose up -d --build)."
}
cmd_status_porcelain() {
require_release_mode
echo "repo=$(env_get OPNEXUS_IMAGE_REPO)"
echo "version_setting=$(env_get OPNEXUS_VERSION)"
echo "running_version=$(running_version)"
echo "db_revision=$(db_revision)"
for svc in "${SERVICES[@]}"; do echo "service.$svc=$(service_state "$svc")"; done
if [ -f "$STATE_FILE" ]; then
# Nur bekannte Schlüssel durchlassen — die Datei wird vom Skript selbst geschrieben.
grep -E '^(started|prev_version|prev_setting|prev_db_revision|target)=' "$STATE_FILE" | sed 's/^/last_update./'
fi
}
cmd_status() {
require_release_mode
echo "Image-Repo: $(env_get OPNEXUS_IMAGE_REPO)"
echo "OPNEXUS_VERSION: $(env_get OPNEXUS_VERSION)"
echo "Laufende Version: $(running_version)"
echo "DB-Revision: $(db_revision)"
for svc in "${SERVICES[@]}"; do echo "$svc: $(service_state "$svc")"; done
[ -f "$STATE_FILE" ] && { echo; echo "Letztes Update (für 'rollback'):"; sed 's/^/ /' "$STATE_FILE"; }
return 0
}
cmd_backup() {
local name="manual-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$name"
say "Backup geschrieben: /backups/$name (Volume postgres_backups)"
}
cmd_update() {
require_release_mode
local target="${1:-$(env_get OPNEXUS_VERSION)}"
target="${target:-latest}"
# Landet in .env und in Image-Tags: nur Zeichen, die ein Tag enthalten darf.
[[ "$target" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die "Ungültige Version '$target' (erlaubt: Buchstaben, Ziffern, . _ -)."
local prev_setting prev_version prev_rev started
prev_setting=$(env_get OPNEXUS_VERSION)
prev_version=$(running_version)
[ -n "$prev_version" ] || die "Backend läuft nicht oder antwortet nicht — erst reparieren, dann updaten."
prev_rev=$(db_revision)
say "Aktuell: $prev_version (DB-Revision ${prev_rev:-keine}) — Ziel: $target"
say "Ziehe neue Images (am laufenden System ändert sich dabei nichts) …"
phase pulling
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."
started=$(date -u +%Y%m%dT%H%M%SZ)
cat > "$STATE_FILE" <<STATE
started=$started
prev_version=$prev_version
prev_setting=$prev_setting
prev_db_revision=$prev_rev
target=$target
STATE
env_set OPNEXUS_VERSION "$target"
say "Wechsle auf $target (das Backend sichert die Datenbank selbst vor einer Migration) …"
phase switching
local targets=()
mapfile -t targets < <(up_targets)
# `up` selbst scheitert, sobald das Backend nicht gesund wird (depends_on) —
# das ist der Fehlerfall, nicht das Ende des Skripts.
if c up -d --no-build "${targets[@]}" && { phase waiting; wait_healthy; }; then
say "Fertig: läuft jetzt auf $(running_version) (DB-Revision $(db_revision))."
phase done
return 0
fi
printf '\033[31mUpdate nicht gesund geworden.\033[0m Backend-Log:\n' >&2
c logs --tail 25 backend >&2 || true
if [ "${OPNEXUS_NO_AUTO_ROLLBACK:-}" = 1 ]; then
die "Automatisches Zurückrollen ist aus. Manuell: opnexus rollback"
fi
say "Rolle automatisch zurück …"
cmd_rollback
phase rolled_back
printf '\033[31mFehler:\033[0m Update auf %s fehlgeschlagen und zurückgerollt auf %s.\n' "$target" "$prev_version" >&2
exit 3
}
latest_pre_upgrade_dump() {
c exec -T postgres-backup sh -c 'ls -1t /backups/pre-upgrade-*.dump 2>/dev/null | head -n1' | tr -d '\r'
}
cmd_rollback() {
require_release_mode
[ -f "$STATE_FILE" ] || die "Kein Update-Zustand ($STATE_FILE) — es gibt nichts zurückzurollen."
# shellcheck disable=SC1090
. "./$STATE_FILE"
[ -n "${prev_version:-}" ] || die "$STATE_FILE ist unvollständig."
# Rollback target = the exact image tag that was configured before the update.
# The version the backend REPORTS only matches the tag for final releases: an
# rc image reports its final version (rc1 images say 1.11.0), so rolling back
# to that would land on a different, newer image. Floating tags ("latest",
# "1.11") cannot be restored by name, so those fall back to the reported version.
if [[ "${prev_setting:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
prev_version="$prev_setting"
fi
say "Rolle zurück auf $prev_version …"
phase stopping
c stop "${SERVICES[@]}"
local now_rev dump
now_rev=$(db_revision)
if [ "$now_rev" != "$prev_db_revision" ]; then
dump=$(latest_pre_upgrade_dump)
[ -n "$dump" ] || die "Datenbank wurde migriert ($prev_db_revision → $now_rev), aber es gibt kein pre-upgrade-Backup. Backend bleibt gestoppt."
# Nur ein Backup, das nach dem Start DIESES Updates entstanden ist, darf
# zurückgespielt werden — ein älteres würde stillschweigend Daten verwerfen.
dump_ts=$(echo "$dump" | sed -E 's/.*-([0-9]{8})-([0-9]{6})\.dump$/\1\2/')
[ "$dump_ts" -ge "${started//[-:TZ]/}" ] 2>/dev/null \
|| die "Neuestes pre-upgrade-Backup ($dump) ist älter als dieses Update (SKIP_MIGRATION_BACKUP gesetzt?). Backend bleibt gestoppt — manuell eingreifen."
say "Datenbank wurde migriert ($prev_db_revision → $now_rev): spiele $dump zurück."
phase restoring
say "Hinweis: Log-Historie und Ressourcen-Messwerte sind im Backup nicht enthalten und danach leer; alles andere steht auf dem Stand vor dem Update."
# Erst prüfen, dass das Backup lesbar ist — BEVOR irgendetwas gelöscht wird.
pgtool pg_restore --list "$dump" > /dev/null \
|| die "Backup $dump ist nicht lesbar. Datenbank unverändert, Backend bleibt gestoppt."
local safety="pre-rollback-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$safety"
say "Sicherheitskopie des jetzigen Zustands: /backups/$safety"
reset_schema() {
pgtool psql -h postgres -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
-c 'DROP SCHEMA public CASCADE' -c 'CREATE SCHEMA public' 2>&1 | grep -v '^NOTICE\|^DETAIL\|^drop cascades' || true
}
reset_schema
if ! pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "$dump"; then
say "Zurückspielen fehlgeschlagen — stelle den Zustand vor dem Rollback aus /backups/$safety wieder her …"
reset_schema
pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "/backups/$safety" \
|| die "Auch die Wiederherstellung schlug fehl. Backups liegen in /backups ($dump, $safety). Backend bleibt gestoppt."
die "Rollback der Datenbank fehlgeschlagen; Zustand vor dem Rollback wiederhergestellt. Backend bleibt gestoppt."
fi
else
say "Datenbank-Revision unverändert ($now_rev) — kein Zurückspielen nötig."
fi
env_set OPNEXUS_VERSION "$prev_version"
phase switching
local rb_targets=()
mapfile -t rb_targets < <(up_targets)
c up -d --no-build "${rb_targets[@]}" || true
phase waiting
wait_healthy || die "Nach dem Rollback sind nicht alle Dienste gesund — 'docker compose logs' prüfen."
say "Fertig: läuft wieder auf $(running_version) (DB-Revision $(db_revision)). OPNEXUS_VERSION in $ENV_FILE ist jetzt fest auf $prev_version gesetzt."
rm -f "$STATE_FILE"
phase done
}
# Setzt ein neues Zufallspasswort und gibt es einmalig aus. Gedacht für die
# frische Installation (das beim ersten Start erzeugte Passwort steht nur
# einmal im Log) und für ein vergessenes Admin-Passwort. Alle Sitzungen des
# Kontos enden. Rückfrage nur, wenn sich schon jemand angemeldet hat.
cmd_password() {
local user="" yes=0 arg
for arg in "$@"; do
case "$arg" in
--yes|-y) yes=1 ;;
-*) die "Unbekannte Option: $arg" ;;
*) [ -z "$user" ] || die "Nur ein Benutzername erlaubt."; user="$arg" ;;
esac
done
c ps --status running --services 2>/dev/null | grep -qx backend \
|| die "Das Backend läuft nicht. Erst starten: docker compose up -d"
if [ "$yes" != 1 ]; then
local logins; logins="$(psql_db -c 'SELECT count(*) FROM admin_users WHERE last_login_at IS NOT NULL' 2>/dev/null || echo 1)"
if [ "$logins" != 0 ]; then
[ -t 0 ] || die "Ohne Terminal nur mit --yes."
read -r -p "Passwort von ${user:-dem Admin} neu setzen? Alle Sitzungen enden. [j/N] " answer
case "$answer" in j|J|y|Y) ;; *) die "Abgebrochen." ;; esac
fi
fi
c exec -T backend python -m app.reset_password ${user:+"$user"}
}
case "${1:-}" in
password) shift; cmd_password "$@" ;;
status) if [ "${2:-}" = "--porcelain" ]; then cmd_status_porcelain; else cmd_status; fi ;;
update) shift; cmd_update "${1:-}" ;;
rollback) cmd_rollback ;;
backup) cmd_backup ;;
*) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""OPNexus-Updater: kleine interne HTTP-API vor dem `opnexus`-Skript.
Läuft als eigener, optionaler Container (Compose-Profil "updater") und ist der
EINZIGE Teil von OPNexus, der den Docker-Socket sieht — das Backend bekommt ihn
nie. Er kann bewusst nur drei Dinge:
GET /status Zustand der Installation und des letzten/laufenden Auftrags
POST /update {"version": "X.Y.Z"} auf eine neuere stabile Version wechseln
POST /rollback das letzte Update rückgängig machen
und es gibt keinen Weg, einen eigenen Befehl auszuführen. Erreichbar ist er nur
aus dem Compose-Netz (kein veröffentlichter Port) und nur mit dem Token, den er
beim ersten Start selbst erzeugt und in sein Zustands-Volume schreibt (das
Backend hängt dasselbe Volume schreibgeschützt ein).
Die eigentliche Arbeit — pullen, wechseln, auf "gesund" warten, bei Fehler
zurückrollen, Datenbank sichern/zurückspielen — macht dasselbe Skript wie auf
der Kommandozeile (`updater/opnexus`), damit es nur eine Update-Logik gibt.
"""
import hmac
import json
import os
import re
import secrets
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
STATE_DIR = Path(os.environ.get("UPDATER_STATE_DIR", "/state"))
PROJECT_DIR = Path(os.environ.get("OPNEXUS_DIR", "/project"))
SCRIPT = os.environ.get("OPNEXUS_SCRIPT", "/usr/local/bin/opnexus")
PORT = int(os.environ.get("UPDATER_PORT", "8080"))
# Wie in einem Docker-Tag, aber ohne Vorabversionen: nur X.Y.Z wird akzeptiert.
VERSION_RE = re.compile(r"^(\d{1,4})\.(\d{1,5})\.(\d{1,6})$")
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
MAX_BODY = 1024
LOG_TAIL_LINES = 150
LOG_TAIL_BYTES = 16_000
STATUS_CACHE_SECONDS = 3.0
TOKEN_FILE = STATE_DIR / "token"
JOB_FILE = STATE_DIR / "job.json"
LOG_FILE = STATE_DIR / "job.log"
PHASE_FILE = STATE_DIR / "job.phases"
_lock = threading.Lock() # genau ein Auftrag zur Zeit
_status_cache: dict = {"at": 0.0, "value": None}
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def parse_version(value: str | None):
match = VERSION_RE.match((value or "").strip())
return tuple(int(part) for part in match.groups()) if match else None
def read_env_file() -> dict[str, str]:
values: dict[str, str] = {}
try:
for line in (PROJECT_DIR / ".env").read_text(encoding="utf-8").splitlines():
if "=" in line and not line.lstrip().startswith("#"):
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
except OSError:
pass
return values
def load_token() -> str:
"""Token einmalig erzeugen; danach immer aus der Datei lesen."""
STATE_DIR.mkdir(parents=True, exist_ok=True)
try:
existing = TOKEN_FILE.read_text(encoding="utf-8").strip()
if len(existing) >= 32:
return existing
except OSError:
pass
token = secrets.token_urlsafe(32)
tmp = TOKEN_FILE.with_suffix(".tmp")
tmp.write_text(token + "\n", encoding="utf-8")
os.chmod(tmp, 0o644) # Zugriff regelt, welche Container das Volume sehen
tmp.replace(TOKEN_FILE)
return token
def read_job() -> dict:
try:
return json.loads(JOB_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def write_job(job: dict) -> None:
tmp = JOB_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(job), encoding="utf-8")
tmp.replace(JOB_FILE)
def log_tail() -> list[str]:
try:
raw = LOG_FILE.read_bytes()[-LOG_TAIL_BYTES:].decode("utf-8", errors="replace")
except OSError:
return []
lines = [ANSI_RE.sub("", line).rstrip() for line in raw.splitlines()]
return [line for line in lines if line][-LOG_TAIL_LINES:]
def phases() -> list[str]:
try:
return [line.strip() for line in PHASE_FILE.read_text(encoding="utf-8").splitlines() if line.strip()]
except OSError:
return []
def script_env(extra: dict[str, str] | None = None) -> dict[str, str]:
env = dict(os.environ)
env.update({"OPNEXUS_DIR": str(PROJECT_DIR), "OPNEXUS_UPDATER": "1"})
env.update(extra or {})
return env
def live_status() -> dict:
"""`opnexus status --porcelain`, kurz zwischengespeichert (docker exec ist nicht gratis)."""
cached = _status_cache["value"]
if cached is not None and time.monotonic() - _status_cache["at"] < STATUS_CACHE_SECONDS:
return cached
env_file = read_env_file()
result: dict = {
"mode": "release" if env_file.get("OPNEXUS_IMAGE_REPO") else "local",
"version_setting": env_file.get("OPNEXUS_VERSION") or None,
"running_version": None,
"db_revision": None,
"services": {},
"last_update": None,
}
if result["mode"] == "release":
try:
proc = subprocess.run(
[SCRIPT, "status", "--porcelain"], cwd=PROJECT_DIR, env=script_env(),
capture_output=True, text=True, timeout=45,
)
except (OSError, subprocess.TimeoutExpired):
proc = None
if proc is not None and proc.returncode == 0:
last: dict[str, str] = {}
for line in proc.stdout.splitlines():
key, _, value = line.partition("=")
if key == "running_version":
result["running_version"] = value or None
elif key == "db_revision":
result["db_revision"] = value or None
elif key.startswith("service."):
result["services"][key.split(".", 1)[1]] = value
elif key.startswith("last_update."):
last[key.split(".", 1)[1]] = value
if last:
result["last_update"] = {
"prev_version": last.get("prev_setting") if parse_version(last.get("prev_setting")) else last.get("prev_version"),
"prev_db_revision": last.get("prev_db_revision") or None,
"target": last.get("target"),
"started": last.get("started"),
}
_status_cache.update(at=time.monotonic(), value=result)
return result
def status_payload() -> dict:
job = read_job()
running = job.get("state") == "running"
payload = {"job": job or None, "phases": phases() if job else [], "log": log_tail() if job else []}
if running:
# Während eines Auftrags kann das Backend gerade fehlen — nicht anfragen.
payload.update(mode="release", running_version=None, db_revision=None, services={}, last_update=None,
rollback_available=False, rollback_loses_data=False)
return payload
live = live_status()
last = live["last_update"]
payload.update(live)
payload["rollback_available"] = bool(last and last.get("prev_version"))
payload["rollback_loses_data"] = bool(last and last.get("prev_db_revision") != live["db_revision"])
return payload
class Reject(Exception):
def __init__(self, code: str, status: int = 409):
super().__init__(code)
self.code, self.status = code, status
def start_job(action: str, target: str | None) -> dict:
if not _lock.acquire(blocking=False):
raise Reject("busy")
try:
if read_job().get("state") == "running":
raise Reject("busy")
_status_cache["value"] = None
live = live_status()
if live["mode"] != "release":
raise Reject("not_release_mode")
if action == "update":
current = parse_version(live["running_version"])
wanted = parse_version(target)
if wanted is None:
raise Reject("invalid_version", 400)
if current is None:
raise Reject("backend_unreachable")
if wanted <= current:
raise Reject("not_newer")
if wanted[0] != current[0]:
raise Reject("major_change")
elif action == "rollback":
last = live["last_update"]
if not (last and last.get("prev_version")):
raise Reject("no_rollback")
job = {
"id": secrets.token_hex(8), "action": action, "target": target, "state": "running",
"started_at": now_iso(), "finished_at": None, "exit_code": None,
"from_version": live["running_version"],
}
for path in (LOG_FILE, PHASE_FILE):
path.write_text("", encoding="utf-8")
write_job(job)
except BaseException:
_lock.release()
raise
# Ab hier gehört das Lock dem Worker-Thread.
threading.Thread(target=_run_job, args=(job,), daemon=True).start()
return job
def _run_job(job: dict) -> None:
args = [SCRIPT, "update", job["target"]] if job["action"] == "update" else [SCRIPT, "rollback"]
env = script_env({"OPNEXUS_PHASE_FILE": str(PHASE_FILE), "TERM": "dumb"})
try:
with LOG_FILE.open("ab") as log:
proc = subprocess.Popen(args, cwd=PROJECT_DIR, env=env, stdout=log, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, start_new_session=True)
code = proc.wait()
except OSError as exc:
code = 127
with LOG_FILE.open("a", encoding="utf-8") as log:
log.write(f"Updater: Skript konnte nicht gestartet werden: {exc}\n")
if code == 0:
state = "succeeded"
elif code == 3 and job["action"] == "update":
state = "rolled_back"
else:
state = "failed"
job.update(state=state, exit_code=code, finished_at=now_iso())
write_job(job)
_status_cache["value"] = None
try:
if state == "succeeded" and job["action"] == "update":
self_update()
finally:
_lock.release()
def _docker(*args: str, timeout: int = 120) -> subprocess.CompletedProcess:
return subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout,
cwd=PROJECT_DIR, env=script_env())
def self_update() -> None:
"""Den Updater-Container selbst auf das neue Image bringen.
Läuft in einem eigenen, kurzlebigen Container: Würde der Updater sich aus
sich selbst heraus neu erstellen, würde `docker compose` mitten im Vorgang
mit ihm beendet. Das Ergebnis des Auftrags ist zu diesem Zeitpunkt längst
gespeichert; schlägt dieser Schritt fehl, läuft der Updater einfach in der
alten Version weiter und wird beim nächsten `docker compose up -d` erneuert.
"""
def note(text: str) -> None:
with LOG_FILE.open("a", encoding="utf-8") as log:
log.write(text + "\n")
try:
me = os.environ.get("HOSTNAME", "")
inspect = _docker("inspect", me, "--format",
'{{.Config.Image}}|{{range .Mounts}}{{if eq .Destination "' + str(PROJECT_DIR) + '"}}{{.Source}}{{end}}{{end}}')
current_image, _, host_dir = inspect.stdout.strip().partition("|")
if inspect.returncode != 0 or not host_dir.startswith("/"):
note("Updater: eigenes Verzeichnis auf dem Host nicht ermittelbar — Updater-Container nicht erneuert.")
return
images = _docker("compose", "config", "--images").stdout.split()
new_image = next((i for i in images if "/opnexus-updater:" in i or i.startswith("opnexus-updater:")), "")
if not new_image or new_image == current_image:
return
_docker("rm", "-f", "opnexus-updater-self-update")
started = _docker(
"run", "-d", "--rm", "--name", "opnexus-updater-self-update",
"-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", f"{host_dir}:{host_dir}", "-w", host_dir,
"--entrypoint", "docker", new_image, "compose", "up", "-d", "--no-build", "--no-deps", "updater",
)
note("Updater: erneuert sich selbst auf " + new_image if started.returncode == 0
else "Updater: Selbst-Update fehlgeschlagen: " + started.stderr.strip()[:300])
except (OSError, subprocess.TimeoutExpired) as exc:
note(f"Updater: Selbst-Update fehlgeschlagen: {exc}")
class Handler(BaseHTTPRequestHandler):
server_version = "opnexus-updater"
token = ""
def log_message(self, fmt, *args): # Zugriffsprotokoll auf stderr, ohne Query/Header
sys.stderr.write("updater: %s %s\n" % (self.command, self.path.split("?", 1)[0]))
def _send(self, status: int, body: dict) -> None:
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def _authorized(self) -> bool:
header = self.headers.get("Authorization", "")
supplied = header[7:] if header.startswith("Bearer ") else ""
# Ein leeres Token darf nie "passen" (leerer Header == leeres Token).
return bool(self.token) and hmac.compare_digest(supplied.encode(), self.token.encode())
def _body(self) -> dict:
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
raise Reject("bad_request", 400)
if length > MAX_BODY:
raise Reject("bad_request", 413)
if length == 0:
return {}
try:
data = json.loads(self.rfile.read(length))
except ValueError:
raise Reject("bad_request", 400)
if not isinstance(data, dict):
raise Reject("bad_request", 400)
return data
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/health":
return self._send(200, {"ok": True})
if not self._authorized():
return self._send(401, {"error": "unauthorized"})
if path == "/status":
return self._send(200, status_payload())
self._send(404, {"error": "not_found"})
def do_POST(self):
path = self.path.split("?", 1)[0]
if not self._authorized():
return self._send(401, {"error": "unauthorized"})
try:
body = self._body()
if path == "/update":
version = body.get("version")
job = start_job("update", version if isinstance(version, str) else None)
elif path == "/rollback":
job = start_job("rollback", None)
else:
return self._send(404, {"error": "not_found"})
except Reject as rejected:
return self._send(rejected.status, {"error": rejected.code})
self._send(202, {"job": job})
def mark_interrupted() -> None:
"""Ein Auftrag, der beim Start noch als "running" steht, hat nie geendet."""
job = read_job()
if job.get("state") == "running":
job.update(state="failed", finished_at=now_iso())
write_job(job)
with LOG_FILE.open("a", encoding="utf-8") as log:
log.write("Updater: wurde während des Auftrags neu gestartet — Zustand bitte prüfen (opnexus status).\n")
def main() -> None:
Handler.token = load_token()
mark_interrupted()
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.daemon_threads = True
print(f"updater: listening on :{PORT}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+215
View File
@@ -0,0 +1,215 @@
"""Tests für updater/server.py — nur Standardbibliothek:
python3 -m unittest discover -s updater -p 'test_*.py'
Das echte `opnexus`-Skript und Docker werden durch ein Fake-Skript ersetzt, das
`status --porcelain` beantwortet und `update`/`rollback` mit einem vorgegebenen
Exit-Code beendet; so sind Zustandsübergänge, Ablehnungen und die Authentifizierung
prüfbar, ohne einen Docker-Daemon.
"""
import json
import os
import stat
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
import server
FAKE = """#!/bin/sh
case "$1" in
status)
echo "running_version=${FAKE_RUNNING:-1.14.0}"
echo "db_revision=0004"
echo "service.backend=healthy"
if [ -n "$FAKE_LAST" ]; then
echo "last_update.prev_version=1.13.0"
echo "last_update.prev_setting=1.13.0"
echo "last_update.prev_db_revision=${FAKE_PREV_REV:-0004}"
fi
;;
update|rollback)
[ -z "$OPNEXUS_PHASE_FILE" ] || echo pulling >> "$OPNEXUS_PHASE_FILE"
echo "arbeite $1 $2"
sleep "${FAKE_SLEEP:-0.2}"
exit "${FAKE_EXIT:-0}"
;;
esac
"""
class UpdaterTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
(root / "state").mkdir()
(root / "project").mkdir()
(root / "project" / ".env").write_text("OPNEXUS_IMAGE_REPO=reg.test/opnexus\nOPNEXUS_VERSION=1.14.0\n")
script = root / "opnexus"
script.write_text(FAKE)
script.chmod(script.stat().st_mode | stat.S_IEXEC)
state = root / "state"
server.STATE_DIR = state
server.TOKEN_FILE = state / "token"
server.JOB_FILE = state / "job.json"
server.LOG_FILE = state / "job.log"
server.PHASE_FILE = state / "job.phases"
server.PROJECT_DIR = root / "project"
server.SCRIPT = str(script)
server._status_cache.update(at=0.0, value=None)
for key in ("FAKE_EXIT", "FAKE_RUNNING", "FAKE_LAST", "FAKE_PREV_REV", "FAKE_SLEEP"):
os.environ.pop(key, None)
self.token = server.load_token()
server.Handler.token = self.token
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
self.httpd.daemon_threads = True
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
deadline = time.time() + 5
while server._lock.locked() and time.time() < deadline:
time.sleep(0.05)
self.tmp.cleanup()
def call(self, method, path, body=None, token="__default__"):
token = self.token if token == "__default__" else token
headers = {"Content-Type": "application/json"}
if token is not None:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(self.base + path, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as err:
return err.code, json.loads(err.read())
def wait_done(self):
deadline = time.time() + 10
while time.time() < deadline:
_, body = self.call("GET", "/status")
if body["job"] and body["job"]["state"] != "running":
return body
time.sleep(0.1)
self.fail("job did not finish")
# --- Authentifizierung -------------------------------------------------
def test_health_is_public_everything_else_needs_the_token(self):
self.assertEqual(self.call("GET", "/health", token=None)[0], 200)
for token in (None, "", "wrong", self.token[:-1]):
self.assertEqual(self.call("GET", "/status", token=token)[0], 401, token)
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"}, token=token)[0], 401)
self.assertEqual(self.call("POST", "/rollback", {}, token=token)[0], 401)
def test_token_is_generated_once_and_reused(self):
self.assertGreaterEqual(len(self.token), 32)
self.assertEqual(server.load_token(), self.token)
def test_unknown_paths_are_404(self):
self.assertEqual(self.call("GET", "/etc/passwd")[0], 404)
self.assertEqual(self.call("POST", "/exec", {"cmd": "id"})[0], 404)
# --- Status ------------------------------------------------------------
def test_status_reports_version_and_mode(self):
status, body = self.call("GET", "/status")
self.assertEqual(status, 200)
self.assertEqual(body["mode"], "release")
self.assertEqual(body["running_version"], "1.14.0")
self.assertFalse(body["rollback_available"])
def test_local_build_install_is_reported_as_local(self):
(server.PROJECT_DIR / ".env").write_text("POSTGRES_PASSWORD=x\n")
_, body = self.call("GET", "/status")
self.assertEqual(body["mode"], "local")
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"})[1], {"error": "not_release_mode"})
# --- Update ------------------------------------------------------------
def test_update_runs_and_succeeds(self):
status, body = self.call("POST", "/update", {"version": "1.14.1"})
self.assertEqual(status, 202)
self.assertEqual(body["job"]["target"], "1.14.1")
done = self.wait_done()
self.assertEqual(done["job"]["state"], "succeeded")
self.assertIn("pulling", done["phases"])
self.assertTrue(any("arbeite update 1.14.1" in line for line in done["log"]))
def test_failed_update_exit_codes(self):
os.environ["FAKE_EXIT"] = "3"
self.call("POST", "/update", {"version": "1.14.1"})
self.assertEqual(self.wait_done()["job"]["state"], "rolled_back")
server._status_cache.update(at=0.0, value=None)
os.environ["FAKE_EXIT"] = "1"
self.call("POST", "/update", {"version": "1.14.1"})
self.assertEqual(self.wait_done()["job"]["state"], "failed")
def test_invalid_and_disallowed_versions_never_start_a_job(self):
cases = {
"1.14.0": "not_newer", "1.13.9": "not_newer", "2.0.0": "major_change",
"latest": "invalid_version", "1.14.1-rc1": "invalid_version", "1.14": "invalid_version",
"1.14.1; id": "invalid_version", "../1.14.1": "invalid_version", "": "invalid_version",
}
for version, error in cases.items():
status, body = self.call("POST", "/update", {"version": version})
self.assertEqual(body, {"error": error}, version)
self.assertIn(status, (400, 409))
self.assertEqual(self.call("POST", "/update", {})[1], {"error": "invalid_version"})
self.assertEqual(self.call("POST", "/update", {"version": 5})[1], {"error": "invalid_version"})
self.assertIsNone(self.call("GET", "/status")[1]["job"])
def test_second_request_while_running_is_busy(self):
os.environ["FAKE_SLEEP"] = "1.5"
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"})[0], 202)
self.assertEqual(self.call("POST", "/update", {"version": "1.14.2"}), (409, {"error": "busy"}))
self.assertEqual(self.call("POST", "/rollback", {}), (409, {"error": "busy"}))
_, running = self.call("GET", "/status")
self.assertEqual(running["job"]["state"], "running")
self.assertEqual(self.wait_done()["job"]["state"], "succeeded")
def test_oversized_or_malformed_body_is_rejected(self):
req = urllib.request.Request(self.base + "/update", method="POST", data=b"x" * 5000,
headers={"Authorization": "Bearer " + self.token, "Content-Length": "5000"})
with self.assertRaises(urllib.error.HTTPError) as ctx:
urllib.request.urlopen(req, timeout=5)
self.assertEqual(ctx.exception.code, 413)
req = urllib.request.Request(self.base + "/update", method="POST", data=b"{not json",
headers={"Authorization": "Bearer " + self.token})
with self.assertRaises(urllib.error.HTTPError) as ctx:
urllib.request.urlopen(req, timeout=5)
self.assertEqual(ctx.exception.code, 400)
# --- Rollback ----------------------------------------------------------
def test_rollback_needs_a_previous_update(self):
self.assertEqual(self.call("POST", "/rollback", {}), (409, {"error": "no_rollback"}))
def test_rollback_and_data_loss_flag(self):
os.environ["FAKE_LAST"] = "1"
_, body = self.call("GET", "/status")
self.assertTrue(body["rollback_available"])
self.assertFalse(body["rollback_loses_data"]) # Revision unverändert
server._status_cache.update(at=0.0, value=None)
os.environ["FAKE_PREV_REV"] = "0003"
_, body = self.call("GET", "/status")
self.assertTrue(body["rollback_loses_data"])
server._status_cache.update(at=0.0, value=None)
self.assertEqual(self.call("POST", "/rollback", {})[0], 202)
self.assertEqual(self.wait_done()["job"]["state"], "succeeded")
# --- Neustart ----------------------------------------------------------
def test_job_left_running_after_a_restart_is_marked_failed(self):
server.write_job({"id": "x", "action": "update", "target": "1.14.1", "state": "running"})
server.mark_interrupted()
job = server.read_job()
self.assertEqual(job["state"], "failed")
self.assertIn("neu gestartet", server.LOG_FILE.read_text())
if __name__ == "__main__":
unittest.main()