Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf326d2d17 | ||
|
|
8fccb1a547 | ||
|
|
cf59bdb38e | ||
|
|
c662a58426 | ||
|
|
3b76f9f5fa | ||
|
|
b341ccd49f |
No files matched your search
+9
-1
@@ -71,10 +71,18 @@ TRUST_PROXY_HEADERS=false
|
|||||||
# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht
|
# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht
|
||||||
# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit
|
# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit
|
||||||
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
|
||||||
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update`.
|
# 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_URL=https://updates.opnexus.dev/latest.json
|
||||||
# UPDATE_CHECK_INTERVAL_HOURS=12
|
# 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) ──────────────────────
|
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
||||||
# Firewall-API-Zugangsdaten gehen per HTTP-Basic-Auth raus, deshalb sind
|
# Firewall-API-Zugangsdaten gehen per HTTP-Basic-Auth raus, deshalb sind
|
||||||
# http://-Adressen für Firewalls abgelehnt (beim Anlegen UND bei jeder
|
# http://-Adressen für Firewalls abgelehnt (beim Anlegen UND bei jeder
|
||||||
|
|||||||
@@ -59,3 +59,5 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: python3 scripts/check_i18n.py
|
- run: python3 scripts/check_i18n.py
|
||||||
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
|
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
|
||||||
|
# Updater-Container (Zustandsübergänge, Ablehnungen, Auth) ohne Docker-Daemon.
|
||||||
|
- run: python3 -m unittest discover -s updater -p 'test_*.py'
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
# Berechtigung "write:package" eintragen)
|
# Berechtigung "write:package" eintragen)
|
||||||
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
|
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
|
||||||
# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration.
|
# 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).
|
# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen).
|
||||||
#
|
#
|
||||||
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
|
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
service: [backend, frontend, proxy]
|
service: [backend, frontend, proxy, updater]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,80 @@ den Einstellungen.
|
|||||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||||
ansteht): Merlin …
|
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
|
## [1.12.0] "Condor" — 2026-10-09
|
||||||
|
|
||||||
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
|
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
|
||||||
|
|||||||
+17
-6
@@ -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)
|
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **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
|
## 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.
|
- **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.
|
- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume.
|
||||||
- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
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
|
## Schnellstart
|
||||||
|
|
||||||
Voraussetzung: Docker mit Compose-Plugin.
|
Voraussetzung: Docker mit Compose-Plugin.
|
||||||
@@ -35,19 +44,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
|
|||||||
docker compose up -d --build
|
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
|
```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).
|
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`:
|
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.12.0
|
OPNEXUS_VERSION=1.16.0
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
|||||||
|
|
||||||
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
|
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **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
|
## 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.
|
- **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.
|
- **Hierarchical policies** — pre/post rulebases with inheritance across group trees.
|
||||||
- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
All screenshots show fictitious sample data.
|
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
|
## Quick start
|
||||||
|
|
||||||
Requires Docker with the Compose plugin.
|
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
|
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
|
```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).
|
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`:
|
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.12.0
|
OPNEXUS_VERSION=1.16.0
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```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).
|
`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
|
## Privacy
|
||||||
|
|
||||||
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
user = await auth_service.resolve_session(db, token)
|
user = await auth_service.resolve_session(db, token)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
parts = request.url.path.strip("/").split("/")
|
parts = request.url.path.strip("/").split("/")
|
||||||
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user"}
|
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")
|
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
||||||
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
|
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
|
||||||
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
|
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ class Settings(BaseSettings):
|
|||||||
update_check_url: str = ""
|
update_check_url: str = ""
|
||||||
update_check_interval_hours: int = 12
|
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 "*"
|
# Cookies now carry the session, so this can no longer be a bare "*"
|
||||||
# (the Fetch spec forbids credentialed requests against a wildcard
|
# (the Fetch spec forbids credentialed requests against a wildcard
|
||||||
# response) — see main.py's allow_origin_regex. Default only matches
|
# response) — see main.py's allow_origin_regex. Default only matches
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
|
|
||||||
class ErrorCode(str, Enum):
|
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_EMPTY = "BACKUP_EMPTY"
|
||||||
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
|
||||||
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
|
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ from app.routers import openvpn as openvpn_router
|
|||||||
from app.routers import wireguard as wireguard_router
|
from app.routers import wireguard as wireguard_router
|
||||||
from app.routers import certificates as certificates_router
|
from app.routers import certificates as certificates_router
|
||||||
from app.routers import network as network_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.routers import restore as restore_router
|
||||||
from app.version import APP_VERSION
|
from app.version import APP_VERSION
|
||||||
from app.routers import dashboard as dashboard_router
|
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(cluster_router.router, prefix="/api")
|
||||||
app.include_router(services_router.router, prefix="/api")
|
app.include_router(services_router.router, prefix="/api")
|
||||||
app.include_router(network_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(resource_samples_router.router, prefix="/api")
|
||||||
app.include_router(scheduled_maintenance_router.router, prefix="/api")
|
app.include_router(scheduled_maintenance_router.router, prefix="/api")
|
||||||
@@ -48,6 +48,7 @@ class NotificationSettings(Base):
|
|||||||
maintenance_unknown_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
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")
|
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")
|
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")
|
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)
|
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import ForeignKey, String
|
from sqlalchemy import Boolean, ForeignKey, String
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from app.database import Base
|
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)
|
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')
|
theme: Mapped[str] = mapped_column(String(10), default='system')
|
||||||
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
|
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')
|
||||||
@@ -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)))
|
||||||
@@ -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)
|
||||||
@@ -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)
|
return await network_change_service.item(host, kind, item_uuid)
|
||||||
except OpnsenseAPIError as exc:
|
except OpnsenseAPIError as exc:
|
||||||
raise _upstream_error(host, exc) from exc
|
raise _upstream_error(host, exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/objects/{kind}")
|
||||||
|
async def list_objects(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Rows of a generic object kind (tunables, cron jobs, LAGG, ...), reduced to their display columns."""
|
||||||
|
if kind not in network_change_service.OBJECT_COLUMNS:
|
||||||
|
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||||
|
host = await _host_or_404(db, host_id)
|
||||||
|
try:
|
||||||
|
return await network_change_service.list_objects(host, kind)
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
raise _upstream_error(host, exc) from exc
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.services import network_drift_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/network", tags=["network"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/drift")
|
||||||
|
async def network_drift(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Per cluster: differences in the network configuration between its nodes (live read of every node)."""
|
||||||
|
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
|
||||||
|
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
|
||||||
|
for host in hosts:
|
||||||
|
if host.cluster_name:
|
||||||
|
by_cluster[host.cluster_name].append(host)
|
||||||
|
clusters = []
|
||||||
|
for name, members in sorted(by_cluster.items()):
|
||||||
|
if len(members) < 2:
|
||||||
|
continue
|
||||||
|
differences, compared, unreachable = await network_drift_service.compute_cluster(members)
|
||||||
|
clusters.append({"name": name, "hosts": [h.name for h in members], "compared": compared,
|
||||||
|
"unreachable": unreachable, "differences": differences})
|
||||||
|
return {"clusters": clusters}
|
||||||
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
|
|||||||
class Preferences(BaseModel):
|
class Preferences(BaseModel):
|
||||||
theme: Literal['light','dark','system'] | None = None
|
theme: Literal['light','dark','system'] | None = None
|
||||||
language: Literal['de','en'] | None = None
|
language: Literal['de','en'] | None = None
|
||||||
|
setup_done: bool | None = None
|
||||||
|
|
||||||
@router.get('')
|
@router.get('')
|
||||||
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
|
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
|
||||||
preferences=await db.get(UserPreferences,user.id)
|
preferences=await db.get(UserPreferences,user.id)
|
||||||
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
|
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
|
||||||
'language':preferences.language if preferences else 'de',
|
'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,
|
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||||
'session_lifetime_seconds':settings.session_lifetime}
|
'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)
|
current = await db.get(UserPreferences, user.id)
|
||||||
theme = payload.theme or (current.theme if current else 'system')
|
theme = payload.theme or (current.theme if current else 'system')
|
||||||
language = payload.language or (current.language if current else 'de')
|
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()
|
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,
|
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
|
||||||
'session_lifetime_seconds':settings.session_lifetime}
|
'session_lifetime_seconds':settings.session_lifetime}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.routers.hosts import _upstream_error
|
||||||
|
from app.services import snapshot_service
|
||||||
|
from app.services.host_maintenance_service import ensure_no_maintenance
|
||||||
|
from app.services.opnsense_client import OpnsenseAPIError
|
||||||
|
from app.services.policy_service import policy_lock
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/hosts/{host_id}/snapshots", tags=["snapshots"])
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
note: str = Field(default="", max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||||
|
host = await db.get(OpnsenseHost, host_id)
|
||||||
|
if not host:
|
||||||
|
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_snapshots(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
host = await _host(db, host_id)
|
||||||
|
try:
|
||||||
|
return await snapshot_service.list_snapshots(host)
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
raise _upstream_error(host, exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_snapshot(host_id: uuid.UUID, payload: SnapshotCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
await policy_lock(db)
|
||||||
|
host = await _host(db, host_id)
|
||||||
|
await ensure_no_maintenance(db, [host])
|
||||||
|
return await snapshot_service.create(host, payload.name, payload.note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{snapshot_uuid}", status_code=204)
|
||||||
|
async def delete_snapshot(host_id: uuid.UUID, snapshot_uuid: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
await policy_lock(db)
|
||||||
|
host = await _host(db, host_id)
|
||||||
|
await ensure_no_maintenance(db, [host])
|
||||||
|
if not snapshot_uuid.replace("-", "").isalnum() or len(snapshot_uuid) > 64:
|
||||||
|
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||||
|
await snapshot_service.delete(host, snapshot_uuid)
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.services import update_check_service
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.user import AdminUser
|
||||||
|
from app.routers.auth import current_user
|
||||||
|
from app.services import update_check_service, updater_client
|
||||||
|
|
||||||
router = APIRouter(prefix="/system", tags=["system"])
|
router = APIRouter(prefix="/system", tags=["system"])
|
||||||
|
|
||||||
@@ -10,3 +14,93 @@ async def version_check() -> dict:
|
|||||||
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
|
||||||
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
|
||||||
return await update_check_service.check()
|
return await update_check_service.check()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -135,7 +135,8 @@ class HostNetwork(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class NetworkChangeRequest(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"]
|
action: Literal["add", "set", "del"]
|
||||||
# Goes into a URL path, so only plain identifiers.
|
# Goes into a URL path, so only plain identifiers.
|
||||||
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
|
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.config import settings
|
|||||||
SmtpSecurity = Literal["none", "starttls", "ssl"]
|
SmtpSecurity = Literal["none", "starttls", "ssl"]
|
||||||
ProblemType = Literal[
|
ProblemType = Literal[
|
||||||
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
||||||
"carp_split_brain", "nat_drift", "carp_auto_revert_failed",
|
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ class NotificationSettingsRead(BaseModel):
|
|||||||
maintenance_unknown_enabled: bool
|
maintenance_unknown_enabled: bool
|
||||||
carp_health_enabled: bool
|
carp_health_enabled: bool
|
||||||
nat_drift_enabled: bool
|
nat_drift_enabled: bool
|
||||||
|
network_drift_enabled: bool = True
|
||||||
carp_auto_revert_failed_enabled: bool
|
carp_auto_revert_failed_enabled: bool
|
||||||
|
|
||||||
poll_interval_seconds: int
|
poll_interval_seconds: int
|
||||||
@@ -71,6 +72,7 @@ class NotificationSettingsUpdate(BaseModel):
|
|||||||
maintenance_unknown_enabled: bool | None = None
|
maintenance_unknown_enabled: bool | None = None
|
||||||
carp_health_enabled: bool | None = None
|
carp_health_enabled: bool | None = None
|
||||||
nat_drift_enabled: bool | None = None
|
nat_drift_enabled: bool | None = None
|
||||||
|
network_drift_enabled: bool | None = None
|
||||||
carp_auto_revert_failed_enabled: bool | None = None
|
carp_auto_revert_failed_enabled: bool | None = None
|
||||||
|
|
||||||
poll_interval_seconds: int | None = Field(
|
poll_interval_seconds: int | None = Field(
|
||||||
|
|||||||
@@ -367,7 +367,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
|
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
|
||||||
" generated password:\n\n %s\n\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,
|
"=" * 64, settings.admin_username, password, "=" * 64,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -48,6 +48,18 @@ FIELDS: dict[str, set[str]] = {
|
|||||||
"kea_subnet": {"subnet", "pools", "valid_lifetime", "next_server", "description", "option_data.routers",
|
"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"},
|
"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"},
|
"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",
|
"interface": {"if", "descr", "enable", "type4", "ipaddr", "gateway", "type6", "ipaddrv6", "gatewayv6", "mtu",
|
||||||
"blockpriv", "blockbogons", "lock"},
|
"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",
|
SECTION = {"vlan": "vlans", "vip": "vips", "gateway": "gateways", "route": "routes", "interface": "assignments",
|
||||||
"dns_host": "dns_hosts", "dhcp_host": "dhcp_hosts", "kea_subnet": "kea_subnets",
|
"dns_host": "dns_hosts", "dhcp_host": "dhcp_hosts", "kea_subnet": "kea_subnets",
|
||||||
"kea_reservation": "kea_reservations"}
|
"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.
|
# 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]")
|
_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_iface = next((i for i in net.interfaces if i.device == mgmt_device), None)
|
||||||
mgmt_ident = mgmt_iface.identifier if mgmt_iface else 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]))
|
blocks.append(_risk("api_unavailable", section=SECTION[kind]))
|
||||||
if mgmt_device is None and kind in WIRING_KINDS:
|
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.
|
# 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:
|
if action in ("set", "del") and uuid in ("wan", "lan") and uuid != mgmt_ident:
|
||||||
warnings.append(_risk("core_interface", name=uuid))
|
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"):
|
if kind == "vlan" and action in ("set", "del"):
|
||||||
device = next((v.device for v in net.vlans if v.uuid == uuid), None)
|
device = next((v.device for v in net.vlans if v.uuid == uuid), None)
|
||||||
if device and device == mgmt_device:
|
if device and device == mgmt_device:
|
||||||
@@ -231,6 +274,26 @@ async def _peers(db: AsyncSession, host: OpnsenseHost) -> list[str]:
|
|||||||
return list(rows.all())
|
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]:
|
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)."""
|
"""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)
|
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)
|
||||||
@@ -14,7 +14,7 @@ from app.models.one_to_one_deployment import OneToOneDeployment
|
|||||||
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||||
from app.models.port_forward_deployment import PortForwardDeployment
|
from app.models.port_forward_deployment import PortForwardDeployment
|
||||||
from app.models.rule_deployment import RuleDeployment
|
from app.models.rule_deployment import RuleDeployment
|
||||||
from app.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.notification_dedup import Problems
|
||||||
from app.services.host_status_service import get_statuses
|
from app.services.host_status_service import get_statuses
|
||||||
from app.services.live_certificates_service import get_live_certificates
|
from app.services.live_certificates_service import get_live_certificates
|
||||||
@@ -148,6 +148,40 @@ async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
|
|||||||
return problems
|
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]:
|
async def check_revert_failed(db: AsyncSession) -> dict[str, dict]:
|
||||||
problems: dict[str, dict] = {}
|
problems: dict[str, dict] = {}
|
||||||
for deployment_type, model, id_attr in _DEPLOYMENT_MODELS:
|
for deployment_type, model, id_attr in _DEPLOYMENT_MODELS:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ _LABELS = {
|
|||||||
"maintenance_unknown": "Wartungsergebnis unklar",
|
"maintenance_unknown": "Wartungsergebnis unklar",
|
||||||
"carp_split_brain": "CARP-Zustand kritisch",
|
"carp_split_brain": "CARP-Zustand kritisch",
|
||||||
"nat_drift": "NAT-Drift im Cluster",
|
"nat_drift": "NAT-Drift im Cluster",
|
||||||
|
"network_drift": "Netzwerk-Abweichung im Cluster",
|
||||||
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ _CHECKS = [
|
|||||||
("maintenance_unknown", "maintenance_unknown_enabled", notification_checks.check_maintenance_unknown),
|
("maintenance_unknown", "maintenance_unknown_enabled", notification_checks.check_maintenance_unknown),
|
||||||
("carp_split_brain", "carp_health_enabled", notification_checks.check_carp_health),
|
("carp_split_brain", "carp_health_enabled", notification_checks.check_carp_health),
|
||||||
("nat_drift", "nat_drift_enabled", notification_checks.check_nat_drift),
|
("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),
|
("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",
|
"smtp_from_address", "smtp_to_addresses",
|
||||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
||||||
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
||||||
"carp_auto_revert_failed_enabled",
|
"network_drift_enabled", "carp_auto_revert_failed_enabled",
|
||||||
"poll_interval_seconds",
|
"poll_interval_seconds",
|
||||||
]
|
]
|
||||||
for field in simple_fields:
|
for field in simple_fields:
|
||||||
|
|||||||
@@ -1101,6 +1101,34 @@ NETWORK_ITEM_APIS = {
|
|||||||
"kea_subnet": {"body_key": "subnet4", "base": "/kea/dhcpv4", "add": "addSubnet", "set": "setSubnet",
|
"kea_subnet": {"body_key": "subnet4", "base": "/kea/dhcpv4", "add": "addSubnet", "set": "setSubnet",
|
||||||
"del": "delSubnet", "get": "getSubnet", "search": "searchSubnet",
|
"del": "delSubnet", "get": "getSubnet", "search": "searchSubnet",
|
||||||
"reconfigure": "/kea/service/reconfigure"},
|
"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",
|
"kea_reservation": {"body_key": "reservation", "base": "/kea/dhcpv4", "add": "addReservation",
|
||||||
"set": "setReservation", "del": "delReservation", "get": "getReservation",
|
"set": "setReservation", "del": "delReservation", "get": "getReservation",
|
||||||
"search": "searchReservation", "reconfigure": "/kea/service/reconfigure"},
|
"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:
|
async def search_kea_reservations(host: OpnsenseHost) -> dict:
|
||||||
return await _search_all(host, "/kea/dhcpv4/searchReservation")
|
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}")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""ZFS snapshots (boot environments) of one firewall: list, create, delete.
|
||||||
|
|
||||||
|
Snapshots are a safety net before bigger changes, not a rollback button:
|
||||||
|
activating one needs a reboot and discards everything since, so that action is
|
||||||
|
deliberately NOT offered here. Deleting refuses the active/next-boot snapshot
|
||||||
|
(the firewall refuses too; we say so up front). Every step is audited.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.errors import ErrorCode, api_error
|
||||||
|
from app.models.host import OpnsenseHost
|
||||||
|
from app.services import audit_service, opnsense_client
|
||||||
|
from app.services.opnsense_client import OpnsenseAPIError
|
||||||
|
|
||||||
|
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _row(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"uuid": str(row.get("uuid", "")), "name": str(row.get("name", "")), "active": str(row.get("active", "")),
|
||||||
|
"size": str(row.get("size", "")), "created": str(row.get("created_str", "")),
|
||||||
|
"mountpoint": str(row.get("mountpoint", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_snapshots(host: OpnsenseHost) -> dict:
|
||||||
|
try:
|
||||||
|
supported = (await opnsense_client._request(host, "GET", "/core/snapshots/isSupported")).get("supported")
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
if "404" in str(exc):
|
||||||
|
return {"supported": False, "rows": []}
|
||||||
|
raise
|
||||||
|
if not supported:
|
||||||
|
return {"supported": False, "rows": []}
|
||||||
|
return {"supported": True, "rows": [_row(r) for r in await opnsense_client.list_snapshots(host)]}
|
||||||
|
|
||||||
|
|
||||||
|
async def create(host: OpnsenseHost, name: str, note: str) -> dict:
|
||||||
|
if not NAME.match(name):
|
||||||
|
raise api_error(422, ErrorCode.NETWORK_FIELD_INVALID, field="name")
|
||||||
|
audit = {"kind": "snapshot", "action": "snapshot.add", "entity_name": name, "host_id": str(host.id), "host_name": host.name}
|
||||||
|
await audit_service.persist(**audit, status="started", message=None, details={"note": note[:200]})
|
||||||
|
try:
|
||||||
|
await opnsense_client.create_snapshot(host, name, note[:200])
|
||||||
|
created = next((r for r in await opnsense_client.list_snapshots(host) if r.get("name") == name), None)
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
|
||||||
|
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||||
|
await audit_service.persist(**audit, status="applied" if created else "unverified", message=None, details={})
|
||||||
|
if not created:
|
||||||
|
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
|
||||||
|
return _row(created)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(host: OpnsenseHost, uuid: str) -> None:
|
||||||
|
rows = await opnsense_client.list_snapshots(host)
|
||||||
|
target = next((r for r in rows if str(r.get("uuid")) == uuid), None)
|
||||||
|
if target is None:
|
||||||
|
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
|
||||||
|
if str(target.get("active", "")).strip() not in ("", "-"):
|
||||||
|
# N = active now, R = active after reboot: removing either would break the boot environment.
|
||||||
|
raise api_error(409, ErrorCode.NETWORK_CHANGE_BLOCKED, reasons="snapshot_active")
|
||||||
|
audit = {"kind": "snapshot", "action": "snapshot.del", "entity_name": str(target.get("name")), "host_id": str(host.id), "host_name": host.name}
|
||||||
|
await audit_service.persist(**audit, status="started", message=None, details={})
|
||||||
|
try:
|
||||||
|
await opnsense_client.delete_snapshot(host, uuid)
|
||||||
|
still = any(str(r.get("uuid")) == uuid for r in await opnsense_client.list_snapshots(host))
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
await audit_service.persist(**audit, status="rejected", message=str(exc)[:500], details={})
|
||||||
|
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)[:300]) from exc
|
||||||
|
await audit_service.persist(**audit, status="unverified" if still else "applied", message=None, details={})
|
||||||
|
if still:
|
||||||
|
raise api_error(502, ErrorCode.NETWORK_APPLY_UNCERTAIN, host=host.name, backup_id="-")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Client für den optionalen OPNexus-Updater-Container (updater/server.py).
|
||||||
|
|
||||||
|
Das Backend kann selbst nichts am Host ändern und soll das auch nicht können:
|
||||||
|
Es fragt den Updater-Dienst nach dem Zustand und reicht eine bereits
|
||||||
|
geprüfte Update-/Rollback-Anfrage weiter. Ist der Dienst nicht aktiviert oder
|
||||||
|
nicht erreichbar, ist das ein normaler Zustand ("nicht verfügbar"), kein Fehler.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_STATUS_TIMEOUT = 50.0 # der Updater fragt docker ab (bis zu 45 s)
|
||||||
|
_ACTION_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
class UpdaterUnavailable(Exception):
|
||||||
|
"""Updater nicht aktiviert (kein Token) oder nicht erreichbar."""
|
||||||
|
|
||||||
|
|
||||||
|
class UpdaterRejected(Exception):
|
||||||
|
def __init__(self, code: str):
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
try:
|
||||||
|
token = Path(settings.updater_token_file).read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
token = ""
|
||||||
|
if not token:
|
||||||
|
raise UpdaterUnavailable("not_enabled")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def _call(method: str, path: str, payload: dict | None, timeout: float) -> dict:
|
||||||
|
headers = {"Authorization": f"Bearer {_token()}"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as http:
|
||||||
|
response = await http.request(method, settings.updater_url.rstrip("/") + path, headers=headers, json=payload)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.info("Updater nicht erreichbar: %s", exc.__class__.__name__)
|
||||||
|
raise UpdaterUnavailable("unreachable") from exc
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = {}
|
||||||
|
if response.status_code == 401:
|
||||||
|
# Token passt nicht (Volume neu angelegt?) — wie "nicht verfügbar" behandeln.
|
||||||
|
raise UpdaterUnavailable("unauthorized")
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise UpdaterRejected(str(body.get("error") or "rejected"))
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def status() -> dict:
|
||||||
|
return await _call("GET", "/status", None, _STATUS_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_update(version: str) -> dict:
|
||||||
|
return await _call("POST", "/update", {"version": version}, _ACTION_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_rollback() -> dict:
|
||||||
|
return await _call("POST", "/rollback", {}, _ACTION_TIMEOUT)
|
||||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
|||||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "1.12.0"
|
APP_VERSION = "1.16.0"
|
||||||
APP_CODENAME = "Condor"
|
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")
|
||||||
@@ -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']}")
|
||||||
@@ -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()
|
plan = (await _preview(client, host_id, **change)).json()
|
||||||
done = (await _execute(client, host_id, plan["token"], **change)).json()
|
done = (await _execute(client, host_id, plan["token"], **change)).json()
|
||||||
assert done["verified"] is False and done["mismatches"] == ["network"]
|
assert done["verified"] is False and done["mismatches"] == ["network"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── generic object kinds ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_objects_list_returns_only_the_display_columns(client, respx_mock):
|
||||||
|
host_id = await _setup(client, respx_mock, "fw-o1")
|
||||||
|
respx_mock.get(_url("/syslog/settings/searchDestinations")).mock(return_value=httpx.Response(200, json={"rows": [
|
||||||
|
{"uuid": "d1", "enabled": "1", "transport": "udp4", "hostname": "192.0.2.10", "port": "514", "program": "", "level": "info",
|
||||||
|
"facility": "daemon", "certificate": "SECRET-CERT-REF", "description": "central"}]}))
|
||||||
|
body = (await client.get(f"/api/hosts/{host_id}/network/objects/syslog_destination")).json()
|
||||||
|
assert body["available"] is True and body["rows"][0]["hostname"] == "192.0.2.10"
|
||||||
|
assert "certificate" not in body["rows"][0] and "facility" not in body["rows"][0]
|
||||||
|
assert (await client.get(f"/api/hosts/{host_id}/network/objects/vlan")).status_code == 404 # typed kinds have their own lists
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_objects_list_reports_a_missing_api_instead_of_failing(client, respx_mock):
|
||||||
|
host_id = await _setup(client, respx_mock, "fw-o2")
|
||||||
|
respx_mock.get(_url("/interfaces/gre_settings/searchItem")).mock(return_value=httpx.Response(404, json={"errorMessage": "Endpoint not found"}))
|
||||||
|
body = (await client.get(f"/api/hosts/{host_id}/network/objects/gre")).json()
|
||||||
|
assert body == {"available": False, "columns": body["columns"], "rows": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tunables_always_need_acknowledgement_and_defaults_cannot_be_edited(client, respx_mock):
|
||||||
|
host_id = await _setup(client, respx_mock, "fw-o3")
|
||||||
|
add = (await _preview(client, host_id, kind="tunable", action="add", fields={"tunable": "vfs.read_max", "value": "128"})).json()
|
||||||
|
assert [r["code"] for r in add["risks"]] == ["tunable_change"] and add["requires_acknowledgement"] is True
|
||||||
|
_item(respx_mock, "/core/tunables/getItem/0add3c58db981d0d24d5d4ab322e3695", "sysctl",
|
||||||
|
{"tunable": "net.inet.tcp.drop_synfin", "value": "1", "descr": "built in"})
|
||||||
|
default = (await _preview(client, host_id, kind="tunable", action="set", uuid="0add3c58db981d0d24d5d4ab322e3695", fields={"value": "0"})).json()
|
||||||
|
assert [b["code"] for b in default["blocks"]] == ["tunable_default_row"] and default["can_execute"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lagg_or_bridge_containing_the_management_device_needs_acknowledgement(client, respx_mock):
|
||||||
|
host_id = await _setup(client, respx_mock, "fw-o4") # reached on vtnet0
|
||||||
|
mine = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet0,vtnet2", "proto": "failover"})).json()
|
||||||
|
assert [r["code"] for r in mine["risks"]] == ["management_member"]
|
||||||
|
other = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet2,vtnet3", "proto": "failover"})).json()
|
||||||
|
assert other["risks"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vxlan_and_gif_are_listed_and_changed_like_the_other_devices(client, respx_mock):
|
||||||
|
host_id = await _setup(client, respx_mock, "fw-o5")
|
||||||
|
respx_mock.get(_url("/interfaces/vxlan_settings/searchItem")).mock(return_value=httpx.Response(200, json={"rows": [
|
||||||
|
{"uuid": "x1", "vxlanif": "vxlan1", "vxlanid": "100", "vxlanlocal": "10.20.30.57", "vxlanremote": "10.20.30.58",
|
||||||
|
"vxlangroup": "", "vxlandev": "lan", "secret_like": "dropped"}]}))
|
||||||
|
body = (await client.get(f"/api/hosts/{host_id}/network/objects/vxlan")).json()
|
||||||
|
assert body["rows"][0]["vxlanid"] == "100" and "secret_like" not in body["rows"][0]
|
||||||
|
_backup(respx_mock)
|
||||||
|
add = respx_mock.post(_url("/interfaces/gif_settings/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "g1"}))
|
||||||
|
respx_mock.post(_url("/interfaces/gif_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
||||||
|
_item(respx_mock, "/interfaces/gif_settings/getItem/g1", "gif", {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"})
|
||||||
|
change = {"kind": "gif", "action": "add", "fields": {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"}}
|
||||||
|
plan = (await _preview(client, host_id, **change)).json()
|
||||||
|
done = await _execute(client, host_id, plan["token"], **change)
|
||||||
|
assert done.status_code == 200 and done.json()["verified"] is True, done.text
|
||||||
|
assert _body(add.calls.last) == {"gif": change["fields"]}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Network configuration differences between the nodes of an HA cluster."""
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.schemas.network import HostNetwork
|
||||||
|
from app.services import network_drift_service, notification_checks
|
||||||
|
from tests.test_network import DHCP_HOSTS, DNS_HOSTS, GATEWAYS, INTERFACES_INFO, KEA_RESERVATIONS, KEA_SUBNETS, ROUTES, VIPS, VLANS, ASSIGNMENTS
|
||||||
|
|
||||||
|
ENDPOINTS = {
|
||||||
|
"/interfaces/overview/interfacesInfo": INTERFACES_INFO, "/interfaces/assignment/searchItem": ASSIGNMENTS,
|
||||||
|
"/interfaces/vlan_settings/searchItem": VLANS, "/interfaces/vip_settings/searchItem": VIPS,
|
||||||
|
"/routing/settings/searchGateway": GATEWAYS, "/routes/routes/searchroute": ROUTES,
|
||||||
|
"/unbound/settings/searchHostOverride": DNS_HOSTS, "/dnsmasq/settings/searchHost": DHCP_HOSTS,
|
||||||
|
"/kea/dhcpv4/searchSubnet": KEA_SUBNETS, "/kea/dhcpv4/searchReservation": KEA_RESERVATIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _host(client, name, base, cluster="ha"):
|
||||||
|
r = await client.post("/api/hosts", json={"name": name, "base_url": base, "api_key": "k", "api_secret": "s",
|
||||||
|
"verify_tls": False, "cluster_name": cluster})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def _serve(respx_mock, base, **replace):
|
||||||
|
for path, body in ENDPOINTS.items():
|
||||||
|
respx_mock.get(f"{base}/api{path}").mock(return_value=httpx.Response(200, json=replace.get(path, body)))
|
||||||
|
|
||||||
|
|
||||||
|
def _net(**replace) -> HostNetwork:
|
||||||
|
data = {
|
||||||
|
"host_id": "x", "host_name": "x", "interfaces": [], "vlans": [], "vips": [], "gateways": [], "routes": [],
|
||||||
|
"management_device": None, "can_configure_interfaces": True, "unavailable": [],
|
||||||
|
}
|
||||||
|
data.update(replace)
|
||||||
|
return HostNetwork(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_configuration_has_no_differences_even_with_per_node_values():
|
||||||
|
vip_a = {"uuid": "1", "interface": "lan", "interface_label": "LAN", "mode": "carp", "address": "192.168.1.1/24",
|
||||||
|
"vhid": "10", "advbase": "1", "advskew": "0", "description": "x"}
|
||||||
|
vip_b = {**vip_a, "uuid": "2", "advskew": "100"} # advskew legitimately differs between master and backup
|
||||||
|
a, b = _net(vips=[vip_a]), _net(vips=[vip_b])
|
||||||
|
assert network_drift_service.compare({"a": a, "b": b}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_and_different_objects_are_reported_with_who_has_what():
|
||||||
|
vlan = {"uuid": "1", "parent": "vtnet1", "tag": "10", "pcp": "0", "description": "LAN", "device": "vtnet1_vlan10"}
|
||||||
|
gw_a = {"uuid": "g", "name": "WAN_GW", "description": "", "interface": "wan", "interface_label": "WAN", "ip_protocol": "inet",
|
||||||
|
"address": "10.0.0.1", "is_default": True, "disabled": False, "priority": "255", "status": "Online"}
|
||||||
|
a = _net(vlans=[vlan, {**vlan, "uuid": "2", "tag": "20"}], gateways=[gw_a])
|
||||||
|
b = _net(vlans=[vlan], gateways=[{**gw_a, "address": "10.0.0.9"}])
|
||||||
|
rows = {(r["category"], r["key"]): r for r in network_drift_service.compare({"a": a, "b": b})}
|
||||||
|
assert rows[("vlans", "20@vtnet1")]["status"] == "missing" and rows[("vlans", "20@vtnet1")]["missing_from"] == ["b"]
|
||||||
|
assert rows[("gateways", "WAN_GW")]["status"] == "different"
|
||||||
|
assert rows[("gateways", "WAN_GW")]["signatures"]["a"] != rows[("gateways", "WAN_GW")]["signatures"]["b"]
|
||||||
|
assert ("vlans", "10@vtnet1") not in rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_category_one_node_cannot_deliver_is_not_reported_as_a_difference():
|
||||||
|
vlan = {"uuid": "1", "parent": "vtnet1", "tag": "10", "pcp": "0", "description": "", "device": "d"}
|
||||||
|
a, b = _net(vlans=[vlan]), _net(vlans=[], unavailable=["vlans"])
|
||||||
|
assert network_drift_service.compare({"a": a, "b": b}) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_endpoint_and_alert_report_a_route_missing_on_one_node(client, respx_mock):
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
await _host(client, "fw-da", "https://10.20.30.91")
|
||||||
|
await _host(client, "fw-db", "https://10.20.30.92")
|
||||||
|
_serve(respx_mock, "https://10.20.30.91")
|
||||||
|
_serve(respx_mock, "https://10.20.30.92", **{"/routes/routes/searchroute": {"rows": []}})
|
||||||
|
body = (await client.get("/api/network/drift")).json()
|
||||||
|
cluster = next(c for c in body["clusters"] if c["name"] == "ha")
|
||||||
|
assert cluster["unreachable"] == []
|
||||||
|
diff = [d for d in cluster["differences"] if d["category"] == "routes"]
|
||||||
|
assert [(d["key"], d["status"], d["missing_from"]) for d in diff] == [("10.9.0.0/16", "missing", ["fw-db"])]
|
||||||
|
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
problems = await notification_checks.check_network_drift(db)
|
||||||
|
mine = [v for k, v in problems.items() if k.startswith("ha:routes:")]
|
||||||
|
assert mine and mine[0]["missing_from"] == ["fw-db"] and "Route" in mine[0]["object"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_unreachable_node_keeps_open_alerts_instead_of_resolving_them(client, respx_mock):
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
await _host(client, "fw-dc", "https://10.20.30.93", cluster="ha2")
|
||||||
|
await _host(client, "fw-dd", "https://10.20.30.94", cluster="ha2")
|
||||||
|
_serve(respx_mock, "https://10.20.30.93")
|
||||||
|
respx_mock.get(url__regex=r"https://10\.20\.30\.94/.*").mock(side_effect=httpx.ConnectError("down"))
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
problems = await notification_checks.check_network_drift(db)
|
||||||
|
assert "ha2:" in problems.preserve_prefixes
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""First-run setup assistant: per-user "done" flag and the password reset tool."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import reset_password
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.user import AdminUser, Session
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setup_done_defaults_to_false_and_persists(client):
|
||||||
|
assert (await client.get("/api/settings")).json()["setup_done"] is False
|
||||||
|
|
||||||
|
resp = await client.put("/api/settings", json={"setup_done": True})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["setup_done"] is True
|
||||||
|
assert (await client.get("/api/settings")).json()["setup_done"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_other_preference_changes_keep_setup_done(client):
|
||||||
|
await client.put("/api/settings", json={"setup_done": True})
|
||||||
|
resp = await client.put("/api/settings", json={"theme": "dark", "language": "en"})
|
||||||
|
body = resp.json()
|
||||||
|
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", True)
|
||||||
|
|
||||||
|
# ...and the other direction: saving setup_done leaves theme/language alone.
|
||||||
|
resp = await client.put("/api/settings", json={"setup_done": False})
|
||||||
|
body = resp.json()
|
||||||
|
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setup_done_rejects_non_boolean(client):
|
||||||
|
assert (await client.put("/api/settings", json={"setup_done": "maybe"})).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_password_prints_new_password_and_ends_sessions(client, capsys):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
before = (await db.execute(select(Session))).scalars().all()
|
||||||
|
assert before, "the client fixture logged in, so a session must exist"
|
||||||
|
|
||||||
|
assert await reset_password.main(None) == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
lines = dict(line.split(": ", 1) for line in out.strip().splitlines())
|
||||||
|
assert lines["Username"] == settings.admin_username
|
||||||
|
new_password = lines["Password"]
|
||||||
|
assert len(new_password) >= 20 and new_password != settings.admin_password
|
||||||
|
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert (await db.execute(select(Session))).scalars().all() == []
|
||||||
|
user = (await db.execute(select(AdminUser).where(AdminUser.username == settings.admin_username))).scalar_one()
|
||||||
|
assert await auth_service.verify_password_async(user.password_hash, new_password)
|
||||||
|
assert not await auth_service.verify_password_async(user.password_hash, settings.admin_password)
|
||||||
|
# Put the shared bootstrap admin back for the tests that follow.
|
||||||
|
user.password_hash = await auth_service.hash_password_async(settings.admin_password)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_password_unknown_user_fails(capsys):
|
||||||
|
assert await reset_password.main("does-not-exist") == 1
|
||||||
|
assert "No such user" in capsys.readouterr().err
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""ZFS snapshots: list, create, delete (never the active one)."""
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
BASE = "https://10.20.30.80"
|
||||||
|
|
||||||
|
|
||||||
|
async def _host(client, respx_mock, name="fw-snap"):
|
||||||
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||||
|
return (await client.post("/api/hosts", json={"name": name, "base_url": BASE, "api_key": "k", "api_secret": "s"})).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _snaps(respx_mock, rows):
|
||||||
|
respx_mock.get(f"{BASE}/api/core/snapshots/isSupported").mock(return_value=httpx.Response(200, json={"supported": True}))
|
||||||
|
return respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": rows}))
|
||||||
|
|
||||||
|
|
||||||
|
ROW = {"uuid": "s1", "name": "default", "active": "NR", "mountpoint": "/", "size": "1.2G", "created_str": "2026-10-06 13:59"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_snapshots(client, respx_mock):
|
||||||
|
host_id = await _host(client, respx_mock)
|
||||||
|
_snaps(respx_mock, [ROW])
|
||||||
|
body = (await client.get(f"/api/hosts/{host_id}/snapshots")).json()
|
||||||
|
assert body["supported"] is True and body["rows"][0]["name"] == "default" and body["rows"][0]["active"] == "NR"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsupported_firewall_is_reported(client, respx_mock):
|
||||||
|
host_id = await _host(client, respx_mock, "fw-nozfs")
|
||||||
|
respx_mock.get(f"{BASE}/api/core/snapshots/isSupported").mock(return_value=httpx.Response(200, json={"supported": False}))
|
||||||
|
assert (await client.get(f"/api/hosts/{host_id}/snapshots")).json() == {"supported": False, "rows": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_is_confirmed_by_listing_again(client, respx_mock):
|
||||||
|
host_id = await _host(client, respx_mock, "fw-snap2")
|
||||||
|
new = {"uuid": "s2", "name": "before_change", "active": "-", "mountpoint": "-", "size": "0", "created_str": "now"}
|
||||||
|
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": [ROW, new]}))
|
||||||
|
post = respx_mock.post(f"{BASE}/api/core/snapshots/add").mock(return_value=httpx.Response(200, json="OK"))
|
||||||
|
resp = await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "before_change", "note": "x"})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["uuid"] == "s2" and post.called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_rejects_bad_names_and_reports_a_snapshot_that_never_appeared(client, respx_mock):
|
||||||
|
host_id = await _host(client, respx_mock, "fw-snap3")
|
||||||
|
assert (await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "bad name!"})).status_code == 422
|
||||||
|
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(return_value=httpx.Response(200, json={"rows": [ROW]}))
|
||||||
|
respx_mock.post(f"{BASE}/api/core/snapshots/add").mock(return_value=httpx.Response(200, json="OK"))
|
||||||
|
assert (await client.post(f"/api/hosts/{host_id}/snapshots", json={"name": "ghost"})).status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_active_snapshot_cannot_be_deleted_others_can(client, respx_mock):
|
||||||
|
host_id = await _host(client, respx_mock, "fw-snap4")
|
||||||
|
old = {"uuid": "s3", "name": "old", "active": "-", "mountpoint": "-", "size": "1M", "created_str": "x"}
|
||||||
|
state = {"rows": [ROW, old]}
|
||||||
|
respx_mock.get(f"{BASE}/api/core/snapshots/search").mock(side_effect=lambda r: httpx.Response(200, json=state))
|
||||||
|
def drop(request):
|
||||||
|
state["rows"] = [ROW]
|
||||||
|
return httpx.Response(200, json={"status": "ok"})
|
||||||
|
delete = respx_mock.post(f"{BASE}/api/core/snapshots/del/s3").mock(side_effect=drop)
|
||||||
|
blocked = await client.delete(f"/api/hosts/{host_id}/snapshots/s1")
|
||||||
|
assert blocked.status_code == 409 and blocked.json()["detail"]["params"]["reasons"] == "snapshot_active"
|
||||||
|
assert (await client.delete(f"/api/hosts/{host_id}/snapshots/s3")).status_code == 204 and delete.called
|
||||||
|
assert (await client.delete(f"/api/hosts/{host_id}/snapshots/nope")).status_code == 404
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.test_users import _login_as
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import update_check_service
|
||||||
|
from app.version import APP_VERSION
|
||||||
|
|
||||||
|
FEED = "https://feed.test/api/v1/repos/o/r/releases/latest"
|
||||||
|
UPDATER = "http://updater.test:8080"
|
||||||
|
|
||||||
|
|
||||||
|
def _bump(minor=0, patch=0, major=0):
|
||||||
|
a, b, c = (int(x) for x in APP_VERSION.split("."))
|
||||||
|
return f"{a + major}.{b + minor}.{c + patch}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(monkeypatch, tmp_path):
|
||||||
|
token = tmp_path / "token"
|
||||||
|
token.write_text("t" * 40)
|
||||||
|
monkeypatch.setattr(settings, "update_check_url", FEED)
|
||||||
|
monkeypatch.setattr(settings, "updater_url", UPDATER)
|
||||||
|
monkeypatch.setattr(settings, "updater_token_file", str(token))
|
||||||
|
update_check_service.reset_cache()
|
||||||
|
yield
|
||||||
|
update_check_service.reset_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _feed(respx_mock, version):
|
||||||
|
respx_mock.get(FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{version}"}))
|
||||||
|
|
||||||
|
|
||||||
|
def _updater_status(respx_mock, **over):
|
||||||
|
body = {"mode": "release", "running_version": APP_VERSION, "job": None, "phases": [], "log": [],
|
||||||
|
"services": {"backend": "healthy"}, "rollback_available": False, "rollback_loses_data": False,
|
||||||
|
"last_update": None}
|
||||||
|
body.update(over)
|
||||||
|
return respx_mock.get(f"{UPDATER}/status").mock(return_value=httpx.Response(200, json=body))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_reports_update_and_can_update(client, respx_mock):
|
||||||
|
_feed(respx_mock, _bump(minor=1))
|
||||||
|
_updater_status(respx_mock)
|
||||||
|
body = (await client.get("/api/system/updater")).json()
|
||||||
|
assert body["available"] is True and body["can_update"] is True
|
||||||
|
assert body["latest"] == _bump(minor=1) and body["major_change"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_without_token_is_unavailable_not_an_error(client, respx_mock, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
||||||
|
_feed(respx_mock, _bump(minor=1))
|
||||||
|
body = (await client.get("/api/system/updater")).json()
|
||||||
|
assert body["available"] is False and body["reason"] == "not_enabled" and body["can_update"] is False
|
||||||
|
assert body["update_available"] is True # der Hinweis bleibt trotzdem
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_unreachable_updater(client, respx_mock):
|
||||||
|
_feed(respx_mock, _bump(minor=1))
|
||||||
|
respx_mock.get(f"{UPDATER}/status").mock(side_effect=httpx.ConnectError("down"))
|
||||||
|
body = (await client.get("/api/system/updater")).json()
|
||||||
|
assert body["available"] is False and body["reason"] == "unreachable"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_major_change_is_not_offered_for_one_click(client, respx_mock):
|
||||||
|
_feed(respx_mock, _bump(major=1))
|
||||||
|
_updater_status(respx_mock)
|
||||||
|
body = (await client.get("/api/system/updater")).json()
|
||||||
|
assert body["major_change"] is True and body["can_update"] is False
|
||||||
|
resp = await client.post("/api/system/updater/update", json={"version": _bump(major=1)})
|
||||||
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_MAJOR_CHANGE"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_build_install_cannot_update(client, respx_mock):
|
||||||
|
_feed(respx_mock, _bump(minor=1))
|
||||||
|
_updater_status(respx_mock, mode="local")
|
||||||
|
body = (await client.get("/api/system/updater")).json()
|
||||||
|
assert body["can_update"] is False and body["mode"] == "local"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_forwards_only_the_feed_version(client, respx_mock):
|
||||||
|
newer = _bump(patch=1)
|
||||||
|
_feed(respx_mock, newer)
|
||||||
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
|
||||||
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
||||||
|
assert resp.status_code == 202
|
||||||
|
sent = route.calls.last.request
|
||||||
|
assert sent.headers["authorization"] == "Bearer " + "t" * 40
|
||||||
|
assert b'"version": "%s"' % newer.encode() in sent.content or newer.encode() in sent.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("version", ["0.0.1", "99.99.99", "1.0.0-rc1", "latest", "../../x"])
|
||||||
|
async def test_update_rejects_any_other_version(client, respx_mock, version):
|
||||||
|
_feed(respx_mock, _bump(patch=1))
|
||||||
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
|
||||||
|
resp = await client.post("/api/system/updater/update", json={"version": version})
|
||||||
|
assert resp.status_code in (409, 422)
|
||||||
|
assert route.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_when_no_update_available(client, respx_mock):
|
||||||
|
_feed(respx_mock, APP_VERSION)
|
||||||
|
resp = await client.post("/api/system/updater/update", json={"version": APP_VERSION})
|
||||||
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_NOT_OFFERED"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_busy_and_rejected_map_to_error_codes(client, respx_mock):
|
||||||
|
newer = _bump(patch=1)
|
||||||
|
_feed(respx_mock, newer)
|
||||||
|
route = respx_mock.post(f"{UPDATER}/update")
|
||||||
|
route.mock(return_value=httpx.Response(409, json={"error": "busy"}))
|
||||||
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
||||||
|
assert resp.json()["detail"]["code"] == "UPDATER_BUSY"
|
||||||
|
route.mock(return_value=httpx.Response(409, json={"error": "not_release_mode"}))
|
||||||
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
||||||
|
assert resp.json()["detail"] == {"code": "UPDATER_REJECTED", "params": {"reason": "not_release_mode"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_without_updater_is_503(client, respx_mock, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
||||||
|
newer = _bump(patch=1)
|
||||||
|
_feed(respx_mock, newer)
|
||||||
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
||||||
|
assert resp.status_code == 503 and resp.json()["detail"]["code"] == "UPDATER_UNAVAILABLE"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rollback_is_forwarded(client, respx_mock):
|
||||||
|
route = respx_mock.post(f"{UPDATER}/rollback").mock(return_value=httpx.Response(202, json={"job": {"id": "r"}}))
|
||||||
|
resp = await client.post("/api/system/updater/rollback")
|
||||||
|
assert resp.status_code == 202 and route.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_only_user_cannot_read_or_start_updates(client, respx_mock):
|
||||||
|
newer = _bump(patch=1)
|
||||||
|
_feed(respx_mock, newer)
|
||||||
|
status = _updater_status(respx_mock)
|
||||||
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
|
||||||
|
await client.post("/api/users", json={"username": "viewer-upd", "password": "password-123456", "role": "read_only"})
|
||||||
|
viewer = await _login_as("viewer-upd", "password-123456")
|
||||||
|
try:
|
||||||
|
assert (await viewer.get("/api/system/updater")).status_code == 403
|
||||||
|
assert (await viewer.post("/api/system/updater/update", json={"version": newer})).status_code == 403
|
||||||
|
assert (await viewer.post("/api/system/updater/rollback")).status_code == 403
|
||||||
|
finally:
|
||||||
|
await viewer.aclose()
|
||||||
|
assert status.call_count == 0 and route.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anonymous_is_rejected(anon_client):
|
||||||
|
assert (await anon_client.get("/api/system/updater")).status_code == 401
|
||||||
|
assert (await anon_client.post("/api/system/updater/rollback")).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_request_is_audited(client, respx_mock):
|
||||||
|
newer = _bump(patch=1)
|
||||||
|
_feed(respx_mock, newer)
|
||||||
|
respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
|
||||||
|
assert (await client.post("/api/system/updater/update", json={"version": newer})).status_code == 202
|
||||||
|
events = (await client.get("/api/audit?kind=system")).json()["items"]
|
||||||
|
assert {(e["action"], e["status"]) for e in events} >= {("update", "requested"), ("update", "accepted")}
|
||||||
@@ -16,3 +16,6 @@ services:
|
|||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
build: ./proxy
|
build: ./proxy
|
||||||
|
|
||||||
|
updater:
|
||||||
|
build: ./updater
|
||||||
@@ -120,6 +120,9 @@ services:
|
|||||||
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
|
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
|
||||||
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
|
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
|
||||||
UPDATE_CHECK_INTERVAL_HOURS: ${UPDATE_CHECK_INTERVAL_HOURS:-12}
|
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:
|
volumes:
|
||||||
- secrets:/data/opnsense-secrets
|
- secrets:/data/opnsense-secrets
|
||||||
- backups:/data/opnsense-backups
|
- backups:/data/opnsense-backups
|
||||||
@@ -133,6 +136,9 @@ services:
|
|||||||
# Gleiches Volume wie der postgres-backup-Service: hier landet das
|
# Gleiches Volume wie der postgres-backup-Service: hier landet das
|
||||||
# automatische Sicherheits-Backup vor einer Schema-Migration.
|
# automatische Sicherheits-Backup vor einer Schema-Migration.
|
||||||
- postgres_backups:/backups
|
- 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
|
# 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
|
# 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
|
# specific interface IP) for network-wide access instead of editing
|
||||||
@@ -195,7 +201,34 @@ services:
|
|||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- 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:
|
volumes:
|
||||||
|
updater_state:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
postgres_backups:
|
postgres_backups:
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
+210
-55
@@ -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)
|
## Barrierefreiheit (2026-10-04)
|
||||||
|
|
||||||
Version 1.8.0 „Harris“. Ziel: OPNexus per Tastatur und mit Screenreader
|
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
|
`aria-hidden`, Unternavigation als eigene Landmarke. Leere Tabellenköpfe
|
||||||
tragen einen nur für Screenreader sichtbaren Text („Aktionen“). Der
|
tragen einen nur für Screenreader sichtbaren Text („Aktionen“). Der
|
||||||
Host-Status (`StatusDot`) war fest deutsch und folgt jetzt der Sprache.
|
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
|
**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
|
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
|
Firewall-Daten (Aliase, Regelbeschreibungen) und die deutschen Suchbegriffe der
|
||||||
Befehlspalette (sie bleiben zusätzlich zu den englischen Seitentiteln nutzbar).
|
Befehlspalette (sie bleiben zusätzlich zu den englischen Seitentiteln nutzbar).
|
||||||
- **Prüfwerkzeuge:** `python3 scripts/check_i18n.py` (Katalog-Parität,
|
- **Prüfwerkzeuge:** `python3 scripts/check_i18n.py` (Katalog-Parität,
|
||||||
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel) und
|
Platzhalter, Abdeckung aller `ErrorCode`s, referenzierte Schlüssel).
|
||||||
`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).
|
|
||||||
|
|
||||||
**Verifiziert 2026-09-19:** DE→EN→DE mit DB-Persistenz und Reload; 17 Seiten und
|
**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
|
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)
|
## 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`,
|
eine lokal gespeicherte OPNsense-Konfigurationssicherung (`backup_store.py`,
|
||||||
bereits vorhandenes "Backups"-Panel) prüfen, sicher als Vorschau anzeigen und
|
bereits vorhandenes "Backups"-Panel) prüfen, sicher als Vorschau anzeigen und
|
||||||
den Wechsel auf das OPNsense-Webinterface dokumentiert übergeben — **OPNexus
|
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
|
Tabelle werden durch einen Datenbank-Trigger blockiert; Datenbankadministratoren
|
||||||
können technisch weiterhin das Schema ändern. Keine rückwirkende Rekonstruktion
|
können technisch weiterhin das Schema ändern. Keine rückwirkende Rekonstruktion
|
||||||
früherer Aktionen. XML-Backups, Passwörter und API-Secrets werden nicht protokolliert.
|
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
|
Es gibt keine implementierte Restore-Ausführung. Der Plan unterscheidet lokale
|
||||||
OPNexus-XML-Dateien von bereits auf der Firewall vorhandenen Revisionen.
|
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"
|
docker compose logs backend | grep -A5 "generated password"
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach in `.env` ein eigenes `ADMIN_PASSWORD` setzen (empfohlen) oder das
|
Ist das Passwort verloren oder nie gesehen worden, setzt `opnexus password`
|
||||||
Passwort über die künftige "Passwort ändern"-Funktion (`POST
|
(im Repo-Checkout `scripts/opnexus password`) ein **neues** Zufallspasswort und
|
||||||
/api/auth/change-password`, aktuell nur per API, kein UI-Formular) ändern.
|
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
|
Login-Versuche sind pro (IP, Benutzername) und pro IP rate-limitiert
|
||||||
(Standard: 5 Fehlversuche / 5 Minuten Sperre).
|
(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
|
CI lädt es deshalb nicht hoch). Anwender brauchen damit nur Docker und diese
|
||||||
vier Dateien.
|
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 —
|
**Registry ist reine Konfiguration**, nicht Teil der Workflow-Datei —
|
||||||
Repository-Variablen `REGISTRY` (Default `ghcr.io`) und `IMAGE_NAMESPACE`
|
Repository-Variablen `REGISTRY` (Default `ghcr.io`) und `IMAGE_NAMESPACE`
|
||||||
(Default: Repo-Besitzer), Secrets `REGISTRY_USERNAME`/`REGISTRY_TOKEN`. Für
|
(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
|
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Das ist der offizielle Feed des Projekts (statische JSON-Datei aus
|
Das ist der offizielle Feed des Projekts (eine statische JSON-Datei, ausgeliefert als
|
||||||
`website/latest.json`, siehe `website/README.md`). Jeder andere Feed im
|
`updates.opnexus.dev/latest.json`). Jeder andere Feed im
|
||||||
selben Format funktioniert ebenso, etwa `…/api/v1/repos/<owner>/opnexus/releases/latest`
|
selben Format funktioniert ebenso, etwa `…/api/v1/repos/<owner>/opnexus/releases/latest`
|
||||||
auf einer eigenen Forgejo/GitHub-Instanz.
|
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
|
Nutzerdaten. Ein unerreichbarer Feed bleibt unauffällig (der letzte bekannte
|
||||||
Stand bleibt, Neuversuch frühestens nach 15 Minuten); Vorabversionen
|
Stand bleibt, Neuversuch frühestens nach 15 Minuten); Vorabversionen
|
||||||
(`-rc1`) werden nie vorgeschlagen. Der Hinweis lässt sich pro Version
|
(`-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`
|
#### 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
|
**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 ü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
|
eine zweite, doppelte Regel an (die Vorschau zeigt „create“). Keine Multi-Host-Vorlagen
|
||||||
Rolling-Apply auf CARP-Paaren (die Vorschau weist auf den Partner hin), keine
|
für Netzwerkobjekte, kein automatisches Rolling-Apply auf CARP-Paaren (nur das
|
||||||
LAGG/Bridge/GRE/VXLAN-Verwaltung, keine dnsmasq-Bereiche, keine Kea-DDNS-Felder,
|
Übertragen auf den Partner per Klick), keine Kea-DDNS-Felder.
|
||||||
Gateway-Gruppen nicht enthalten.
|
|
||||||
|
### 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
|
## 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 |
@@ -5,7 +5,7 @@ import { api, AuditEvent, AuditPage } from "@/lib/api";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input, Select } from "@/components/ui/Field";
|
import { Input, Select } from "@/components/ui/Field";
|
||||||
import { useLanguage } from "@/components/LanguageProvider";
|
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 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"];
|
const statusKeys = ["requested","accepted","rejected","interrupted","success","pending","error","reverted","revert_failed","removed","submitted","acknowledged","starting","unknown","denied"];
|
||||||
export default function AuditHistory() {
|
export default function AuditHistory() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLanguage } from "@/components/LanguageProvider";
|
import { useLanguage } from "@/components/LanguageProvider";
|
||||||
import { labelFor, type Language } from "@/lib/i18n";
|
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() {
|
export default function DashboardPage() {
|
||||||
const { language, t, errorMessage } = useLanguage();
|
const { language, t, errorMessage } = useLanguage();
|
||||||
|
const router = useRouter();
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
|
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
|
||||||
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
|
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
|
||||||
@@ -133,6 +135,15 @@ export default function DashboardPage() {
|
|||||||
.then(async (data) => {
|
.then(async (data) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setSummary(data);
|
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 reachableHosts = data.hosts.filter((h) => h.status !== "offline" && h.status !== "disabled");
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
reachableHosts.map(async (host) => {
|
reachableHosts.map(async (host) => {
|
||||||
@@ -164,7 +175,7 @@ export default function DashboardPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [errorMessage]);
|
}, [errorMessage, router]);
|
||||||
|
|
||||||
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
|
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
|
||||||
const securityWarningsLoading = problems === null || firmwareWarnings === null;
|
const securityWarningsLoading = problems === null || firmwareWarnings === null;
|
||||||
@@ -212,7 +223,7 @@ export default function DashboardPage() {
|
|||||||
{summary === null ? (
|
{summary === null ? (
|
||||||
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
|
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
|
||||||
) : summary.hosts.length === 0 ? (
|
) : 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">
|
<ul className="divide-y divide-slate-100">
|
||||||
{summary.hosts.map((host) => (
|
{summary.hosts.map((host) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { EmptyState } from "@/components/ui/EmptyState";
|
|||||||
import { Skeleton } from "@/components/ui/Skeleton";
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
|
import { Table, TBody, TD, TH, THead, TRow } from "@/components/ui/Table";
|
||||||
import { NetworkChangeModal } from "@/components/NetworkChangeModal";
|
import { NetworkChangeModal } from "@/components/NetworkChangeModal";
|
||||||
|
import { NetworkCompare } from "@/components/NetworkCompare";
|
||||||
|
import { NetworkObjectSection } from "@/components/NetworkObjectSection";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { api, Host, HostNetwork, NetworkAction, NetworkKind } from "@/lib/api";
|
import { api, Host, HostNetwork, NetworkAction, NetworkKind } from "@/lib/api";
|
||||||
import { useReadOnly } from "@/lib/useReadOnly";
|
import { useReadOnly } from "@/lib/useReadOnly";
|
||||||
@@ -14,12 +16,28 @@ import { AlertTriangle, Cable, Pencil, Plus, Trash2 } from "lucide-react";
|
|||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
type Section = "interfaces" | "vlans" | "vips" | "gateways" | "routes" | "dns" | "dhcp";
|
type Section = "interfaces" | "vlans" | "vips" | "gateways" | "routes" | "dns" | "dhcp" | "devices" | "compare";
|
||||||
type Change = { kind: NetworkKind; action: NetworkAction; uuid?: string; preset?: Record<string, string> };
|
// 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;
|
type OpenChange = (change: Change) => void;
|
||||||
|
|
||||||
const HOST_KEY = "opnexus-network-host";
|
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" };
|
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 {
|
function statusTone(status: string): BadgeTone {
|
||||||
@@ -37,7 +55,7 @@ export default function NetworkPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// The sub-navigation in the header picks the section; the path is the single source of truth.
|
// The sub-navigation in the header picks the section; the path is the single source of truth.
|
||||||
const { tab } = useParams<{ tab: string }>();
|
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 [change, setChange] = useState<Change | null>(null);
|
||||||
const readOnly = useReadOnly();
|
const readOnly = useReadOnly();
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
@@ -57,7 +75,7 @@ export default function NetworkPage() {
|
|||||||
}, [errorMessage]);
|
}, [errorMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostId) return;
|
if (!hostId || section === "compare") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
// Switching host clears the view; a reload after a change keeps it so an open dialog is not torn down.
|
// 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 () => {
|
return () => {
|
||||||
cancelled = true;
|
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) {
|
if (hosts && hosts.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -134,13 +168,29 @@ export default function NetworkPage() {
|
|||||||
{section === "dns" && <DnsTable network={network} onChange={setChange} />}
|
{section === "dns" && <DnsTable network={network} onChange={setChange} />}
|
||||||
{section === "dhcp" && <DhcpSections network={network} onChange={setChange} />}
|
{section === "dhcp" && <DhcpSections network={network} onChange={setChange} />}
|
||||||
</fieldset>
|
</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>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{change && (
|
{change && (
|
||||||
<NetworkChangeModal
|
<NetworkChangeModal
|
||||||
hostId={hostId}
|
key={`${modalHostId}:${change.kind}:${change.action}:${change.uuid ?? ""}`}
|
||||||
hostName={hosts?.find((h) => h.id === hostId)?.name ?? ""}
|
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}
|
kind={change.kind}
|
||||||
action={change.action}
|
action={change.action}
|
||||||
uuid={change.uuid}
|
uuid={change.uuid}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { TwoFactorSection } from "@/components/TwoFactorSection";
|
import { TwoFactorSection } from "@/components/TwoFactorSection";
|
||||||
|
import { OpnexusUpdateSection } from "@/components/OpnexusUpdateSection";
|
||||||
import { InfoTip, Tooltip } from "@/components/ui/Tooltip";
|
import { InfoTip, Tooltip } from "@/components/ui/Tooltip";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, AppSettings, LogSettings, NotificationSettings, SmtpSecurity, Theme } from "@/lib/api";
|
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 { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
|
||||||
import { translate } from "@/lib/i18n";
|
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() {
|
function NotificationsSection() {
|
||||||
const { t, errorMessage } = useLanguage();
|
const { t, errorMessage } = useLanguage();
|
||||||
const readOnly = useReadOnly();
|
const readOnly = useReadOnly();
|
||||||
@@ -42,6 +58,7 @@ function NotificationsSection() {
|
|||||||
const [maintenanceUnknown, setMaintenanceUnknown] = useState(true);
|
const [maintenanceUnknown, setMaintenanceUnknown] = useState(true);
|
||||||
const [carpHealth, setCarpHealth] = useState(true);
|
const [carpHealth, setCarpHealth] = useState(true);
|
||||||
const [natDrift, setNatDrift] = useState(true);
|
const [natDrift, setNatDrift] = useState(true);
|
||||||
|
const [networkDrift, setNetworkDrift] = useState(true);
|
||||||
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
|
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
|
||||||
const [pollInterval, setPollInterval] = useState(300);
|
const [pollInterval, setPollInterval] = useState(300);
|
||||||
|
|
||||||
@@ -63,6 +80,7 @@ function NotificationsSection() {
|
|||||||
setMaintenanceUnknown(data.maintenance_unknown_enabled);
|
setMaintenanceUnknown(data.maintenance_unknown_enabled);
|
||||||
setCarpHealth(data.carp_health_enabled);
|
setCarpHealth(data.carp_health_enabled);
|
||||||
setNatDrift(data.nat_drift_enabled);
|
setNatDrift(data.nat_drift_enabled);
|
||||||
|
setNetworkDrift(data.network_drift_enabled);
|
||||||
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
|
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
|
||||||
setPollInterval(data.poll_interval_seconds);
|
setPollInterval(data.poll_interval_seconds);
|
||||||
setWebhookSecret("");
|
setWebhookSecret("");
|
||||||
@@ -102,6 +120,7 @@ function NotificationsSection() {
|
|||||||
maintenance_unknown_enabled: maintenanceUnknown,
|
maintenance_unknown_enabled: maintenanceUnknown,
|
||||||
carp_health_enabled: carpHealth,
|
carp_health_enabled: carpHealth,
|
||||||
nat_drift_enabled: natDrift,
|
nat_drift_enabled: natDrift,
|
||||||
|
network_drift_enabled: networkDrift,
|
||||||
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
|
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
|
||||||
poll_interval_seconds: pollInterval,
|
poll_interval_seconds: pollInterval,
|
||||||
});
|
});
|
||||||
@@ -129,7 +148,7 @@ function NotificationsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
<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">
|
<form onSubmit={save} className="space-y-5">
|
||||||
@@ -232,6 +251,7 @@ function NotificationsSection() {
|
|||||||
[t("settings.notify.event.maintenanceUnknown"), maintenanceUnknown, setMaintenanceUnknown],
|
[t("settings.notify.event.maintenanceUnknown"), maintenanceUnknown, setMaintenanceUnknown],
|
||||||
[t("settings.notify.event.carpHealth"), carpHealth, setCarpHealth],
|
[t("settings.notify.event.carpHealth"), carpHealth, setCarpHealth],
|
||||||
[t("settings.notify.event.natDrift"), natDrift, setNatDrift],
|
[t("settings.notify.event.natDrift"), natDrift, setNatDrift],
|
||||||
|
[t("settings.notify.event.networkDrift"), networkDrift, setNetworkDrift],
|
||||||
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
|
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
|
||||||
].map(([label, value, setter]) => (
|
].map(([label, value, setter]) => (
|
||||||
<label key={label as string} className="flex items-center gap-2 text-sm">
|
<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 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 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);}}
|
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>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SetupWizard } from "@/components/SetupWizard";
|
||||||
|
|
||||||
|
export default function SetupPage() {
|
||||||
|
return <SetupWizard />;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { APP_CODENAME, APP_VERSION } from "@/lib/version";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
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
|
* 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 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
|
// 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.
|
// <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 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>;
|
const title = <title>{pageName ? pageName.includes("OPNexus") ? pageName : `${pageName} · OPNexus` : "OPNexus"}</title>;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,17 @@ import {
|
|||||||
FolderTree,
|
FolderTree,
|
||||||
Globe,
|
Globe,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
HardDrive,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
Camera,
|
||||||
|
Sliders,
|
||||||
|
GitCompare,
|
||||||
|
Boxes,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Layers,
|
Layers,
|
||||||
|
ListChecks,
|
||||||
LogOut,
|
LogOut,
|
||||||
Network,
|
Network,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -74,7 +82,21 @@ export const navGroups: NavGroup[] = [
|
|||||||
{ href: "/network/gateways", label: "Gateways", icon: Router, keywords: ["Standard-Gateway", "Uplink"] },
|
{ href: "/network/gateways", label: "Gateways", icon: Router, keywords: ["Standard-Gateway", "Uplink"] },
|
||||||
{ href: "/network/routes", label: "Routen", icon: Route, keywords: ["Routing", "Statische Routen"] },
|
{ 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/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,
|
icon: Users,
|
||||||
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
|
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, "-")}`;
|
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() {
|
export function Nav() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [readOnly, setReadOnly] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
api.me().then((state) => setReadOnly(state.role === "read_only")).catch(() => {});
|
api.me().then((state) => setReadOnly(state.role === "read_only")).catch(() => {});
|
||||||
@@ -169,11 +202,11 @@ export function Nav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-slate-200 bg-white">
|
<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">
|
<Link href="/about" title={t("nav.aboutTitle")} className="shrink-0">
|
||||||
<Logo className="h-6 w-auto sm:h-7" />
|
<Logo className="h-6 w-auto sm:h-7" />
|
||||||
</Link>
|
</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 }) => {
|
{navGroups.map(({ key, icon: Icon, items }) => {
|
||||||
const active = activeGroup?.key === key;
|
const active = activeGroup?.key === key;
|
||||||
return (
|
return (
|
||||||
@@ -184,12 +217,12 @@ export function Nav() {
|
|||||||
aria-label={t(`nav.groups.${key}`)}
|
aria-label={t(`nav.groups.${key}`)}
|
||||||
aria-current={active ? "true" : undefined}
|
aria-current={active ? "true" : undefined}
|
||||||
className={clsx(
|
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"
|
active ? "bg-brand-50 text-brand-600" : "text-slate-600 hover:bg-slate-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={16} aria-hidden="true" />
|
<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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -227,7 +260,7 @@ export function Nav() {
|
|||||||
<div className="border-t border-slate-100 bg-slate-50">
|
<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">
|
<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 }) => {
|
{activeGroup.items.map(({ href, icon: Icon }) => {
|
||||||
const active = pathname?.startsWith(href);
|
const active = matchesNav(pathname, href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
|
|||||||
@@ -114,6 +114,110 @@ const FIELDS: Record<NetworkKind, FieldDef[]> = {
|
|||||||
{ name: "next_server", type: "text" },
|
{ name: "next_server", type: "text" },
|
||||||
{ name: "description", 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: [
|
route: [
|
||||||
{ name: "network", type: "text" },
|
{ name: "network", type: "text" },
|
||||||
{ name: "gateway", type: "select" },
|
{ name: "gateway", type: "select" },
|
||||||
@@ -130,6 +234,9 @@ interface Props {
|
|||||||
uuid?: string;
|
uuid?: string;
|
||||||
/** Values to start a new object with (e.g. the device being assigned). */
|
/** Values to start a new object with (e.g. the device being assigned). */
|
||||||
preset?: Record<string, string>;
|
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;
|
onClose: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}
|
}
|
||||||
@@ -142,7 +249,7 @@ function isVisible(f: FieldDef, values: Record<string, string>): boolean {
|
|||||||
return wanted.includes(values[f.when.field] ?? "");
|
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 { t, errorMessage } = useLanguage();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [step, setStep] = useState<Step>(action === "del" ? "review" : "form");
|
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 [acknowledged, setAcknowledged] = useState(false);
|
||||||
const [typedHost, setTypedHost] = useState("");
|
const [typedHost, setTypedHost] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [sent, setSent] = useState<Record<string, string>>({});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// The form starts from what the firewall itself offers for a new object (defaults + valid choices).
|
// 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);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const fields = action === "del" ? {} : changedFields();
|
const fields = action === "del" ? {} : changedFields();
|
||||||
|
setSent(fields);
|
||||||
const res = await api.executeNetworkChange(hostId, { kind, action, uuid, fields }, plan.token, acknowledged);
|
const res = await api.executeNetworkChange(hostId, { kind, action, uuid, fields }, plan.token, acknowledged);
|
||||||
setResult(res);
|
setResult(res);
|
||||||
setStep("done");
|
setStep("done");
|
||||||
@@ -363,6 +472,18 @@ export function NetworkChangeModal({ hostId, hostName, kind, action, uuid, prese
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-slate-500">{t("network.change.backupId", { id: result.backup_id })}</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">
|
<div className="flex justify-end">
|
||||||
<Button type="button" onClick={onClose}>{t("common.close")}</Button>
|
<Button type="button" onClick={onClose}>{t("common.close")}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,7 +65,10 @@ export function UpdateBanner() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium">{t("updateBanner.title", { latest: check.latest, current: check.current })}</p>
|
<p className="font-medium">{t("updateBanner.title", { latest: check.latest, current: check.current })}</p>
|
||||||
<p className="text-xs">
|
<p className="text-xs">
|
||||||
{t("updateBanner.how")}
|
{t("updateBanner.how")}{" "}
|
||||||
|
<a href="/settings#opnexus-update" className="font-medium underline">
|
||||||
|
{t("updateBanner.settings")}
|
||||||
|
</a>
|
||||||
{releaseUrl && (
|
{releaseUrl && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|||||||
+122
-4
@@ -224,7 +224,72 @@ export interface NetRoute {
|
|||||||
disabled: boolean;
|
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 type NetworkAction = "add" | "set" | "del";
|
||||||
|
|
||||||
export interface NetworkChangeRequest {
|
export interface NetworkChangeRequest {
|
||||||
@@ -996,10 +1061,44 @@ export interface VersionCheck {
|
|||||||
error: string | null;
|
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 = {
|
export const api = {
|
||||||
search: (q: string, options?: RequestInit) => request<SearchHit[]>(`/search?${new URLSearchParams({ q })}`, options),
|
search: (q: string, options?: RequestInit) => request<SearchHit[]>(`/search?${new URLSearchParams({ q })}`, options),
|
||||||
settings: () => request<AppSettings>("/settings"),
|
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"),
|
notificationSettings: () => request<NotificationSettings>("/notifications/settings"),
|
||||||
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
|
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||||
logSettings: () => request<LogSettings>("/logs/settings"),
|
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)]))}`),
|
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"),
|
dashboard: () => request<DashboardSummary>("/dashboard"),
|
||||||
versionCheck: () => request<VersionCheck>("/system/version-check"),
|
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`),
|
getOutboundMode: (id: string) => request<OutboundMode>(`/outbound-nat/hosts/${id}/mode`),
|
||||||
listPortForwards: () => request<PortForward[]>(`/port-forward`),
|
listPortForwards: () => request<PortForward[]>(`/port-forward`),
|
||||||
createPortForward: (data: PortForwardCreate) => request<PortForward>(`/port-forward`, { method: "POST", body: JSON.stringify(data) }),
|
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" }),
|
deleteHost: (id: string) => request<void>(`/hosts/${id}`, { method: "DELETE" }),
|
||||||
getLiveRules: (id: string) => request<LiveRule[]>(`/hosts/${id}/live-rules`),
|
getLiveRules: (id: string) => request<LiveRule[]>(`/hosts/${id}/live-rules`),
|
||||||
getHostNetwork: (id: string) => request<HostNetwork>(`/hosts/${id}/network`),
|
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}`),
|
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}`),
|
getNetworkTemplate: (id: string, kind: NetworkKind) => request<NetworkTemplate>(`/hosts/${id}/network/template/${kind}`),
|
||||||
previewNetworkChange: (id: string, data: NetworkChangeRequest) =>
|
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 interface CarpSwitchPreview {preview_id:string;expires_at:string;action:"carp_enter"|"carp_leave";state:CarpSwitchState;}
|
||||||
|
|
||||||
export type Theme="light"|"dark"|"system";
|
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";
|
export type SmtpSecurity = "none" | "starttls" | "ssl";
|
||||||
|
|
||||||
@@ -1434,6 +1550,7 @@ export interface NotificationSettings {
|
|||||||
maintenance_unknown_enabled: boolean;
|
maintenance_unknown_enabled: boolean;
|
||||||
carp_health_enabled: boolean;
|
carp_health_enabled: boolean;
|
||||||
nat_drift_enabled: boolean;
|
nat_drift_enabled: boolean;
|
||||||
|
network_drift_enabled: boolean;
|
||||||
carp_auto_revert_failed_enabled: boolean;
|
carp_auto_revert_failed_enabled: boolean;
|
||||||
poll_interval_seconds: number;
|
poll_interval_seconds: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -1460,6 +1577,7 @@ export interface NotificationSettingsUpdate {
|
|||||||
maintenance_unknown_enabled?: boolean;
|
maintenance_unknown_enabled?: boolean;
|
||||||
carp_health_enabled?: boolean;
|
carp_health_enabled?: boolean;
|
||||||
nat_drift_enabled?: boolean;
|
nat_drift_enabled?: boolean;
|
||||||
|
network_drift_enabled?: boolean;
|
||||||
carp_auto_revert_failed_enabled?: boolean;
|
carp_auto_revert_failed_enabled?: boolean;
|
||||||
poll_interval_seconds?: number;
|
poll_interval_seconds?: number;
|
||||||
}
|
}
|
||||||
@@ -1483,7 +1601,7 @@ export interface LogSettingsUpdate {
|
|||||||
|
|
||||||
export type ProblemType =
|
export type ProblemType =
|
||||||
| "host_offline" | "cert_expiry" | "rule_drift" | "revert_failed" | "maintenance_unknown"
|
| "host_offline" | "cert_expiry" | "rule_drift" | "revert_failed" | "maintenance_unknown"
|
||||||
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed";
|
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift";
|
||||||
|
|
||||||
export interface CarpEvent {
|
export interface CarpEvent {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+339
-14
@@ -62,7 +62,8 @@
|
|||||||
"maintenanceUnknown": "Wartungsergebnis unklar",
|
"maintenanceUnknown": "Wartungsergebnis unklar",
|
||||||
"carpHealth": "CARP-Zustand kritisch (Split-Brain / kein Master)",
|
"carpHealth": "CARP-Zustand kritisch (Split-Brain / kein Master)",
|
||||||
"natDrift": "NAT-Drift im Cluster",
|
"natDrift": "NAT-Drift im Cluster",
|
||||||
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen"
|
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
|
||||||
|
"networkDrift": "Netzwerk-Abweichung im Cluster"
|
||||||
},
|
},
|
||||||
"pollInterval": "Prüfintervall (Sekunden)",
|
"pollInterval": "Prüfintervall (Sekunden)",
|
||||||
"pollHint": "Mindestens 60s, um die Firewalls nicht unnötig zu belasten."
|
"pollHint": "Mindestens 60s, um die Firewalls nicht unnötig zu belasten."
|
||||||
@@ -109,7 +110,66 @@
|
|||||||
"about": "Über OPNexus",
|
"about": "Über OPNexus",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"development": "Entwicklung",
|
"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": {
|
"nav": {
|
||||||
"aboutTitle": "Über OPNexus — Fleet-Status & Versionen",
|
"aboutTitle": "Über OPNexus — Fleet-Status & Versionen",
|
||||||
@@ -126,7 +186,8 @@
|
|||||||
"vpn": "VPN",
|
"vpn": "VPN",
|
||||||
"certificates": "Zertifikate",
|
"certificates": "Zertifikate",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"system": "System"
|
"system": "System",
|
||||||
|
"hostsystem": "Host-System"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -152,7 +213,15 @@
|
|||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
"updates": "Update Center",
|
"updates": "Update Center",
|
||||||
"settings": "Einstellungen",
|
"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": {
|
"dashboard": {
|
||||||
@@ -164,6 +233,7 @@
|
|||||||
"fleetHealth": "Fleet-/CARP-Gesundheit",
|
"fleetHealth": "Fleet-/CARP-Gesundheit",
|
||||||
"noHosts": "Noch keine Hosts angelegt",
|
"noHosts": "Noch keine Hosts angelegt",
|
||||||
"toHosts": "Zu den Hosts",
|
"toHosts": "Zu den Hosts",
|
||||||
|
"startSetup": "Einrichtungsassistent starten",
|
||||||
"nothingPending": "Nichts offen",
|
"nothingPending": "Nichts offen",
|
||||||
"nothingPendingDescription": "Keine ausstehenden Bestätigungen, Fehler oder unklaren Wartungsvorgänge.",
|
"nothingPendingDescription": "Keine ausstehenden Bestätigungen, Fehler oder unklaren Wartungsvorgänge.",
|
||||||
"securityAndExpiryWarnings": "Sicherheits- & Ablauf-Warnungen",
|
"securityAndExpiryWarnings": "Sicherheits- & Ablauf-Warnungen",
|
||||||
@@ -500,7 +570,13 @@
|
|||||||
"NETWORK_BACKUP_FAILED": "Backup von „{host}“ vor der Änderung fehlgeschlagen – es wurde nichts geändert",
|
"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_CHANGE_BLOCKED": "Die Änderung ist nicht möglich ({reasons})",
|
||||||
"NETWORK_FIELD_INVALID": "Ungültiges Feld: {field}",
|
"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": {
|
"workflow": {
|
||||||
"attention": "Fehler prüfen",
|
"attention": "Fehler prüfen",
|
||||||
@@ -552,8 +628,9 @@
|
|||||||
"updateBanner": {
|
"updateBanner": {
|
||||||
"title": "OPNexus {latest} ist verfügbar (installiert: {current}).",
|
"title": "OPNexus {latest} ist verfügbar (installiert: {current}).",
|
||||||
"notes": "Release-Notes",
|
"notes": "Release-Notes",
|
||||||
"how": "Aktualisieren auf dem Server mit „opnexus update“.",
|
"how": "Aktualisieren in den Einstellungen oder auf dem Server mit „opnexus update“.",
|
||||||
"dismiss": "Hinweis für diese Version ausblenden"
|
"dismiss": "Hinweis für diese Version ausblenden",
|
||||||
|
"settings": "Zu den Einstellungen"
|
||||||
},
|
},
|
||||||
"acme": {
|
"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.",
|
"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",
|
"wireguard_tunnel": "WireGuard",
|
||||||
"certificate": "Zertifikate",
|
"certificate": "Zertifikate",
|
||||||
"acme": "ACME",
|
"acme": "ACME",
|
||||||
"notification": "Benachrichtigungen"
|
"notification": "Benachrichtigungen",
|
||||||
|
"system": "OPNexus-System"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"deploy": "Push",
|
"deploy": "Push",
|
||||||
@@ -1817,7 +1895,19 @@
|
|||||||
"dns_host": "DNS-Eintrag",
|
"dns_host": "DNS-Eintrag",
|
||||||
"dhcp_host": "DHCP-Host (dnsmasq)",
|
"dhcp_host": "DHCP-Host (dnsmasq)",
|
||||||
"kea_subnet": "DHCP-Subnetz (Kea)",
|
"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": {
|
"field": {
|
||||||
"if": "Eltern-Schnittstelle",
|
"if": "Eltern-Schnittstelle",
|
||||||
@@ -1894,7 +1984,66 @@
|
|||||||
"domain_name_servers": "DNS-Server",
|
"domain_name_servers": "DNS-Server",
|
||||||
"domain_name": "Domain-Name",
|
"domain_name": "Domain-Name",
|
||||||
"ntp_servers": "NTP-Server"
|
"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": {
|
"risks": {
|
||||||
"management_interface": "Diese Änderung betrifft {device}, die Schnittstelle, über die OPNexus die Firewall erreicht. Die Verbindung kann abbrechen.",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"warnings": {
|
||||||
"carp_change": "CARP-Änderungen können einen Wechsel zwischen Master und Backup auslösen.",
|
"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.",
|
"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": {
|
"blocks": {
|
||||||
"api_unavailable": "Diese Firewall stellt dafür keine API bereit ({section}).",
|
"api_unavailable": "Diese Firewall stellt dafür keine API bereit ({section}).",
|
||||||
"nothing_to_change": "Es gibt nichts zu ändern.",
|
"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_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_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": {
|
"change": {
|
||||||
"title": {
|
"title": {
|
||||||
@@ -1948,7 +2101,9 @@
|
|||||||
"after": "Nachher"
|
"after": "Nachher"
|
||||||
},
|
},
|
||||||
"invalidValue": "Ungültiger Wert.",
|
"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",
|
"rowAdd": "Hinzufügen",
|
||||||
"rowEdit": "Bearbeiten",
|
"rowEdit": "Bearbeiten",
|
||||||
@@ -1960,6 +2115,176 @@
|
|||||||
"dnsmasq": "Hosts (dnsmasq: statisches DHCP und DNS)",
|
"dnsmasq": "Hosts (dnsmasq: statisches DHCP und DNS)",
|
||||||
"keaSubnets": "Subnetze (Kea)",
|
"keaSubnets": "Subnetze (Kea)",
|
||||||
"keaReservations": "Reservierungen (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
@@ -62,7 +62,8 @@
|
|||||||
"maintenanceUnknown": "Maintenance outcome unclear",
|
"maintenanceUnknown": "Maintenance outcome unclear",
|
||||||
"carpHealth": "CARP state critical (split-brain / no master)",
|
"carpHealth": "CARP state critical (split-brain / no master)",
|
||||||
"natDrift": "NAT drift in the cluster",
|
"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)",
|
"pollInterval": "Check interval (seconds)",
|
||||||
"pollHint": "At least 60s, so the firewalls are not loaded unnecessarily."
|
"pollHint": "At least 60s, so the firewalls are not loaded unnecessarily."
|
||||||
@@ -109,7 +110,66 @@
|
|||||||
"about": "About OPNexus",
|
"about": "About OPNexus",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"development": "Development",
|
"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": {
|
"nav": {
|
||||||
"aboutTitle": "About OPNexus — fleet status & versions",
|
"aboutTitle": "About OPNexus — fleet status & versions",
|
||||||
@@ -126,7 +186,8 @@
|
|||||||
"vpn": "VPN",
|
"vpn": "VPN",
|
||||||
"certificates": "Certificates",
|
"certificates": "Certificates",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"system": "System"
|
"system": "System",
|
||||||
|
"hostsystem": "Host system"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -152,7 +213,15 @@
|
|||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
"updates": "Update Center",
|
"updates": "Update Center",
|
||||||
"settings": "Settings",
|
"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": {
|
"dashboard": {
|
||||||
@@ -164,6 +233,7 @@
|
|||||||
"fleetHealth": "Fleet/CARP health",
|
"fleetHealth": "Fleet/CARP health",
|
||||||
"noHosts": "No hosts yet",
|
"noHosts": "No hosts yet",
|
||||||
"toHosts": "Go to hosts",
|
"toHosts": "Go to hosts",
|
||||||
|
"startSetup": "Start the setup assistant",
|
||||||
"nothingPending": "Nothing pending",
|
"nothingPending": "Nothing pending",
|
||||||
"nothingPendingDescription": "No pending confirmations, errors, or unclear maintenance operations.",
|
"nothingPendingDescription": "No pending confirmations, errors, or unclear maintenance operations.",
|
||||||
"securityAndExpiryWarnings": "Security & expiry warnings",
|
"securityAndExpiryWarnings": "Security & expiry warnings",
|
||||||
@@ -500,7 +570,13 @@
|
|||||||
"NETWORK_BACKUP_FAILED": "Backup of “{host}” before the change failed – nothing was changed",
|
"NETWORK_BACKUP_FAILED": "Backup of “{host}” before the change failed – nothing was changed",
|
||||||
"NETWORK_CHANGE_BLOCKED": "The change is not possible ({reasons})",
|
"NETWORK_CHANGE_BLOCKED": "The change is not possible ({reasons})",
|
||||||
"NETWORK_FIELD_INVALID": "Invalid field: {field}",
|
"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": {
|
"workflow": {
|
||||||
"attention": "Check errors",
|
"attention": "Check errors",
|
||||||
@@ -552,8 +628,9 @@
|
|||||||
"updateBanner": {
|
"updateBanner": {
|
||||||
"title": "OPNexus {latest} is available (installed: {current}).",
|
"title": "OPNexus {latest} is available (installed: {current}).",
|
||||||
"notes": "Release notes",
|
"notes": "Release notes",
|
||||||
"how": "Update on the server with “opnexus update”.",
|
"how": "Update in the settings or on the server with “opnexus update”.",
|
||||||
"dismiss": "Hide this notice for this version"
|
"dismiss": "Hide this notice for this version",
|
||||||
|
"settings": "Go to settings"
|
||||||
},
|
},
|
||||||
"acme": {
|
"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.",
|
"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",
|
"wireguard_tunnel": "WireGuard",
|
||||||
"certificate": "Certificates",
|
"certificate": "Certificates",
|
||||||
"acme": "ACME",
|
"acme": "ACME",
|
||||||
"notification": "Notifications"
|
"notification": "Notifications",
|
||||||
|
"system": "OPNexus system"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"deploy": "Push",
|
"deploy": "Push",
|
||||||
@@ -1817,7 +1895,19 @@
|
|||||||
"dns_host": "DNS entry",
|
"dns_host": "DNS entry",
|
||||||
"dhcp_host": "DHCP host (dnsmasq)",
|
"dhcp_host": "DHCP host (dnsmasq)",
|
||||||
"kea_subnet": "DHCP subnet (Kea)",
|
"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": {
|
"field": {
|
||||||
"if": "Parent interface",
|
"if": "Parent interface",
|
||||||
@@ -1894,7 +1984,66 @@
|
|||||||
"domain_name_servers": "DNS servers",
|
"domain_name_servers": "DNS servers",
|
||||||
"domain_name": "Domain name",
|
"domain_name": "Domain name",
|
||||||
"ntp_servers": "NTP servers"
|
"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": {
|
"risks": {
|
||||||
"management_interface": "This change affects {device}, the interface OPNexus uses to reach the firewall. The connection may drop.",
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"warnings": {
|
||||||
"carp_change": "CARP changes can trigger a switch between master and backup.",
|
"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.",
|
"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": {
|
"blocks": {
|
||||||
"api_unavailable": "This firewall does not offer an API for this ({section}).",
|
"api_unavailable": "This firewall does not offer an API for this ({section}).",
|
||||||
"nothing_to_change": "There is nothing to change.",
|
"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_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_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": {
|
"change": {
|
||||||
"title": {
|
"title": {
|
||||||
@@ -1948,7 +2101,9 @@
|
|||||||
"after": "After"
|
"after": "After"
|
||||||
},
|
},
|
||||||
"invalidValue": "Invalid value.",
|
"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",
|
"rowAdd": "Add",
|
||||||
"rowEdit": "Edit",
|
"rowEdit": "Edit",
|
||||||
@@ -1960,6 +2115,176 @@
|
|||||||
"dnsmasq": "Hosts (dnsmasq: static DHCP and DNS)",
|
"dnsmasq": "Hosts (dnsmasq: static DHCP and DNS)",
|
||||||
"keaSubnets": "Subnets (Kea)",
|
"keaSubnets": "Subnets (Kea)",
|
||||||
"keaReservations": "Reservations (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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||||
// entries already kept in the project README/CHANGELOG.md, no build
|
// entries already kept in the project README/CHANGELOG.md, no build
|
||||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||||
export const APP_VERSION = "1.12.0";
|
export const APP_VERSION = "1.16.0";
|
||||||
export const APP_CODENAME = "Condor";
|
export const APP_CODENAME = "Hobby";
|
||||||
export const APP_MAINTAINER = "Julian Boldt";
|
export const APP_MAINTAINER = "Julian Boldt";
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# Tags und pusht sie optional — für den Fall, dass (noch) kein CI-Runner
|
||||||
# existiert. Tags und Namen entsprechen .github/workflows/release.yml:
|
# 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)
|
# scripts/build_release_images.sh v1.10.0 # nur bauen (Architektur dieses Hosts)
|
||||||
# docker login git.opnexus.dev
|
# docker login git.opnexus.dev
|
||||||
@@ -35,7 +35,7 @@ if [[ "$version" != *-* ]]; then # nur stabile Versionen bekommen Kurz-Tags un
|
|||||||
tags+=("$major.$minor" "$major" latest)
|
tags+=("$major.$minor" "$major" latest)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for svc in backend frontend proxy; do
|
for svc in backend frontend proxy updater; do
|
||||||
image="$repo/opnexus-$svc"
|
image="$repo/opnexus-$svc"
|
||||||
targs=(); for t in "${tags[@]}"; do targs+=(-t "$image:$t"); done
|
targs=(); for t in "${tags[@]}"; do targs+=(-t "$image:$t"); done
|
||||||
echo "==> $image (${tags[*]})"
|
echo "==> $image (${tags[*]})"
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ OPNexus $version
|
|||||||
|
|
||||||
1. cp .env.example .env und POSTGRES_PASSWORD (und was sonst nötig ist) eintragen
|
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
|
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)
|
./opnexus update 1.10.1 (feste Version)
|
||||||
Zurück: ./opnexus rollback
|
Zurück: ./opnexus rollback
|
||||||
Sichern: ./opnexus backup
|
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".
|
Vollständige Anleitung: README.md im Repository, Abschnitt "Release-Images & CI".
|
||||||
TXT
|
TXT
|
||||||
|
|
||||||
|
|||||||
-222
@@ -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
|
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../updater/opnexus
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
@@ -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"]
|
||||||
Executable
+307
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user