Compare commits

...
7 Commits
Author SHA1 Message Date
Julian Boldt 218a1f14ac Release v1.18.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.

- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
  Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
  Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
  Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
  Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
  `api_tokens` (Migration `0008`).
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
  offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
  Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
  erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
  0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
  Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
  auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
  Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
  Anfrage neu geladen.
2026-10-19 23:54:02 +02:00
Julian Boldt 79783d6d62 Release v1.17.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
unbeaufsichtigt aktualisieren. Auf einem Test-Cluster live geprüft (Neustart und ein echtes Update
mit Umschaltung), siehe Handbuch „Geplante Wartung“.

- **Rollenprüfung beim Einplanen:** Ein Host, der in einem Cluster gerade CARP-MASTER ist,
  lässt sich nicht mehr einplanen (Neustart/Update/Major-Upgrade). Bisher wurde der Plan
  angenommen und scheiterte erst zur Uhrzeit. Der Planungsdialog zeigt den Grund an und
  sperrt den Button. Ist die Rolle nicht feststellbar (Host gerade nicht erreichbar), wird
  weiter angenommen; die volle Prüfung läuft wie bisher zum Zündzeitpunkt.
- **Benachrichtigung „Geplante Wartung nicht ausgeführt“:** Eine fehlgeschlagene geplante
  Wartung meldet sich per Webhook/E-Mail (einzeln schaltbar in den Einstellungen, standardmäßig
  an). Offen für drei Tage nach dem geplanten Zeitpunkt, dann ohne „behoben“-Meldung geschlossen.
  Neue Spalte `notification_settings.scheduled_maintenance_failed_enabled` (Migration `0006`).
- **Automatische CARP-Umschaltung (Option pro Plan, standardmäßig aus):** Ein geplanter Neustart,
  Update oder Major-Upgrade auf dem MASTER schaltet vorher auf den Partner um, prüft die
  Übernahme, führt die Aktion aus, wartet auf die Rückkehr des Hosts und schaltet zurück. Fehler
  vor dem Start der Aktion stellen den Normalbetrieb wieder her; danach bleibt der Host bewusst im
  Wartungsmodus (Partner trägt den Verkehr) und es kommt eine Benachrichtigung. Der Ablauf steht in
  der Datenbank und überlebt einen Neustart des Backends. Neue Spalten an `scheduled_maintenance`
  (Migration `0007`).
- **Backup vor geplantem Update:** Ein geplantes Update oder Major-Upgrade zieht unmittelbar vor
  dem Start selbst ein Konfigurationsbackup (nur lesend auf der Firewall). Bisher scheiterte jeder
  Plan, der mehr als 24 Stunden in der Zukunft lag, an der Backup-Aktualität. Schlägt das Backup
  fehl, wird nicht aktualisiert.
2026-10-19 16:45:07 +02:00
Julian Boldt c15417869b Handbook rewritten as a user manual
docs/HANDBUCH.md is now organised for operators (install, TLS, configuration, users, firewalls, rules, network, VPN, certificates, HA, updates, monitoring, operation, security, troubleshooting, limits). The unused September screenshots are removed; README and configuration comments point at the new section names.
2026-10-10 14:36:58 +02:00
Julian Boldt cf326d2d17 Release v1.16.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:

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

- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
  neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
  automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
  (mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
  **Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
  in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
  **aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
  hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
  ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
  „zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
  Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
  Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
  zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
2026-10-10 00:24:01 +02:00
97 changed files with 7615 additions and 3786 deletions

No files matched your search

+14 -6
View File
@@ -36,7 +36,7 @@ PROXY_BIND_ADDRESS=0.0.0.0
# Host interface the backend/frontend ports are published on. Defaults
# to 127.0.0.1 (this host only) if unset. Set to 0.0.0.0 for access from
# other machines without a reverse proxy (see README's "Produktivbetrieb"
# other machines without a reverse proxy (see the handbook, "Zugriff aus dem Netz und TLS"
# for the safer TLS-proxy alternative), or to one specific interface IP.
BIND_ADDRESS=
# Host-Ports von Backend und Frontend (nur nötig, wenn 8100/3100 schon belegt
@@ -44,15 +44,15 @@ BIND_ADDRESS=
# BACKEND_PORT=8100
# FRONTEND_PORT=3100
# Set to true once this is running behind TLS (see README's
# "Produktivbetrieb" section) — otherwise the session cookie is sent
# Set to true once this is running behind TLS (see the handbook,
# "Zugriff aus dem Netz und TLS") — otherwise the session cookie is sent
# unencrypted and browsers won't even attach a Secure cookie without
# HTTPS in the first place.
COOKIE_SECURE=false
# Set to true ONLY when running behind a reverse proxy you trust to
# always set/overwrite X-Forwarded-For itself (every example in README's
# "Produktivbetrieb" does). Used to attribute failed logins to the real
# always set/overwrite X-Forwarded-For itself (every example in the handbook's
# "Zugriff aus dem Netz und TLS" does). Used to attribute failed logins to the real
# client IP for the login lockout instead of the proxy's own IP. Leave
# false (default) if the app is reachable directly with no proxy in
# front — otherwise any client could spoof this header to dodge the
@@ -71,10 +71,18 @@ TRUST_PROXY_HEADERS=false
# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht
# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update`.
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
# (optional, siehe unten) per Klick im Webinterface.
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
# UPDATE_CHECK_INTERVAL_HOURS=12
# ── Updates aus dem Webinterface (optional, braucht Release-Images) ───
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch, "Updates per Klick".
# COMPOSE_PROFILES=updater
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
# Firewall-API-Zugangsdaten gehen per HTTP-Basic-Auth raus, deshalb sind
# http://-Adressen für Firewalls abgelehnt (beim Anlegen UND bei jeder
+2
View File
@@ -59,3 +59,5 @@ jobs:
- uses: actions/checkout@v4
- run: python3 scripts/check_i18n.py
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
# Updater-Container (Zustandsübergänge, Ablehnungen, Auth) ohne Docker-Daemon.
- run: python3 -m unittest discover -s updater -p 'test_*.py'
+2 -2
View File
@@ -12,7 +12,7 @@
# Berechtigung "write:package" eintragen)
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration.
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy}:<version>
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy,updater}:<version>
# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen).
#
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
@@ -42,7 +42,7 @@ jobs:
strategy:
fail-fast: true
matrix:
service: [backend, frontend, proxy]
service: [backend, frontend, proxy, updater]
steps:
- uses: actions/checkout@v4
+94 -1
View File
@@ -29,7 +29,100 @@ in der App auf der Login-Seite, im Footer jeder Seite, auf `/about` und in
den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
ansteht): Lanner, Merlin …
## [1.18.0] "Kestrel" — 2026-10-19
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.
- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
`api_tokens` (Migration `0008`).
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
Anfrage neu geladen.
## [1.17.0] "Imperial" — 2026-10-19
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
unbeaufsichtigt aktualisieren. Auf einem Test-Cluster live geprüft (Neustart und ein echtes Update
mit Umschaltung), siehe Handbuch „Geplante Wartung“.
- **Rollenprüfung beim Einplanen:** Ein Host, der in einem Cluster gerade CARP-MASTER ist,
lässt sich nicht mehr einplanen (Neustart/Update/Major-Upgrade). Bisher wurde der Plan
angenommen und scheiterte erst zur Uhrzeit. Der Planungsdialog zeigt den Grund an und
sperrt den Button. Ist die Rolle nicht feststellbar (Host gerade nicht erreichbar), wird
weiter angenommen; die volle Prüfung läuft wie bisher zum Zündzeitpunkt.
- **Benachrichtigung „Geplante Wartung nicht ausgeführt“:** Eine fehlgeschlagene geplante
Wartung meldet sich per Webhook/E-Mail (einzeln schaltbar in den Einstellungen, standardmäßig
an). Offen für drei Tage nach dem geplanten Zeitpunkt, dann ohne „behoben“-Meldung geschlossen.
Neue Spalte `notification_settings.scheduled_maintenance_failed_enabled` (Migration `0006`).
- **Automatische CARP-Umschaltung (Option pro Plan, standardmäßig aus):** Ein geplanter Neustart,
Update oder Major-Upgrade auf dem MASTER schaltet vorher auf den Partner um, prüft die
Übernahme, führt die Aktion aus, wartet auf die Rückkehr des Hosts und schaltet zurück. Fehler
vor dem Start der Aktion stellen den Normalbetrieb wieder her; danach bleibt der Host bewusst im
Wartungsmodus (Partner trägt den Verkehr) und es kommt eine Benachrichtigung. Der Ablauf steht in
der Datenbank und überlebt einen Neustart des Backends. Neue Spalten an `scheduled_maintenance`
(Migration `0007`).
- **Backup vor geplantem Update:** Ein geplantes Update oder Major-Upgrade zieht unmittelbar vor
dem Start selbst ein Konfigurationsbackup (nur lesend auf der Firewall). Bisher scheiterte jeder
Plan, der mehr als 24 Stunden in der Zukunft lag, an der Backup-Aktualität. Schlägt das Backup
fehl, wird nicht aktualisiert.
## [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
+19 -6
View File
@@ -5,9 +5,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp)
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.14.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.18.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -20,10 +20,21 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
- **Automatisierung** — persönliche API-Tokens (Lesen / Ausrollen / Admin), Prometheus-Metriken, ein nur lesender MCP-Endpunkt für KI-Assistenten und ein OpenAPI-Schema.
- **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
Alle Screenshots zeigen fiktive Beispieldaten.
## Ratgeber
- [OPNsense-Firewalls zentral verwalten](https://opnexus.dev/opnsense-zentral-verwalten/) – wann sich zentrale Verwaltung lohnt und wie sie funktioniert
- [OPNsense-HA-Cluster mit CARP verwalten](https://opnexus.dev/opnsense-ha-cluster-carp/) – Rollen, Alarme, geordnete Updates, Abgleich beider Knoten
- [OPNsense-Regeln sicher ausrollen](https://opnexus.dev/opnsense-regeln-sicher-ausrollen/) – Vorschau, Bestätigungsfenster und automatischer Rollback
- [Das Kommandozeilen-Werkzeug `opnexus`](https://opnexus.dev/docs/cli/) – Installation aktualisieren, zurückrollen und sichern
## Schnellstart
Voraussetzung: Docker mit Compose-Plugin.
@@ -35,19 +46,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
docker compose up -d --build
```
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben:
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben. Verloren oder nie gesehen? Dieses Kommando setzt ein neues Zufallspasswort und zeigt es an (im Repo-Checkout `scripts/opnexus`, im Release-Bundle `./opnexus`):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
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).
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-aus-dem-netz-und-tls).
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.14.0
OPNEXUS_VERSION=1.18.0
```
```bash
+22 -7
View File
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp)
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.14.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.18.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -20,12 +20,23 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
- **Automation** — personal API tokens (read / deploy / admin), Prometheus metrics, a read-only MCP endpoint for AI assistants and an OpenAPI schema.
- **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
![Cluster view](website/assets/screenshots/en/cluster-light-desktop.webp)
![Cluster view](docs/screenshots/readme-cluster-en.webp)
All screenshots show fictitious sample data.
## Guides
- [Manage OPNsense firewalls centrally](https://opnexus.dev/en/manage-opnsense-centrally/) – when central management pays off and how it works
- [Manage OPNsense HA clusters with CARP](https://opnexus.dev/en/opnsense-ha-cluster-carp/) – roles, alerts, ordered updates, comparing both nodes
- [Roll out OPNsense rules safely](https://opnexus.dev/en/roll-out-opnsense-rules-safely/) – preview, confirmation window and automatic rollback
- [The `opnexus` command-line tool](https://opnexus.dev/en/docs/cli/) – update, roll back and back up an installation
## Quick start
Requires Docker with the Compose plugin.
@@ -37,19 +48,21 @@ cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD)
docker compose up -d --build
```
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log:
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log. Lost it, or never saw it? This sets a new random one and prints it (use `scripts/opnexus` in a repo checkout, `./opnexus` in the release bundle):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
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).
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-aus-dem-netz-und-tls) (German).
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.14.0
OPNEXUS_VERSION=1.18.0
```
```bash
@@ -58,6 +71,8 @@ docker compose pull && docker compose up -d --no-build
`scripts/opnexus update` / `rollback` then update the installation (pull first, database backup before migrations, automatic rollback if the services do not become healthy).
**Updating from the web interface** is available as an opt-in: set `COMPOSE_PROFILES=updater` in `.env` and an administrator can update (and roll back) under *Settings › Update OPNexus*. It runs as a separate small container that is the only one with Docker access — see the [handbook](docs/HANDBUCH.md#update-aus-dem-webinterface-optional) (German) for the security model.
## Privacy
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
+29 -5
View File
@@ -8,7 +8,8 @@ from starlette.responses import JSONResponse
from app.database import SessionLocal
from app.errors import ErrorCode
from app.routers.auth import COOKIE_NAME
from app.services import auth_service
from app.routers.auth import client_ip
from app.services import api_token_service, auth_service
_PUBLIC_PATHS = {
"/api/auth/login",
@@ -35,6 +36,8 @@ _PUBLIC_PATHS = {
_READ_ONLY_ALLOWED_WRITES = {
"/api/auth/change-password",
"/api/settings",
# Read-only JSON-RPC tools (see routers/mcp.py).
"/api/mcp",
# Self-scoped: a user managing their own second factor.
"/api/auth/2fa/setup",
"/api/auth/2fa/enable",
@@ -48,6 +51,7 @@ _READ_ONLY_ALLOWED_WRITES = {
_SECURITY_KINDS = {
"ipsec": "ipsec_tunnel", "openvpn": "openvpn_instance", "wireguard": "wireguard_tunnel",
"certificates": "certificate", "acme": "acme", "notifications": "notification", "auth": "auth",
"tokens": "api_token",
}
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings"}
@@ -86,13 +90,29 @@ class AuthMiddleware(BaseHTTPMiddleware):
if request.url.path in _PUBLIC_PATHS:
return await call_next(request)
api_token = None
bearer = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
token = request.cookies.get(COOKIE_NAME, "")
if token:
if bearer is not None:
# An API token wins over a cookie; a bad one never falls back to it.
async with SessionLocal() as db:
resolved = await api_token_service.resolve(db, bearer, client_ip(request))
user, api_token = resolved if resolved is not None else (None, None)
token = bearer
elif token:
async with SessionLocal() as db:
user = await auth_service.resolve_session(db, token)
if token:
if user is not None and api_token is not None and api_token_service.denies(
api_token.scope, request.method, request.url.path
):
return JSONResponse(
status_code=403,
content={"detail": {"code": ErrorCode.API_TOKEN_SCOPE_FORBIDDEN.value, "params": {"scope": api_token.scope}}},
)
if user is not None:
parts = request.url.path.strip("/").split("/")
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user"}
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system"}
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
@@ -109,10 +129,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
user.role == "read_only"
and request.method not in ("GET", "HEAD", "OPTIONS")
and request.url.path not in _READ_ONLY_ALLOWED_WRITES
# Own API tokens (the router limits a read_only user to read scope).
and not request.url.path.startswith("/api/tokens")
):
if tracked:
# Refused attempts at security-relevant writes stay attributable.
ctx = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
try:
await audit_service.persist(**event, status="denied", message="Schreibzugriff für Nur-Lese-Konto verweigert")
finally:
@@ -122,7 +144,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
)
request.state.user = user
token = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
request.state.api_token = api_token
actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150]
token = audit_service.context.set({"actor": actor, "id": str(uuid.uuid4()), "action": event["action"]})
try:
if tracked:
await audit_service.persist(**event, status="requested")
+8
View File
@@ -28,6 +28,14 @@ class Settings(BaseSettings):
update_check_url: str = ""
update_check_interval_hours: int = 12
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
# nie Zugriff auf den Docker-Socket; es reicht nur "aktualisieren" /
# "zurückrollen" an diesen Dienst weiter, authentifiziert mit dem Token,
# den der Updater in sein Zustands-Volume schreibt.
updater_url: str = "http://updater:8080"
updater_token_file: str = "/updater/token"
# Cookies now carry the session, so this can no longer be a bare "*"
# (the Fetch spec forbids credentialed requests against a wildcard
# response) — see main.py's allow_origin_regex. Default only matches
+12
View File
@@ -6,6 +6,12 @@ from fastapi import HTTPException
class ErrorCode(str, Enum):
UPDATER_UNAVAILABLE = "UPDATER_UNAVAILABLE"
UPDATER_BUSY = "UPDATER_BUSY"
UPDATER_REJECTED = "UPDATER_REJECTED"
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
ADMIN_REQUIRED = "ADMIN_REQUIRED"
BACKUP_EMPTY = "BACKUP_EMPTY"
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
@@ -43,6 +49,8 @@ class ErrorCode(str, Enum):
FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998"
FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR = "FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR"
GEPLANTE_WARTUNG_NICHT_GEFUNDEN = "GEPLANTE_WARTUNG_NICHT_GEFUNDEN"
GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH = "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER = "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
GROUP_HAS_ASSIGNED_ITEMS = "GROUP_HAS_ASSIGNED_ITEMS"
GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN = "GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN"
GRUPPENNAME_BEREITS_VERGEBEN = "GRUPPENNAME_BEREITS_VERGEBEN"
@@ -146,6 +154,10 @@ class ErrorCode(str, Enum):
SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
SERVICE_LOCKED = "SERVICE_LOCKED"
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED"
API_TOKEN_NOT_FOUND = "API_TOKEN_NOT_FOUND"
API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN"
API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH"
def api_error(status_code: int, code: ErrorCode, **params: Any) -> HTTPException:
+64 -4
View File
@@ -5,6 +5,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.openapi.utils import get_openapi
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import DBAPIError
@@ -17,7 +18,7 @@ from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddle
from app.config import settings
from app.database import SessionLocal
from app import migrate
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router
from app.routers import one_to_one
from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments
from app.routers import outbound_nat
@@ -49,6 +50,7 @@ from app.routers import resource_samples as resource_samples_router
from app.services import resource_monitor_service
from app.routers import scheduled_maintenance as scheduled_maintenance_router
from app.services import scheduled_maintenance_service
from app.services import compliance_service
from app.services import carp_event_service, carp_maintenance_window_service
# Without this, Python's root logger defaults to WARNING with no handler,
@@ -229,6 +231,7 @@ async def _resource_poll_loop():
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
_RUNNING_MAINTENANCE_POLL_SECONDS = 15
async def _scheduled_maintenance_poll_loop():
@@ -244,9 +247,31 @@ async def _scheduled_maintenance_poll_loop():
"Geplante Wartung: %d fällig, %d ausgeführt, %d blockiert/fehlgeschlagen",
result["due"], result["fired"], result["blocked_or_failed"],
)
in_progress = await scheduled_maintenance_service.advance_running(db)
except Exception:
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
await asyncio.sleep(_SCHEDULED_MAINTENANCE_POLL_SECONDS)
in_progress = 0
# A handover run is stepped quickly (the partner should take over within a minute or two);
# otherwise the relaxed cadence is plenty.
await asyncio.sleep(_RUNNING_MAINTENANCE_POLL_SECONDS if in_progress else _SCHEDULED_MAINTENANCE_POLL_SECONDS)
_COMPLIANCE_FIRST_RUN_SECONDS = 120
_COMPLIANCE_INTERVAL_SECONDS = 6 * 3600
async def _compliance_poll_loop():
"""Sixth background loop: refreshes every enabled host's security-posture result.
Each check is one read-only config download per firewall, so every six hours
is plenty; the first run waits a little so a restart is not a thundering herd."""
await asyncio.sleep(_COMPLIANCE_FIRST_RUN_SECONDS)
while True:
try:
async with SessionLocal() as db:
await compliance_service.check_all(db)
except Exception:
logger.exception("Sicherheitslage-Prüfung fehlgeschlagen")
await asyncio.sleep(_COMPLIANCE_INTERVAL_SECONDS)
@asynccontextmanager
@@ -267,6 +292,7 @@ async def lifespan(app: FastAPI):
log_ingest_task = asyncio.create_task(_log_ingest_poll_loop())
resource_task = asyncio.create_task(_resource_poll_loop())
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
compliance_task = asyncio.create_task(_compliance_poll_loop())
try:
yield
finally:
@@ -275,14 +301,44 @@ async def lifespan(app: FastAPI):
log_ingest_task.cancel()
resource_task.cancel()
scheduled_maintenance_task.cancel()
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task):
compliance_task.cancel()
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task):
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="OPNexus", version=APP_VERSION, lifespan=lifespan)
API_DESCRIPTION = (
"REST API of OPNexus, the central management for OPNsense firewall fleets.\n\n"
"**Authentication:** send a personal API token as `Authorization: Bearer opx_...` "
"(create one under *System > API tokens*). Scopes: `read` (GET only), `deploy` "
"(+ roll out rules, NAT, VPN, maintenance), `admin` (+ users, settings, hosts). "
"A token never exceeds its owner's role. Token and account management is session-only.\n\n"
"Errors are `{\"detail\": {\"code\": \"...\", \"params\": {...}}}` with a stable `code`."
)
# The interactive Swagger/ReDoc pages load scripts from a CDN, which the proxy's
# Content-Security-Policy forbids; the schema itself is served (behind auth) for
# any client that renders it (Postman, openapi-generator, an AI assistant).
app = FastAPI(
title="OPNexus", version=APP_VERSION, lifespan=lifespan, description=API_DESCRIPTION,
docs_url=None, redoc_url=None, openapi_url="/api/openapi.json",
)
def _custom_openapi():
if app.openapi_schema is None:
schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes)
schema.setdefault("components", {}).setdefault("securitySchemes", {})["bearerAuth"] = {
"type": "http", "scheme": "bearer", "description": "Personal API token (opx_...)",
}
schema["security"] = [{"bearerAuth": []}]
app.openapi_schema = schema
return app.openapi_schema
app.openapi = _custom_openapi
@app.exception_handler(RequestValidationError)
async def _safe_validation_error(request: Request, exc: RequestValidationError):
@@ -342,6 +398,10 @@ app.include_router(hosts.router, prefix="/api")
app.include_router(rules.router, prefix="/api")
app.include_router(search_router.router, prefix="/api")
app.include_router(users_router.router, prefix="/api")
app.include_router(tokens_router.router, prefix="/api")
app.include_router(metrics_router.router, prefix="/api")
app.include_router(mcp_router.router, prefix="/api")
app.include_router(compliance_router.router, prefix="/api")
app.include_router(logs.router, prefix="/api")
app.include_router(groups.router, prefix="/api")
app.include_router(aliases.router, prefix="/api")
+38
View File
@@ -0,0 +1,38 @@
"""Personal API tokens for scripts, CI jobs, Prometheus and MCP clients.
Only the SHA-256 of the token is stored (the token is 256 random bits, so a
fast hash is enough and lets a request be resolved with one indexed lookup);
the raw value is shown exactly once, at creation. A token belongs to a user and
never exceeds that user's role: see services/api_token_service.py for the
scopes and AuthMiddleware for where they are enforced.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ApiToken(Base):
__tablename__ = "api_tokens"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False)
# "read" | "deploy" | "admin"
scope: Mapped[str] = mapped_column(String(10), nullable=False)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
# First characters of the token, so a list can tell tokens apart. Not secret.
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user: Mapped["AdminUser"] = relationship("AdminUser")
+31
View File
@@ -0,0 +1,31 @@
"""Latest security-posture result per host (see services/compliance_service.py).
One row per host, replaced on every check. The findings never contain
configuration values beyond rule descriptions and the protocol names they
report on; secrets from config.xml are never read into them.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ComplianceResult(Base):
__tablename__ = "compliance_results"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
host_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
)
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# None when the check could not run (see error).
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
findings: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# None | "unreachable" | "invalid_config"
error: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
error_detail: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
+2 -1
View File
@@ -50,6 +50,7 @@ class NotificationSettings(Base):
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")
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@@ -71,7 +72,7 @@ class NotificationProblem(Base):
__table_args__ = (UniqueConstraint("problem_type", "dedup_key", name="uq_notification_problem_key"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown | ... (see ProblemType)
problem_type: Mapped[str] = mapped_column(String(32))
dedup_key: Mapped[str] = mapped_column(String(300))
status: Mapped[str] = mapped_column(String(16), default="open") # open | resolved
+4 -1
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -9,3 +9,6 @@ class UserPreferences(Base):
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
theme: Mapped[str] = mapped_column(String(10), default='system')
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
# Set once the user finished or dismissed the first-run setup assistant
# (frontend /setup); it is then no longer offered automatically.
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
+8 -1
View File
@@ -13,7 +13,7 @@ table only tracks the *schedule* itself, not the resulting operation.
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -40,3 +40,10 @@ class ScheduledMaintenance(Base):
# Set to the resulting HostOperation's id once fired, so the UI can
# link from the schedule to the operation it produced.
host_operation_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
# Opt-in: hand the traffic over to the CARP partner first, run the action, then switch back.
carp_handover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Only while status == "running" with a handover: handover_wait | job_wait | leave | leave_wait.
# State lives here, not in memory, so a backend restart in the middle of a run resumes cleanly.
phase: Mapped[str | None] = mapped_column(String(16), nullable=True)
phase_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
progress: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+67
View File
@@ -0,0 +1,67 @@
"""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 and API tokens 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 api_token_service, 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 api_token_service.revoke_all(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)))
+17 -1
View File
@@ -9,7 +9,7 @@ from app.errors import ErrorCode, api_error
from app.config import settings
from app.database import get_db
from app.models.user import AdminUser
from app.services import auth_service, totp_service
from app.services import api_token_service, auth_service, totp_service
logger = logging.getLogger(__name__)
@@ -63,6 +63,14 @@ def _account_success(user: AdminUser, action: str) -> None:
async def resolve_user(request: Request, db: AsyncSession) -> AdminUser:
# AuthMiddleware already resolved the caller (session cookie or API token), but
# in its own, closed DB session: re-load into this request's session so that
# changes made to the user (2FA, password...) are actually persisted.
resolved = getattr(request.state, "user", None)
if resolved is not None:
user = await db.get(AdminUser, resolved.id)
if user is not None:
return user
token = request.cookies.get(COOKIE_NAME, "")
user = await auth_service.resolve_session(db, token)
if user is None:
@@ -262,6 +270,14 @@ async def logout(request: Request, response: Response, db: AsyncSession = Depend
@router.get("/me")
async def me(request: Request, db: AsyncSession = Depends(get_db)):
raw = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
if raw is not None:
resolved = await api_token_service.resolve(db, raw, client_ip(request))
if resolved is None:
return {"authenticated": False}
user, api_token = resolved
return {"authenticated": True, "username": user.username, "role": user.role,
"via": "token", "scope": api_token.scope, "token_name": api_token.name}
token = request.cookies.get(COOKIE_NAME, "")
user = await auth_service.resolve_session(db, token)
if user is None:
+67
View File
@@ -0,0 +1,67 @@
"""Security posture ("Sicherheitslage"): stored results and on-demand checks.
Reads (GET) only return what the last check stored, so they are cheap and safe
for dashboards, Prometheus and MCP. A check (POST) downloads the host's config
read-only, evaluates it and stores the result; see services/compliance_service.py.
"""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost
from app.services import compliance_service
router = APIRouter(tags=["compliance"])
def _read(host: OpnsenseHost, row: ComplianceResult | None, *, detail: bool) -> dict:
result = {
"host_id": host.id, "host_name": host.name, "cluster_name": host.cluster_name, "enabled": host.enabled,
"checked_at": row.checked_at if row else None,
"score": row.score if row else None,
"grade": compliance_service.grade(row.score) if row and row.score is not None else None,
"error": row.error if row else None,
"counts": compliance_service.counts(row.findings) if row else None,
}
if detail:
result["findings"] = row.findings if row else []
return result
@router.get("/compliance")
async def fleet_compliance(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
rows = {r.host_id: r for r in (await db.scalars(select(ComplianceResult))).all()}
return [_read(host, rows.get(host.id), detail=False) for host in hosts]
@router.post("/compliance/check")
async def check_fleet(db: AsyncSession = Depends(get_db)):
await compliance_service.check_all(db)
return await fleet_compliance(db)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.get("/hosts/{host_id}/compliance")
async def host_compliance(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host_id))
return _read(host, row, detail=True)
@router.post("/hosts/{host_id}/compliance/check")
async def check_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
row = await compliance_service.check_host(db, host)
return _read(host, row, detail=True)
+18 -12
View File
@@ -184,18 +184,8 @@ async def _carp_auto_revert_actions(db: AsyncSession) -> list[PendingAction]:
return actions
@router.get("", response_model=DashboardSummary)
async def summary(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
statuses = await get_statuses(hosts)
host_summaries = [
DashboardHost(
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
)
for host in hosts
]
async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
"""Everything that needs a human, newest first (shared with the metrics endpoint)."""
pending_actions: list[PendingAction] = []
pending_actions += await _rule_actions(db)
pending_actions += await _nat_actions(db, PortForwardDeployment, PortForwardRule, "port_forward_id", "port_forward", "port-forward")
@@ -209,6 +199,22 @@ async def summary(db: AsyncSession = Depends(get_db)):
pending_actions += await _stuck_host_operations(db)
pending_actions += await _carp_auto_revert_actions(db)
pending_actions.sort(key=lambda item: item.created_at, reverse=True)
return pending_actions
@router.get("", response_model=DashboardSummary)
async def summary(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
statuses = await get_statuses(hosts)
host_summaries = [
DashboardHost(
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
)
for host in hosts
]
pending_actions = await collect_pending_actions(db)
activity_rows = (
await db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(15))
+202
View File
@@ -0,0 +1,202 @@
"""Read-only MCP server (Model Context Protocol, streamable HTTP transport).
Lets an AI assistant (Claude Code, Claude Desktop, ...) look at the fleet:
claude mcp add --transport http opnexus https://<opnexus>/api/mcp \
--header "Authorization: Bearer opx_..."
Stateless JSON-RPC over POST (the spec allows plain ``application/json``
replies; there is no server-initiated stream, GET answers 405). Every tool is a
GET against OPNexus' own API, sent through the app itself with the caller's own
credentials, so exactly the caller's permissions apply: a ``read`` token sees
what a ``read`` token sees, secret-bearing endpoints stay closed. Nothing here
can change state.
"""
import json
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
from app.version import APP_VERSION
router = APIRouter(tags=["mcp"])
SUPPORTED_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
MAX_RESULT_CHARS = 60_000
# Not reachable through the generic reader (credentials management, the MCP endpoint itself).
_BLOCKED_PREFIXES = ("/api/tokens", "/api/auth", "/api/mcp")
TOOLS = [
{
"name": "fleet_overview",
"description": "Fleet status at a glance: every firewall with online state and CARP role, items that need "
"attention (failed or unconfirmed deployments, stuck maintenance) and the latest activity.",
"inputSchema": {"type": "object", "properties": {}},
"path": "/api/dashboard",
},
{
"name": "list_hosts",
"description": "All managed firewalls with name, URL, cluster and whether monitoring is enabled.",
"inputSchema": {"type": "object", "properties": {}},
"path": "/api/hosts",
},
{
"name": "host_resources",
"description": "Recent CPU load, memory and disk usage samples of one firewall (a sample per minute).",
"inputSchema": {
"type": "object", "required": ["host_id"],
"properties": {
"host_id": {"type": "string", "description": "Firewall id from list_hosts"},
"since_hours": {"type": "integer", "minimum": 1, "maximum": 720, "default": 1},
},
},
"path": "/api/hosts/{host_id}/resource-samples",
"query": ("since_hours",),
# The raw per-sample payload (every interface counter) would drown the useful numbers.
"drop": ("raw",),
},
{
"name": "carp_events",
"description": "History of CARP role changes (MASTER/BACKUP) across the clusters.",
"inputSchema": {"type": "object", "properties": {}},
"path": "/api/cluster/carp-events",
},
{
"name": "recent_audit",
"description": "The audit trail: who changed or deployed what, newest first.",
"inputSchema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25},
"kind": {"type": "string", "description": "e.g. rule, alias, host, user"},
"status": {"type": "string", "description": "e.g. accepted, rejected, denied"},
"q": {"type": "string", "description": "free-text filter"},
},
},
"path": "/api/audit",
"query": ("limit", "kind", "status", "q"),
},
{
"name": "search",
"description": "Search rules, aliases, NAT, VPN, certificates, groups and hosts by text.",
"inputSchema": {
"type": "object", "required": ["q"],
"properties": {"q": {"type": "string", "description": "at least 2 characters"}},
},
"path": "/api/search",
"query": ("q",),
},
{
"name": "api_get",
"description": "Read any other OPNexus API endpoint (GET only), e.g. /api/rules, /api/aliases, /api/logs. "
"The schema of all endpoints is at /api/openapi.json.",
"inputSchema": {
"type": "object", "required": ["path"],
"properties": {
"path": {"type": "string", "description": "Starts with /api/"},
"query": {"type": "object", "description": "Query parameters", "additionalProperties": {"type": "string"}},
},
},
},
]
def _tool_list() -> list[dict]:
return [{k: v for k, v in tool.items() if k in ("name", "description", "inputSchema")} for tool in TOOLS]
def _text(text: str, is_error: bool = False) -> dict:
if len(text) > MAX_RESULT_CHARS:
text = text[:MAX_RESULT_CHARS] + f"\n… truncated ({len(text)} characters in total); narrow the request."
return {"content": [{"type": "text", "text": text}], "isError": is_error}
async def _get(request: Request, path: str, query: dict, drop: tuple = ()) -> dict:
if not path.startswith("/api/") or ".." in path or "?" in path or "#" in path or path.startswith(_BLOCKED_PREFIXES):
return _text("This path cannot be read through MCP.", True)
headers = {k: v for k in ("authorization", "cookie") if (v := request.headers.get(k))}
url = path + (("?" + urlencode(query)) if query else "")
transport = httpx.ASGITransport(app=request.app)
async with httpx.AsyncClient(transport=transport, base_url="http://opnexus", headers=headers, timeout=30) as client:
response = await client.get(url)
body = response.text
try:
data = response.json()
if drop and isinstance(data, list):
data = [{k: v for k, v in item.items() if k not in drop} if isinstance(item, dict) else item for item in data]
body = json.dumps(data, ensure_ascii=False, indent=1)
except ValueError:
pass
return _text(body, response.status_code >= 400)
async def _call_tool(request: Request, name: str, arguments: dict) -> dict:
tool = next((t for t in TOOLS if t["name"] == name), None)
if tool is None:
return _text(f"Unknown tool: {name}", True)
if name == "api_get":
query = arguments.get("query") or {}
if not isinstance(arguments.get("path"), str) or not isinstance(query, dict):
return _text("path (string) is required.", True)
return await _get(request, arguments["path"], {str(k): str(v) for k, v in query.items()})
path = tool["path"]
if "{host_id}" in path:
host_id = str(arguments.get("host_id", ""))
if not host_id.replace("-", "").isalnum():
return _text("host_id is required (see list_hosts).", True)
path = path.format(host_id=host_id)
query = {key: arguments[key] for key in tool.get("query", ()) if arguments.get(key) not in (None, "")}
return await _get(request, path, query, tool.get("drop", ()))
def _error(request_id, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
async def _handle(request: Request, message: dict) -> dict | None:
"""The JSON-RPC response for one message, None for a notification."""
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or "method" not in message:
return _error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request")
method, request_id = message["method"], message.get("id")
params = message.get("params") or {}
if "id" not in message: # notification (e.g. notifications/initialized): nothing to answer
return None
if method == "initialize":
requested = params.get("protocolVersion")
return {"jsonrpc": "2.0", "id": request_id, "result": {
"protocolVersion": requested if requested in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0],
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "opnexus", "version": APP_VERSION},
"instructions": "Read-only access to an OPNsense fleet managed by OPNexus.",
}}
if method == "ping":
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
if method == "tools/list":
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_list()}}
if method == "tools/call":
arguments = params.get("arguments") or {}
if not isinstance(params.get("name"), str) or not isinstance(arguments, dict):
return _error(request_id, -32602, "name (string) and arguments (object) are required")
return {"jsonrpc": "2.0", "id": request_id, "result": await _call_tool(request, params["name"], arguments)}
return _error(request_id, -32601, f"Method not found: {method}")
@router.post("/mcp")
async def mcp_endpoint(request: Request):
try:
payload = await request.json()
except ValueError:
return JSONResponse(_error(None, -32700, "Parse error"), status_code=400)
if isinstance(payload, list):
replies = [reply for message in payload if (reply := await _handle(request, message)) is not None]
return JSONResponse(replies) if replies else Response(status_code=202)
reply = await _handle(request, payload)
return JSONResponse(reply) if reply is not None else Response(status_code=202)
@router.get("/mcp")
async def mcp_no_stream():
# No server-initiated messages: tell clients not to expect an SSE stream.
return Response(status_code=405, headers={"Allow": "POST"})
+18
View File
@@ -0,0 +1,18 @@
"""GET /api/metrics — Prometheus text exposition (see services/metrics_service.py).
Authenticated like everything else; the intended client is a Prometheus job
with ``authorization: {credentials: <read token>}``.
"""
from fastapi import APIRouter, Depends
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services import metrics_service
router = APIRouter(tags=["metrics"])
@router.get("/metrics", response_class=PlainTextResponse)
async def metrics(db: AsyncSession = Depends(get_db)):
return PlainTextResponse(await metrics_service.render(db), media_type=metrics_service.CONTENT_TYPE)
+17 -1
View File
@@ -11,6 +11,8 @@ from app.errors import ErrorCode, api_error
from app.database import get_db
from app.models import OpnsenseHost
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services.host_maintenance_service import peers
from app.services.host_status_service import get_statuses
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"])
@@ -20,6 +22,8 @@ class Schedule(BaseModel):
action: Literal["reboot", "update", "major_update"]
run_at: datetime
acknowledge_unattended: Literal[True]
# Hand the traffic over to the CARP partner first and switch back afterwards (cluster nodes only).
carp_handover: bool = False
def _read(sched: ScheduledMaintenance) -> dict:
@@ -27,6 +31,7 @@ def _read(sched: ScheduledMaintenance) -> dict:
"id": sched.id, "host_id": sched.host_id, "action": sched.action, "run_at": sched.run_at,
"status": sched.status, "actor": sched.actor, "created_at": sched.created_at,
"result": sched.result, "host_operation_id": sched.host_operation_id,
"carp_handover": sched.carp_handover, "phase": sched.phase,
}
@@ -61,9 +66,20 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc)
if run_at <= now:
raise api_error(422, ErrorCode.ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN)
if payload.carp_handover:
if not host.cluster_name or len(await peers(db, host)) < 2:
raise api_error(409, ErrorCode.CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER)
elif host.cluster_name:
# Without a handover, fire time refuses every cluster node that is not the CARP BACKUP, so a
# plan for the current MASTER can only ever end as "failed" at 3 a.m. Say so now.
# Only a *known* MASTER is rejected here: an unreachable node or an unclear role
# is still judged by the full check when the plan fires.
statuses = await get_statuses(await peers(db, host))
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
raise api_error(409, ErrorCode.GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH)
sched = ScheduledMaintenance(
host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at,
actor=request.state.user.username, created_at=now,
actor=request.state.user.username, created_at=now, carp_handover=payload.carp_handover,
)
db.add(sched)
await db.commit()
+5 -2
View File
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
class Preferences(BaseModel):
theme: Literal['light','dark','system'] | None = None
language: Literal['de','en'] | None = None
setup_done: bool | None = None
@router.get('')
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
preferences=await db.get(UserPreferences,user.id)
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
'language':preferences.language if preferences else 'de',
'setup_done':preferences.setup_done if preferences else False,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
current = await db.get(UserPreferences, user.id)
theme = payload.theme or (current.theme if current else 'system')
language = payload.language or (current.language if current else 'de')
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
await db.commit()
return {'username':username,'theme':theme,'language':language,
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
+96 -2
View File
@@ -1,6 +1,10 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.services import update_check_service
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.services import update_check_service, updater_client
router = APIRouter(prefix="/system", tags=["system"])
@@ -10,3 +14,93 @@ async def version_check() -> dict:
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
return await update_check_service.check()
def _admin(user: AdminUser) -> AdminUser:
# Der Updater ist Administration der Installation selbst — auch das reine
# Lesen (Logs, Rollback-Optionen) bleibt Admins vorbehalten.
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
return user
def _major(version: str | None) -> int | None:
try:
return int((version or "").split(".")[0])
except ValueError:
return None
class UpdateRequest(BaseModel):
version: str = Field(min_length=5, max_length=20)
@router.get("/updater")
async def updater_status(user: AdminUser = Depends(current_user)) -> dict:
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
_admin(user)
check = await update_check_service.check()
result: dict = {
"available": False, "reason": None, "mode": None,
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
"release_url": check["release_url"], "feed_enabled": check["enabled"],
"feed_error": check["error"], "checked_at": check["checked_at"],
"can_update": False, "major_change": False,
"job": None, "phases": [], "log": [], "services": {},
"rollback_available": False, "rollback_loses_data": False, "rollback_version": None,
}
try:
updater = await updater_client.status()
except updater_client.UpdaterUnavailable as exc:
result["reason"] = str(exc)
return result
except updater_client.UpdaterRejected:
result["reason"] = "unreachable"
return result
mode = updater.get("mode")
running = (updater.get("job") or {}).get("state") == "running"
result.update(
available=True, mode=mode, job=updater.get("job"), phases=updater.get("phases") or [],
log=updater.get("log") or [], services=updater.get("services") or {},
rollback_available=bool(updater.get("rollback_available")),
rollback_loses_data=bool(updater.get("rollback_loses_data")),
rollback_version=((updater.get("last_update") or {}).get("prev_version")),
)
# Hauptversionssprünge nur bewusst über die Kommandozeile (Release-Notes lesen).
result["major_change"] = bool(check["latest"]) and _major(check["latest"]) != _major(check["current"])
result["can_update"] = (
mode == "release" and not running and bool(check["update_available"]) and not result["major_change"]
)
return result
@router.post("/updater/update", status_code=202)
async def updater_update(payload: UpdateRequest, user: AdminUser = Depends(current_user)) -> dict:
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
Version. Beliebige Versionen werden bewusst nicht angenommen."""
_admin(user)
check = await update_check_service.check()
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
if _major(check["latest"]) != _major(check["current"]):
raise api_error(409, ErrorCode.UPDATE_MAJOR_CHANGE)
return await _forward(updater_client.start_update(check["latest"]))
@router.post("/updater/rollback", status_code=202)
async def updater_rollback(user: AdminUser = Depends(current_user)) -> dict:
_admin(user)
return await _forward(updater_client.start_rollback())
async def _forward(call) -> dict:
try:
return await call
except updater_client.UpdaterUnavailable:
raise api_error(503, ErrorCode.UPDATER_UNAVAILABLE)
except updater_client.UpdaterRejected as rejected:
if rejected.code == "busy":
raise api_error(409, ErrorCode.UPDATER_BUSY)
raise api_error(409, ErrorCode.UPDATER_REJECTED, reason=rejected.code)
+67
View File
@@ -0,0 +1,67 @@
"""Manage personal API tokens (see services/api_token_service.py for scopes).
Session-only: an API token cannot reach these endpoints (AuthMiddleware), so a
leaked token can never mint a replacement or list/revoke its siblings. A
read_only user may manage their own tokens, but only with read scope.
"""
import uuid
from fastapi import APIRouter, Depends, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.api_token import ApiToken
from app.models.user import AdminUser
from app.routers.auth import _account_failure, _account_success, _check_account_budget, current_user
from app.schemas.api_token import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, TokenScope
from app.services import api_token_service, auth_service
router = APIRouter(prefix="/tokens", tags=["tokens"])
def _read(token: ApiToken, owner: str) -> dict:
return {
"id": token.id, "name": token.name, "scope": token.scope, "prefix": token.prefix, "owner": owner,
"created_at": token.created_at, "expires_at": token.expires_at,
"last_used_at": token.last_used_at, "last_used_ip": token.last_used_ip,
}
@router.get("", response_model=list[ApiTokenRead])
async def list_tokens(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
"""Own tokens; an admin sees everybody's (to be able to revoke them)."""
query = select(ApiToken, AdminUser.username).join(AdminUser, ApiToken.user_id == AdminUser.id)
if user.role != "admin":
query = query.where(ApiToken.user_id == user.id)
rows = (await db.execute(query.order_by(ApiToken.created_at.desc()))).all()
return [_read(token, owner) for token, owner in rows]
@router.post("", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED)
async def create_token(
payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
):
if user.role != "admin" and payload.scope != TokenScope.read:
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_TOO_HIGH)
_check_account_budget(user, "create-token")
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
_account_failure(user, "create-token")
raise api_error(401, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
_account_success(user, "create-token")
token, raw = await api_token_service.create(
db, user, name=payload.name.strip(), scope=payload.scope.value, expires_in_days=payload.expires_in_days
)
return {**_read(token, user.username), "token": raw}
@router.delete("/{token_id}", status_code=204)
async def revoke_token(
token_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
):
token = await db.get(ApiToken, token_id)
if token is None or (token.user_id != user.id and user.role != "admin"):
raise api_error(404, ErrorCode.API_TOKEN_NOT_FOUND)
await db.delete(token)
await db.commit()
+2 -1
View File
@@ -9,7 +9,7 @@ from app.database import get_db
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.schemas.user import UserCreate, UserPasswordReset, UserRead, UserRoleUpdate
from app.services import auth_service, totp_service
from app.services import api_token_service, auth_service, totp_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -79,6 +79,7 @@ async def reset_password(user_id: uuid.UUID, payload: UserPasswordReset, db: Asy
# A reset is the answer to a suspected compromise: every session of the
# account ends with the old password, atomically with the new hash.
await auth_service.revoke_sessions(db, target.id, commit=False)
await api_token_service.revoke_all(db, target.id, commit=False)
await db.commit()
return target
+40
View File
@@ -0,0 +1,40 @@
import uuid
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field
class TokenScope(str, Enum):
read = "read"
deploy = "deploy"
admin = "admin"
class ApiTokenCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
# Least privilege by default.
scope: TokenScope = TokenScope.read
# None = never expires. The UI defaults to 90 days.
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
# Creating a long-lived credential needs the account password again.
current_password: str = Field(min_length=1, max_length=1024)
class ApiTokenRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
scope: TokenScope
prefix: str
owner: str
created_at: datetime
expires_at: datetime | None
last_used_at: datetime | None
last_used_ip: str | None
class ApiTokenCreated(ApiTokenRead):
# The only time the raw token is ever returned.
token: str
+3
View File
@@ -10,6 +10,7 @@ SmtpSecurity = Literal["none", "starttls", "ssl"]
ProblemType = Literal[
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
"scheduled_maintenance_failed",
]
@@ -38,6 +39,7 @@ class NotificationSettingsRead(BaseModel):
nat_drift_enabled: bool
network_drift_enabled: bool = True
carp_auto_revert_failed_enabled: bool
scheduled_maintenance_failed_enabled: bool = True
poll_interval_seconds: int
updated_at: datetime
@@ -74,6 +76,7 @@ class NotificationSettingsUpdate(BaseModel):
nat_drift_enabled: bool | None = None
network_drift_enabled: bool | None = None
carp_auto_revert_failed_enabled: bool | None = None
scheduled_maintenance_failed_enabled: bool | None = None
poll_interval_seconds: int | None = Field(
default=None, ge=settings.notification_poll_min_interval_seconds
+126
View File
@@ -0,0 +1,126 @@
"""Personal API tokens: creation, lookup and scope rules.
Scopes (a token can never do more than its owner's role allows; a read_only
user's token is read-only whatever its scope says):
* ``read`` — GET/HEAD/OPTIONS only, except endpoints that return secrets (raw
config backups, notification settings): those need ``admin``. For Prometheus,
dashboards, MCP clients.
* ``deploy`` — additionally every write that rolls out or operates on the fleet
(rules, aliases, NAT, VPN, maintenance, backups...). NOT the writes that
change who can access OPNexus or where it sends data: users, settings,
notifications, host registration/removal, log settings, OPNexus self-update.
* ``admin`` — everything the owner may do, except what is session-only.
Session-only for every scope: managing tokens themselves and the account
endpoints (/api/auth/*), so a leaked token can neither mint more tokens nor
change a password or second factor.
The enforcement lives in AuthMiddleware (one place, like the read_only role).
"""
import hashlib
import re
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.models.api_token import ApiToken
from app.models.user import AdminUser
TOKEN_PREFIX = "opx_"
SCOPES = ("read", "deploy", "admin")
MAX_TOKENS_PER_USER = 50
# last_used_at is only rewritten when older than this, so a scraper hitting
# /api/metrics every few seconds does not turn into a write per request.
_LAST_USED_GRANULARITY = timedelta(minutes=1)
_SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
# notification settings the webhook URL/SMTP data): admin scope only.
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)")
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
_MCP_PATH = "/api/mcp"
_ADMIN_ONLY_WRITES = re.compile(
r"^/api/(users|settings|system|notifications)(/|$)"
r"|^/api/logs/settings$"
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
)
def _now() -> datetime:
return datetime.now(timezone.utc)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def denies(scope: str, method: str, path: str) -> bool:
"""True if a token with this scope must not make this request."""
if _SESSION_ONLY.match(path):
return True
if path == _MCP_PATH:
return False
if method in _SAFE_METHODS:
return scope != "admin" and bool(_ADMIN_SCOPE_READS.match(path))
if scope == "read":
return True
if scope == "deploy" and _ADMIN_ONLY_WRITES.match(path):
return True
return False
def bearer_from_header(header: str) -> str | None:
"""The token of an ``Authorization: Bearer opx_...`` header, else None."""
scheme, _, value = header.partition(" ")
value = value.strip()
if scheme.lower() == "bearer" and value.startswith(TOKEN_PREFIX):
return value
return None
async def create(
db: AsyncSession, user: AdminUser, *, name: str, scope: str, expires_in_days: int | None
) -> tuple[ApiToken, str]:
count = len((await db.scalars(select(ApiToken.id).where(ApiToken.user_id == user.id))).all())
if count >= MAX_TOKENS_PER_USER:
raise api_error(409, ErrorCode.API_TOKEN_LIMIT_REACHED, limit=MAX_TOKENS_PER_USER)
raw = TOKEN_PREFIX + secrets.token_urlsafe(32)
token = ApiToken(
user_id=user.id,
name=name,
scope=scope,
token_hash=hash_token(raw),
prefix=raw[: len(TOKEN_PREFIX) + 6],
expires_at=_now() + timedelta(days=expires_in_days) if expires_in_days else None,
)
db.add(token)
await db.commit()
await db.refresh(token)
return token, raw
async def resolve(db: AsyncSession, raw: str, ip: str | None) -> tuple[AdminUser, ApiToken] | None:
"""The owner and token for a raw bearer token, or None if unknown/expired."""
token = await db.scalar(select(ApiToken).where(ApiToken.token_hash == hash_token(raw)))
if token is None or (token.expires_at is not None and token.expires_at <= _now()):
return None
user = await db.get(AdminUser, token.user_id)
if user is None:
return None
if token.last_used_at is None or _now() - token.last_used_at > _LAST_USED_GRANULARITY:
token.last_used_at = _now()
token.last_used_ip = ip
await db.commit()
return user, token
async def revoke_all(db: AsyncSession, user_id, *, commit: bool = True) -> None:
"""Delete every token of an account (password reset: answer to a suspected compromise)."""
await db.execute(delete(ApiToken).where(ApiToken.user_id == user_id))
if commit:
await db.commit()
+3 -2
View File
@@ -1,7 +1,7 @@
"""Authentication core: password hashing, server-side sessions, brute-force
protection and the initial admin bootstrap.
Two roles ("admin", "read_only"), no orgs, no API keys — see
Two roles ("admin", "read_only"), no orgs; API tokens live in api_token_service — see
AdminUser/AuthMiddleware for how a role is enforced. Originally mirrored
InfraPanel's own app/services/auth_service.py (single-admin-only); the role
column and its enforcement are OPNexus-specific additions on top of that.
@@ -367,7 +367,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
logger.warning(
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
" generated password:\n\n %s\n\n"
" Log in and change it now. This is shown only once.\n%s",
" Log in and change it now. This is shown only once.\n"
" Lost it? ./opnexus password (sets and prints a new one)\n%s",
"=" * 64, settings.admin_username, password, "=" * 64,
)
else:
+276
View File
@@ -0,0 +1,276 @@
"""Security posture of a firewall: hardening checks and rule hygiene.
The checks read the firewall's own config.xml (one read-only download, parsed
in memory with defusedxml, never stored) plus two facts OPNexus already knows:
whether firmware updates are pending and how old the newest local backup is.
The XML layout was verified against real OPNsense 26.7 configs: rules live in
``OPNsense/Firewall/Filter/rules/rule`` (MVC) or, on installs not yet migrated,
in ``filter/rule`` (legacy). Both are normalised to the same shape below.
``evaluate`` is pure (bytes in, findings out). A finding is
``{id, severity, passed, params}``; ``passed`` is None when a check could not be
decided. The score starts at 100 and loses a penalty per failed check, so one
critical finding (an any-any pass rule on the WAN) already turns the host red.
"""
import re
from datetime import datetime, timezone
from xml.etree.ElementTree import Element
import defusedxml.ElementTree as safe_ET
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost
from app.services import backup_store
from app.services import opnsense_client as client
from app.services.opnsense_client import OpnsenseAPIError
MAX_CONFIG_BYTES = 32 * 1024 * 1024
BACKUP_MAX_AGE_DAYS = 7
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
MAX_EXAMPLES = 5
def grade(score: int) -> str:
return "good" if score >= 90 else "fair" if score >= 70 else "poor"
def _text(el: Element | None, path: str) -> str | None:
node = el.find(path) if el is not None else None
if node is None or node.text is None or not node.text.strip():
return None
return node.text.strip()
# ── Rule normalisation ───────────────────────────────────────────────────
def _endpoint(node: Element | None) -> tuple[str, bool, str | None]:
"""(network, negated, port) of a legacy <source>/<destination> element."""
if node is None:
return "any", False, None
negated = node.find("not") is not None
port = _text(node, "port")
if node.find("any") is not None:
return "any", negated, port
for tag in ("network", "address"):
value = _text(node, tag)
if value:
return value, negated, port
return "any", negated, port
def _mvc_rule(rule: Element) -> dict:
return {
"enabled": _text(rule, "enabled") != "0",
"action": (_text(rule, "action") or "pass").lower(),
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
"direction": _text(rule, "direction") or "in",
"ipprotocol": _text(rule, "ipprotocol") or "inet",
"protocol": (_text(rule, "protocol") or "any").lower(),
"src": _text(rule, "source_net") or "any", "src_not": _text(rule, "source_not") == "1",
"sport": _text(rule, "source_port"),
"dst": _text(rule, "destination_net") or "any", "dst_not": _text(rule, "destination_not") == "1",
"dport": _text(rule, "destination_port"),
"floating": False,
"quick": _text(rule, "quick") != "0",
"descr": _text(rule, "description") or "",
"order": int(_text(rule, "sequence") or 0),
}
def _legacy_rule(rule: Element, index: int) -> dict:
src, src_not, sport = _endpoint(rule.find("source"))
dst, dst_not, dport = _endpoint(rule.find("destination"))
return {
"enabled": rule.find("disabled") is None,
"action": (_text(rule, "type") or "pass").lower(),
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
"direction": _text(rule, "direction") or "in",
"ipprotocol": _text(rule, "ipprotocol") or "inet",
"protocol": (_text(rule, "protocol") or "any").lower(),
"src": src, "src_not": src_not, "sport": sport, "dst": dst, "dst_not": dst_not, "dport": dport,
"floating": _text(rule, "floating") == "yes",
"quick": _text(rule, "quick") != "0",
"descr": _text(rule, "descr") or "",
"order": index,
}
def extract_rules(root: Element) -> list[list[dict]]:
"""Rule lists in evaluation order, one per source (MVC, legacy)."""
mvc = sorted((_mvc_rule(r) for r in root.findall("OPNsense/Firewall/Filter/rules/rule")), key=lambda r: r["order"])
legacy = [_legacy_rule(r, i) for i, r in enumerate(root.findall("filter/rule"))]
return [group for group in (mvc, legacy) if group]
def wan_interfaces(root: Element) -> set[str]:
"""Interfaces facing the outside: the one named wan*, or any with an upstream gateway."""
result = set()
for node in root.findall("interfaces/*"):
if node.tag.lower().startswith("wan") or _text(node, "gateway"):
result.add(node.tag)
return result
def _is_any_any(rule: dict) -> bool:
return (
rule["enabled"] and rule["action"] == "pass" and rule["src"] == "any" and not rule["src_not"]
and rule["dst"] == "any" and not rule["dst_not"] and rule["protocol"] == "any"
and not rule["sport"] and not rule["dport"]
)
def _covers(a: dict, b: dict) -> bool:
"""Every packet matched by rule b is already matched by the earlier rule a.
Only exact-or-any comparisons, so it never claims coverage it cannot prove."""
if not (a["enabled"] and b["enabled"]) or a["floating"] or b["floating"] or not a["quick"]:
return False
if not a["interfaces"] or not set(b["interfaces"]) <= set(a["interfaces"]):
return False
if a["direction"] != b["direction"] or a["ipprotocol"] != b["ipprotocol"]:
return False
if a["protocol"] != "any" and a["protocol"] != b["protocol"]:
return False
for side in ("src", "dst"):
if a[side + "_not"] or b[side + "_not"]:
if (a[side], a[side + "_not"]) != (b[side], b[side + "_not"]):
return False
elif a[side] != "any" and a[side] != b[side]:
return False
for port in ("sport", "dport"):
if a[port] and a[port] != b[port]:
return False
return True
def _label(rule: dict) -> str:
return rule["descr"] or f"{rule['action']} {rule['src']} → {rule['dst']}"
def analyse_rules(groups: list[list[dict]]) -> dict:
shadowed, duplicates = [], []
for rules in groups:
for j, later in enumerate(rules):
for earlier in rules[:j]:
if _covers(earlier, later):
(duplicates if earlier["action"] == later["action"] and _covers(later, earlier) else shadowed).append(_label(later))
break
return {"shadowed": shadowed, "duplicates": duplicates}
# ── Checks ───────────────────────────────────────────────────────────────
def _finding(check_id: str, severity: str, passed: bool | None, **params) -> dict:
return {"id": check_id, "severity": severity, "passed": passed, "params": params}
def _listing(items: list[str]) -> dict:
return {"count": len(items), "examples": items[:MAX_EXAMPLES]}
def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None) -> list[dict]:
if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip():
raise ValueError("config.xml missing or too large")
root = safe_ET.fromstring(xml_bytes)
if root.tag != "opnsense":
raise ValueError("not an OPNsense config.xml")
findings: list[dict] = []
# Management access
protocol = _text(root, "system/webgui/protocol")
findings.append(_finding("webgui_https", "high", protocol == "https", protocol=protocol or "http"))
ssh = root.find("system/ssh")
ssh_enabled = ssh is not None and _text(ssh, "enabled") in ("enabled", "1")
root_login = ssh_enabled and _text(ssh, "permitrootlogin") == "1"
password_login = ssh_enabled and _text(ssh, "passwordauth") == "1"
if root_login and password_login:
findings.append(_finding("ssh_hardened", "high", False, reason="root_password"))
elif password_login:
findings.append(_finding("ssh_hardened", "medium", False, reason="password"))
else:
findings.append(_finding("ssh_hardened", "high", True))
community = _text(root, "snmpd/rocommunity")
if root.find("snmpd") is not None:
findings.append(_finding("snmp_community", "high", (community or "").lower() not in ("", "public")))
# Rules
wans = wan_interfaces(root)
groups = extract_rules(root)
all_rules = [rule for group in groups for rule in group]
wan_open = [_label(r) for r in all_rules if _is_any_any(r) and set(r["interfaces"]) & wans]
findings.append(_finding("wan_any_any", "critical", not wan_open, **_listing(wan_open)))
inner_open = [_label(r) for r in all_rules if _is_any_any(r) and r["interfaces"] and not set(r["interfaces"]) & wans]
findings.append(_finding("internal_any_any", "medium", not inner_open, **_listing(inner_open)))
analysis = analyse_rules(groups)
findings.append(_finding("rules_shadowed", "medium", not analysis["shadowed"], **_listing(analysis["shadowed"])))
findings.append(_finding("rules_duplicate", "low", not analysis["duplicates"], **_listing(analysis["duplicates"])))
undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]]
findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented)))
# Operations
findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers"))))
syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"]
findings.append(_finding("remote_logging", "low", bool(syslog_targets) or bool(_text(root, "syslog/remoteserver"))))
findings.append(_finding("ids_enabled", "info", _text(root, "OPNsense/IDS/general/enabled") == "1"))
# From what OPNexus already knows
findings.append(_finding("firmware_current", "medium", None if firmware_status is None else firmware_status != "update"))
if newest_backup_age_s is None:
findings.append(_finding("backup_recent", "medium", False, age_days=None))
else:
age_days = int(newest_backup_age_s // 86400)
findings.append(_finding("backup_recent", "medium", age_days < BACKUP_MAX_AGE_DAYS, age_days=age_days))
return findings
def score(findings: list[dict]) -> int:
return max(0, 100 - sum(PENALTY[f["severity"]] for f in findings if f["passed"] is False))
def counts(findings: list[dict]) -> dict:
result = {severity: 0 for severity in SEVERITY_ORDER}
for finding in findings:
if finding["passed"] is False:
result[finding["severity"]] += 1
return result
# ── Running it for a host ────────────────────────────────────────────────
async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
"""Download the config, evaluate, store (one row per host, replaced each time)."""
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host.id))
if row is None:
row = ComplianceResult(host_id=host.id)
db.add(row)
row.checked_at = datetime.now(timezone.utc)
try:
xml = await client.download_backup(host)
try:
firmware_status = (await client.get_firmware_status(host)).get("status")
except OpnsenseAPIError:
firmware_status = None
snapshots = backup_store.list_snapshots(host.id)
age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age)
row.findings, row.score, row.error = findings, score(findings), None
except OpnsenseAPIError as exc:
row.findings, row.score, row.error = [], None, "unreachable"
row.error_detail = str(exc)[:300]
except ValueError as exc:
row.findings, row.score, row.error = [], None, "invalid_config"
row.error_detail = str(exc)[:300]
await db.commit()
return row
async def check_all(db: AsyncSession) -> int:
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
for host in hosts:
await check_host(db, host)
return len(hosts)
@@ -59,7 +59,9 @@ async def _shared_change_blockers(db, fleet):
return blocked
async def inspect_host(db, host):
async def inspect_host(db, host, *, skip_role_checks=False):
"""skip_role_checks: for the planned CARP handover, where the node is still MASTER when
it is inspected and becomes BACKUP only afterwards (the role is verified separately)."""
fleet = await peers(db, host)
statuses, info, firmware, running = await asyncio.gather(
get_statuses(fleet), client.get_firmware_info(host), client.get_firmware_status(host), client.get_firmware_running(host))
@@ -68,7 +70,7 @@ async def inspect_host(db, host):
if statuses.get(host.id, {}).get("status") != "online": blocked.append("Host ist nicht erreichbar.")
# Fail closed: only the verified ready value permits a disruptive action.
if running.get("status") != "ready": blocked.append("Firmware-Auftrag läuft oder Status ist unbekannt.")
if host.cluster_name:
if host.cluster_name and not skip_role_checks:
others = [h for h in fleet if h.id != host.id]
if statuses.get(host.id, {}).get("carp_status") != "BACKUP":
blocked.append("Cluster-Wartung nur auf dem CARP-BACKUP. Rollenwechsel separat in OPNsense durchführen.")
+155
View File
@@ -0,0 +1,155 @@
"""Prometheus exposition of the fleet state (GET /api/metrics).
Deliberately database-only, like the dashboard summary: a scrape must never
fan out live calls to every firewall (an offline host would stall it past the
scraper's timeout and a 15 s scrape interval would hammer the fleet). The
inputs are what OPNexus' own pollers already store:
* host reachability and resources — the 60 s resource sampler (a host that
produced no sample for ``STALE_AFTER`` is reported down),
* CARP role — the latest observed transition (carp_events),
* open problems — the notification engine's problem table,
* pending actions — the same list the dashboard shows.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.carp_event import CarpEvent
from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost
from app.models.notification import NotificationProblem
from app.models.resource_sample import HostResourceSample
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.routers.dashboard import collect_pending_actions
from app.services import compliance_service
from app.version import APP_VERSION
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
# The sampler runs every 60 s; three missed rounds mean the host is not reachable.
STALE_AFTER = timedelta(seconds=180)
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
class _Exposition:
def __init__(self) -> None:
self.lines: list[str] = []
def family(self, name: str, help_text: str, kind: str = "gauge") -> None:
self.lines.append(f"# HELP {name} {help_text}")
self.lines.append(f"# TYPE {name} {kind}")
def sample(self, name: str, value: float, **labels: str) -> None:
rendered = ",".join(f'{key}="{_escape(str(val))}"' for key, val in labels.items() if val is not None)
number = repr(float(value)) if not float(value).is_integer() else str(int(value))
self.lines.append(f"{name}{{{rendered}}} {number}" if rendered else f"{name} {number}")
def text(self) -> str:
return "\n".join(self.lines) + "\n"
async def render(db: AsyncSession) -> str:
now = datetime.now(timezone.utc)
out = _Exposition()
out.family("opnexus_info", "OPNexus version (always 1).")
out.sample("opnexus_info", 1, version=APP_VERSION)
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
latest_sample = (
select(HostResourceSample.host_id, func.max(HostResourceSample.sampled_at).label("at"))
.group_by(HostResourceSample.host_id)
.subquery()
)
samples = {
sample.host_id: sample
for sample in (
await db.scalars(
select(HostResourceSample).join(
latest_sample,
(HostResourceSample.host_id == latest_sample.c.host_id)
& (HostResourceSample.sampled_at == latest_sample.c.at),
)
)
).all()
}
latest_event = (
select(CarpEvent.host_id, func.max(CarpEvent.detected_at).label("at")).group_by(CarpEvent.host_id).subquery()
)
carp_state = {
event.host_id: event.to_status
for event in (
await db.scalars(
select(CarpEvent).join(
latest_event, (CarpEvent.host_id == latest_event.c.host_id) & (CarpEvent.detected_at == latest_event.c.at)
)
)
).all()
}
out.family("opnexus_hosts", "Number of managed firewalls.")
out.sample("opnexus_hosts", len(hosts))
out.family("opnexus_host_enabled", "1 if monitoring of the host is enabled.")
out.family("opnexus_host_up", "1 if the host answered the resource sampler within the last 3 minutes.")
out.family("opnexus_host_last_sample_timestamp_seconds", "Unix time of the last successful resource sample.")
out.family("opnexus_host_memory_used_percent", "Memory in use, percent (last sample).")
out.family("opnexus_host_load1", "1-minute load average (last sample).")
out.family("opnexus_host_disk_used_percent", "Fullest disk, percent (last sample).")
out.family("opnexus_host_carp_master", "1 if the host's last observed CARP role is MASTER, 0 if BACKUP; absent if unknown.")
for host in hosts:
labels = {"host": host.name, "cluster": host.cluster_name or ""}
sample = samples.get(host.id)
up = bool(host.enabled and sample is not None and now - sample.sampled_at <= STALE_AFTER)
out.sample("opnexus_host_enabled", 1 if host.enabled else 0, **labels)
out.sample("opnexus_host_up", 1 if up else 0, **labels)
if sample is not None:
out.sample("opnexus_host_last_sample_timestamp_seconds", sample.sampled_at.timestamp(), **labels)
for name, value in (
("opnexus_host_memory_used_percent", sample.mem_used_pct),
("opnexus_host_load1", sample.load_avg_1m),
("opnexus_host_disk_used_percent", sample.disk_used_pct),
):
if value is not None:
out.sample(name, value, **labels)
if carp_state.get(host.id) in ("MASTER", "BACKUP"):
out.sample("opnexus_host_carp_master", 1 if carp_state[host.id] == "MASTER" else 0, **labels)
out.family("opnexus_host_compliance_score", "Security posture score 0-100 of the last check (absent if never checked).")
out.family("opnexus_host_compliance_findings", "Failed security checks of the last check, by severity.")
results = {r.host_id: r for r in (await db.scalars(select(ComplianceResult))).all()}
for host in hosts:
row = results.get(host.id)
if row is None or row.score is None:
continue
labels = {"host": host.name, "cluster": host.cluster_name or ""}
out.sample("opnexus_host_compliance_score", row.score, **labels)
for severity, count in compliance_service.counts(row.findings).items():
out.sample("opnexus_host_compliance_findings", count, severity=severity, **labels)
out.family("opnexus_pending_actions", "Deployments and operations that need attention, by kind and status.")
counts: dict[tuple[str, str], int] = {}
for action in await collect_pending_actions(db):
counts[(action.kind, action.status)] = counts.get((action.kind, action.status), 0) + 1
for (kind, status), count in sorted(counts.items()):
out.sample("opnexus_pending_actions", count, kind=kind, status=status)
out.family("opnexus_problems_open", "Open problems known to the notification engine, by type.")
rows = (
await db.execute(
select(NotificationProblem.problem_type, func.count())
.where(NotificationProblem.status == "open")
.group_by(NotificationProblem.problem_type)
)
).all()
for problem_type, count in sorted(rows):
out.sample("opnexus_problems_open", count, type=problem_type)
out.family("opnexus_scheduled_maintenance_pending", "Scheduled maintenance jobs that have not run yet.")
pending = await db.scalar(select(func.count()).select_from(ScheduledMaintenance).where(ScheduledMaintenance.status == "pending"))
out.sample("opnexus_scheduled_maintenance_pending", pending or 0)
return out.text()
@@ -3,6 +3,7 @@ whatever's currently bad. Pure detection — no delivery, no state tracking
here (see notification_dedup.py for the open/resolved diffing that turns
these snapshots into actual notifications)."""
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +15,7 @@ from app.models.one_to_one_deployment import OneToOneDeployment
from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.port_forward_deployment import PortForwardDeployment
from app.models.rule_deployment import RuleDeployment
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import nat_drift_service, network_drift_service, rule_drift_service
from app.services.notification_dedup import Problems
from app.services.host_status_service import get_statuses
@@ -262,3 +264,29 @@ async def check_carp_auto_revert_failed(db: AsyncSession) -> dict[str, dict]:
"message": window.message,
}
return problems
# A failed scheduled run never turns "fixed" by itself, so it is reported as an event:
# open for this long after its planned time, then closed without a "resolved" message
# (see notification_service._NO_RESOLVED_NOTICE).
SCHEDULED_FAILURE_WINDOW = timedelta(days=3)
async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict]:
since = datetime.now(timezone.utc) - SCHEDULED_FAILURE_WINDOW
result = await db.execute(
select(ScheduledMaintenance).where(
ScheduledMaintenance.status == "failed", ScheduledMaintenance.run_at >= since
)
)
problems: dict[str, dict] = {}
for sched in result.scalars().all():
problems[str(sched.id)] = {
"schedule_id": str(sched.id),
"host_id": str(sched.host_id),
"host_name": sched.host_name,
"action": sched.action,
"run_at": sched.run_at.isoformat(),
"message": (sched.result or {}).get("error"),
}
return problems
@@ -33,6 +33,7 @@ _LABELS = {
"nat_drift": "NAT-Drift im Cluster",
"network_drift": "Netzwerk-Abweichung im Cluster",
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
}
@@ -30,13 +30,21 @@ _CHECKS = [
("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),
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
]
# Event-like problems: when they drop out of the window nothing was "fixed", so no
# "behoben" message goes out for them.
_NO_RESOLVED_NOTICE = {"scheduled_maintenance_failed"}
async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None:
"""Best-effort, not an audit trail: attempted once per transition and
never retried, even on failure — avoids retry-storming a broken webhook
URL or SMTP relay every poll cycle."""
if transition == "resolved" and problem.problem_type in _NO_RESOLVED_NOTICE:
problem.resolved_notified_at = _now()
return
if settings_row.webhook_enabled:
try:
await notification_delivery.send_webhook(settings_row, problem, transition)
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
"smtp_from_address", "smtp_to_addresses",
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
"network_drift_enabled", "carp_auto_revert_failed_enabled",
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled",
"poll_interval_seconds",
]
for field in simple_fields:
@@ -6,6 +6,9 @@ action dispatch table) rather than duplicating that logic differently,
since this is the exact same remote action, just fired by a timer instead
of a click.
Updates take a fresh config backup right before firing (a plan for the day after tomorrow
would otherwise always trip over the 24h freshness rule).
v1 scope decision: a blocked or failed scheduled fire is logged and
recorded on the ScheduledMaintenance row's own `result` field (visible in
the UI) but is NOT wired into the NotificationSettings webhook/email
@@ -15,7 +18,7 @@ for this is a bigger change than this feature's own scope. Flagged here,
not silently dropped, in case it's wanted later.
"""
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException
from sqlalchemy import select
@@ -24,12 +27,30 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import audit_service, opnsense_client as client
from app.services.host_maintenance_service import ensure_no_maintenance, inspect_host
from app.services import audit_service, backup_store, opnsense_client as client
from app.services.host_maintenance_service import ACTIVE, ensure_no_maintenance, inspect_carp_switch, inspect_host, peers
from app.services.host_status_service import get_statuses
logger = logging.getLogger(__name__)
_ACTION_FN = {"reboot": client.reboot_host, "update": client.update_firmware, "major_update": client.upgrade_firmware}
# Actions that need a config backup no older than 24h (see host_maintenance_service.inspect_host).
# Unattended, nobody can take it by hand beforehand, so it is taken right before firing.
_BACKUP_BEFORE = {"update", "major_update"}
# ── CARP handover run (opt-in per plan) ─────────────────────────────────────
# MASTER -> maintenance mode -> partner must become MASTER -> action -> wait until the node is
# back -> leave maintenance mode. One step per poll tick, state in the schedule row.
# Rules that keep it safe:
# * the CARP toggle is a toggle: it is only ever sent against freshly read state and never
# replayed after an unclear answer (see routers/maintenance.py carp_execute);
# * until the action was submitted, any problem restores normal operation (nothing happened yet);
# * after that, any problem leaves the node demoted — the partner keeps carrying traffic, which is
# the safe side — and the run ends as failed (=> notification) with an "unknown" operation.
HANDOVER_TIMEOUT = timedelta(minutes=3)
LEAVE_TIMEOUT = timedelta(minutes=5)
JOB_GRACE = timedelta(minutes=5) # no sign of a job after this long: treat a package update as already done
JOB_TIMEOUT = {"reboot": timedelta(minutes=20), "update": timedelta(minutes=60), "major_update": timedelta(minutes=150)}
def _now() -> datetime:
@@ -79,6 +100,16 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
await db.commit()
logger.warning("Geplante Wartung %s blockiert: %s", sched.id, exc.detail)
return
if sched.carp_handover and host.cluster_name:
try:
statuses = await get_statuses(await peers(db, host))
except client.OpnsenseAPIError as exc:
statuses = {}
logger.warning("Geplante Wartung %s: Rolle nicht lesbar: %s", sched.id, exc)
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
await _start_handover(db, sched, host)
return
# Already BACKUP (or role unclear): no handover needed, the normal path below decides.
try:
state = await inspect_host(db, host)
except client.OpnsenseAPIError as exc:
@@ -87,6 +118,18 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
await db.commit()
logger.warning("Geplante Wartung %s: Host nicht erreichbar", sched.id)
return
if sched.action in _BACKUP_BEFORE and not state["blocked"]:
# Read-only on the firewall. Fail closed: no backup, no update. Taken only after the
# other blockers passed, so a plan that is going to fail anyway does not pile up backups.
try:
backup_store.save_snapshot(host.id, await client.download_backup(host))
state = await inspect_host(db, host)
except client.OpnsenseAPIError as exc:
sched.status = "failed"
sched.result = {"error": f"Automatisches Backup vor dem Update fehlgeschlagen: {str(exc)[:300]}"}
await db.commit()
logger.warning("Geplante Wartung %s: Backup vor dem Update fehlgeschlagen", sched.id)
return
if state["actions"][sched.action]:
sched.status = "failed"
sched.result = {"error": " ".join(state["actions"][sched.action])}
@@ -125,3 +168,248 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
host_id=str(host.id), host_name=host.name, entity_id=str(op.id),
message=op.message, details={"scheduled": True, "schedule_id": str(sched.id), "response": result},
)
# ── handover run ─────────────────────────────────────────────────────────────
def _progress(sched: ScheduledMaintenance, **changes) -> dict:
"""JSON columns only notice reassignment, so always hand back a new dict."""
sched.progress = {**(sched.progress or {}), **changes}
return sched.progress
async def _audit(host, sched, op, action, status, message=None, **details):
await audit_service.persist(
kind="host", action=action, status=status, host_id=str(host.id), host_name=host.name,
entity_id=str(op.id) if op else None, message=message,
details={"scheduled": True, "schedule_id": str(sched.id), "handover": True, **details},
)
async def _end(db, sched, host, op, *, ok, message, op_status=None, **result):
sched.status = "succeeded" if ok else "failed"
sched.phase = None
sched.result = {"handover": True, **result} if ok else {"error": message[:900], **result}
if op is not None:
op.status = op_status or ("acknowledged" if ok else "unknown")
op.message = message[:1000]
await db.commit()
await _audit(host, sched, op, sched.action, "completed" if ok else (op_status or "unknown"), message)
if not ok:
logger.warning("Geplante Wartung %s (CARP-Übergabe) beendet: %s", sched.id, message)
async def _toggle(host, expected: str) -> None:
"""One CARP maintenance toggle; raises OpnsenseAPIError for an unclear or wrong-direction answer."""
result = await client.toggle_carp_maintenance(host)
if result.get("action") != expected:
raise client.OpnsenseAPIError("CARP-Antwort passt nicht zur angeforderten Richtung")
async def _start_handover(db: AsyncSession, sched: ScheduledMaintenance, host: OpnsenseHost) -> None:
"""The node is MASTER now. All preconditions are checked while nothing has been changed yet."""
def fail(message: str):
sched.status = "failed"
sched.result = {"error": message[:900]}
return db.commit()
try:
carp = await inspect_carp_switch(db, host)
state = await inspect_host(db, host, skip_role_checks=True)
blocked = list(dict.fromkeys(carp["blocked"] + state["blocked"]))
if carp["action"] != "carp_enter": # MASTER but already in maintenance mode: unclear, do not toggle blindly
blocked.append(f"{host.name} steht bereits im CARP-Wartungsmodus. Rollen prüfen.")
if not blocked and sched.action in _BACKUP_BEFORE:
backup_store.save_snapshot(host.id, await client.download_backup(host))
state = await inspect_host(db, host, skip_role_checks=True)
blocked = list(dict.fromkeys(blocked + state["actions"][sched.action]))
except client.OpnsenseAPIError as exc:
await fail(f"Vorbereitung der CARP-Übergabe fehlgeschlagen, nichts wurde verändert: {str(exc)[:300]}")
return
if blocked:
await fail(" ".join(blocked))
return
now = _now()
op = HostOperation(
host_id=host.id, host_name=host.name, actor=sched.actor, action=sched.action,
expires_at=now, fingerprint="", snapshot=state, status="starting", started_at=now,
)
db.add(op)
sched.status = "running"
await db.commit()
sched.host_operation_id = op.id
await _audit(host, sched, op, "carp_enter", "requested", snapshot=state)
try:
await _toggle(host, "enter_maintenance")
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"CARP-Umschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
f"Rollen von {host.name} und Partner prüfen.")
return
sched.phase, sched.phase_since = "handover_wait", _now()
_progress(sched, version_before=state.get("version"))
op.message = "CARP-Übergabe angefordert, warte auf Bestätigung."
await db.commit()
await _audit(host, sched, op, "carp_enter", "submitted")
async def advance_running(db: AsyncSession) -> int:
"""One step for every handover run in progress. Returns how many are still running."""
rows = list((await db.scalars(
select(ScheduledMaintenance).where(ScheduledMaintenance.status == "running", ScheduledMaintenance.phase.is_not(None))
)).all())
for sched in rows:
host = await db.get(OpnsenseHost, sched.host_id)
op = await db.get(HostOperation, sched.host_operation_id) if sched.host_operation_id else None
try:
if host is None:
raise RuntimeError("Host nicht mehr vorhanden")
if op is not None and op.status not in ACTIVE:
# A human closed or cancelled the operation: stop here and touch nothing more.
sched.status, sched.phase = "failed", None
sched.result = {"error": f"Vorgang von Hand beendet, Ablauf gestoppt. CARP-Rolle von {host.name} prüfen."}
await db.commit()
continue
await _STEPS[sched.phase](db, sched, host, op)
except Exception as exc: # a stuck run must not loop forever or take the poll loop down
logger.exception("Geplante Wartung %s: Schritt %s fehlgeschlagen", sched.id, sched.phase)
if host is not None:
await _end(db, sched, host, op, ok=False,
message=f"Unerwarteter Fehler während der CARP-Übergabe ({type(exc).__name__}). "
f"{host.name} kann im CARP-Wartungsmodus verblieben sein — Rollen prüfen.")
return sum(1 for s in rows if s.status == "running")
def _since(sched: ScheduledMaintenance) -> timedelta:
return _now() - (sched.phase_since or _now())
async def _roles(db, host) -> tuple[dict, bool]:
"""(own status, a partner is online and MASTER)."""
fleet = await peers(db, host)
statuses = await get_statuses(fleet)
partner_master = any(
statuses.get(h.id, {}).get("status") == "online" and statuses.get(h.id, {}).get("carp_status") == "MASTER"
for h in fleet if h.id != host.id
)
return statuses.get(host.id, {}), partner_master
async def _step_handover_wait(db, sched, host, op):
me, partner_master = await _roles(db, host)
if me.get("status") == "online" and me.get("carp_maintenance_mode") and me.get("carp_status") == "BACKUP" and partner_master:
try:
await _ACTION_FN[sched.action](host)
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"Auftrag „{sched.action}“ mit unklarem Ergebnis: {str(exc)[:300]}. Nicht wiederholt. "
f"{host.name} bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr.")
return
sched.phase, sched.phase_since = "job_wait", _now()
_progress(sched, job_started_at=_now().isoformat(), seen_down=False, seen_running=False)
op.status, op.message = "submitted", "Übergabe bestätigt, Auftrag gestartet."
await db.commit()
await _audit(host, sched, op, sched.action, "submitted")
return
if _since(sched) < HANDOVER_TIMEOUT:
return
# The partner never took over. Nothing has run yet, so put the node back to normal.
if me.get("status") == "online" and me.get("carp_maintenance_mode"):
try:
await _toggle(host, "leave_maintenance")
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"CARP-Übergabe nicht bestätigt, und der Normalbetrieb konnte nicht wiederhergestellt werden "
f"({str(exc)[:200]}). {host.name} prüfen.")
return
await _end(db, sched, host, op, ok=False, op_status="cancelled",
message="CARP-Übergabe nicht bestätigt (der Partner wurde nicht MASTER). Normalbetrieb wiederhergestellt, nichts aktualisiert.")
elif me.get("status") == "online":
await _end(db, sched, host, op, ok=False, op_status="cancelled",
message="CARP-Übergabe hat nicht stattgefunden (Wartungsmodus nicht aktiv). Nichts aktualisiert.")
else:
await _end(db, sched, host, op, ok=False,
message=f"CARP-Übergabe nicht bestätigt und {host.name} nicht erreichbar. Rollen prüfen.")
async def _step_job_wait(db, sched, host, op):
prog = dict(sched.progress or {})
started = datetime.fromisoformat(prog["job_started_at"])
elapsed = _now() - started
try:
running = (await client.get_firmware_running(host)).get("status")
except client.OpnsenseAPIError:
_progress(sched, seen_down=True) # rebooting: exactly what a reboot or base update looks like
running = None
else:
if running != "ready":
_progress(sched, seen_running=True)
prog = sched.progress
if running == "ready":
if sched.action == "reboot":
done = bool(prog.get("seen_down"))
else:
done = bool(prog.get("seen_down") or prog.get("seen_running")) or elapsed >= JOB_GRACE
if done:
sched.phase, sched.phase_since = "leave", _now()
await db.commit()
return
if elapsed >= JOB_TIMEOUT[sched.action]:
await _end(db, sched, host, op, ok=False,
message=f"Auftrag „{sched.action}“ auf {host.name} nicht rechtzeitig abgeschlossen. {host.name} bleibt im "
f"CARP-Wartungsmodus, der Partner trägt den Verkehr. Bitte prüfen und von Hand zurückschalten.")
return
await db.commit()
async def _step_leave(db, sched, host, op):
me, _partner = await _roles(db, host)
if me.get("status") != "online":
if _since(sched) >= LEAVE_TIMEOUT:
await _end(db, sched, host, op, ok=False,
message=f"{host.name} ist nach dem Auftrag nicht wieder erreichbar. Der Partner trägt den Verkehr.")
return
if me.get("carp_maintenance_mode"):
try:
await _toggle(host, "leave_maintenance")
except client.OpnsenseAPIError as exc:
await _end(db, sched, host, op, ok=False,
message=f"Zurückschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
f"{host.name} prüfen, ggf. „CARP-Normalbetrieb herstellen“.")
return
await _audit(host, sched, op, "carp_leave", "submitted")
sched.phase, sched.phase_since = "leave_wait", _now()
await db.commit()
async def _step_leave_wait(db, sched, host, op):
fleet = await peers(db, host)
statuses = await get_statuses(fleet)
me = statuses.get(host.id, {})
online = [h for h in fleet if statuses.get(h.id, {}).get("status") == "online"]
masters = [h for h in online if statuses.get(h.id, {}).get("carp_status") == "MASTER"]
if me.get("status") == "online" and not me.get("carp_maintenance_mode") and len(masters) == 1 and len(online) == len(fleet):
extra = {}
try:
extra["version_after"] = (await client.get_firmware_info(host)).get("product_version")
extra["reboot_required"] = str((await client.get_firmware_status(host)).get("status_reboot", "0")) == "1"
except client.OpnsenseAPIError:
pass
extra["version_before"] = (sched.progress or {}).get("version_before")
text = "Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt."
if extra.get("reboot_required"):
text += " Ein Neustart ist noch erforderlich."
await _end(db, sched, host, op, ok=True, message=text, **extra)
return
if _since(sched) >= LEAVE_TIMEOUT:
await _end(db, sched, host, op, ok=False,
message=f"Normalbetrieb nach dem Zurückschalten nicht bestätigt. Rollen von {host.name} und Partner prüfen.")
_STEPS = {
"handover_wait": _step_handover_wait,
"job_wait": _step_job_wait,
"leave": _step_leave,
"leave_wait": _step_leave_wait,
}
+70
View File
@@ -0,0 +1,70 @@
"""Client für den optionalen OPNexus-Updater-Container (updater/server.py).
Das Backend kann selbst nichts am Host ändern und soll das auch nicht können:
Es fragt den Updater-Dienst nach dem Zustand und reicht eine bereits
geprüfte Update-/Rollback-Anfrage weiter. Ist der Dienst nicht aktiviert oder
nicht erreichbar, ist das ein normaler Zustand ("nicht verfügbar"), kein Fehler.
"""
import logging
from pathlib import Path
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
_STATUS_TIMEOUT = 50.0 # der Updater fragt docker ab (bis zu 45 s)
_ACTION_TIMEOUT = 15.0
class UpdaterUnavailable(Exception):
"""Updater nicht aktiviert (kein Token) oder nicht erreichbar."""
class UpdaterRejected(Exception):
def __init__(self, code: str):
super().__init__(code)
self.code = code
def _token() -> str:
try:
token = Path(settings.updater_token_file).read_text(encoding="utf-8").strip()
except OSError:
token = ""
if not token:
raise UpdaterUnavailable("not_enabled")
return token
async def _call(method: str, path: str, payload: dict | None, timeout: float) -> dict:
headers = {"Authorization": f"Bearer {_token()}"}
try:
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.request(method, settings.updater_url.rstrip("/") + path, headers=headers, json=payload)
except httpx.HTTPError as exc:
logger.info("Updater nicht erreichbar: %s", exc.__class__.__name__)
raise UpdaterUnavailable("unreachable") from exc
try:
body = response.json()
except ValueError:
body = {}
if response.status_code == 401:
# Token passt nicht (Volume neu angelegt?) — wie "nicht verfügbar" behandeln.
raise UpdaterUnavailable("unauthorized")
if response.status_code >= 400:
raise UpdaterRejected(str(body.get("error") or "rejected"))
return body
async def status() -> dict:
return await _call("GET", "/status", None, _STATUS_TIMEOUT)
async def start_update(version: str) -> dict:
return await _call("POST", "/update", {"version": version}, _ACTION_TIMEOUT)
async def start_rollback() -> dict:
return await _call("POST", "/rollback", {}, _ACTION_TIMEOUT)
+2 -2
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung").
"""
APP_VERSION = "1.14.0"
APP_CODENAME = "Eagle"
APP_VERSION = "1.18.0"
APP_CODENAME = "Kestrel"
@@ -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,23 @@
"""Benachrichtigung, wenn eine geplante Wartung nicht ausgeführt werden konnte.
Revision ID: 0006
Revises: 0005
Create Date: 2026-10-10
"""
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS "
"scheduled_maintenance_failed_enabled boolean NOT NULL DEFAULT true"
)
def downgrade() -> None:
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS scheduled_maintenance_failed_enabled")
@@ -0,0 +1,26 @@
"""Geplante Wartung mit automatischer CARP-Übergabe: Option, Phase und Fortschritt.
Revision ID: 0007
Revises: 0006
Create Date: 2026-10-19
"""
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS carp_handover boolean NOT NULL DEFAULT false")
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS phase varchar(16)")
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS phase_since timestamp with time zone")
# The default only fills existing rows; like every JSON column here the model has no server default.
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS progress json NOT NULL DEFAULT '{}'::json")
op.execute("ALTER TABLE scheduled_maintenance ALTER COLUMN progress DROP DEFAULT")
def downgrade() -> None:
for column in ("progress", "phase_since", "phase", "carp_handover"):
op.execute(f"ALTER TABLE scheduled_maintenance DROP COLUMN IF EXISTS {column}")
@@ -0,0 +1,42 @@
"""API-Tokens für Skripte, CI, Prometheus und MCP.
Revision ID: 0008
Revises: 0007
Create Date: 2026-10-19
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Eine Altinstallation (vor Alembic) wird mit create_all() nach den aktuellen
# Modellen gebaut und hat die Tabelle dann schon.
if sa.inspect(op.get_bind()).has_table("api_tokens"):
return
op.create_table(
"api_tokens",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("scope", sa.String(length=10), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("prefix", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_ip", sa.String(length=45), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["admin_users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"])
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS api_tokens")
@@ -0,0 +1,37 @@
"""Sicherheitslage: letztes Prüfergebnis je Host.
Revision ID: 0009
Revises: 0008
Create Date: 2026-10-19
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Eine Altinstallation wird mit create_all() nach den aktuellen Modellen gebaut.
if sa.inspect(op.get_bind()).has_table("compliance_results"):
return
op.create_table(
"compliance_results",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("score", sa.Integer(), nullable=True),
sa.Column("findings", sa.JSON(), nullable=False),
sa.Column("error", sa.String(length=20), nullable=True),
sa.Column("error_detail", sa.String(length=300), nullable=True),
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_compliance_results_host_id", "compliance_results", ["host_id"], unique=True)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS compliance_results")
+281
View File
@@ -0,0 +1,281 @@
"""API tokens: creation, scopes, session-only boundaries, revocation, audit."""
from datetime import datetime, timedelta, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, update
from app.config import settings
from app.database import SessionLocal
from app.main import app
from app.models.api_token import ApiToken
from app.services import api_token_service
PASSWORD = settings.admin_password
async def _make(client, scope="read", name="ci", **extra):
resp = await client.post("/api/tokens", json={"name": name, "scope": scope, "current_password": PASSWORD, **extra})
assert resp.status_code == 201, resp.text
return resp.json()
def _bearer(token: str) -> AsyncClient:
return AsyncClient(
transport=ASGITransport(app=app), base_url="http://test", headers={"Authorization": f"Bearer {token}"}
)
async def _login(username: str, password: str) -> AsyncClient:
ac = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
resp = await ac.post("/api/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
return ac
@pytest.mark.parametrize(
"scope,method,path,denied",
[
("read", "GET", "/api/hosts", False),
("read", "POST", "/api/rules", True),
# Secret-bearing reads need the admin scope; MCP (read-only tools) is open to every scope.
("read", "GET", "/api/hosts/abc/backups/config-1.xml", True),
("deploy", "GET", "/api/hosts/abc/backups/config-1.xml", True),
("admin", "GET", "/api/hosts/abc/backups/config-1.xml", False),
("read", "GET", "/api/hosts/abc/backups", False),
("read", "GET", "/api/notifications/settings", True),
("admin", "GET", "/api/notifications/settings", False),
("read", "POST", "/api/mcp", False),
("deploy", "POST", "/api/rules", False),
("deploy", "DELETE", "/api/aliases/x", False),
("deploy", "POST", "/api/hosts", True),
("deploy", "POST", "/api/hosts/tls-probe", True),
("deploy", "PATCH", "/api/hosts/abc", True),
("deploy", "DELETE", "/api/hosts/abc", True),
("deploy", "POST", "/api/hosts/abc/maintenance/execute", False),
("deploy", "POST", "/api/users", True),
("deploy", "PUT", "/api/settings", True),
("deploy", "PUT", "/api/notifications/settings", True),
("deploy", "PUT", "/api/logs/settings", True),
("deploy", "POST", "/api/system/updater/update", True),
("admin", "POST", "/api/users", False),
("admin", "POST", "/api/hosts", False),
# Session-only for every scope, reads included.
("admin", "GET", "/api/tokens", True),
("admin", "POST", "/api/tokens", True),
("read", "GET", "/api/tokens", True),
("admin", "POST", "/api/auth/change-password", True),
("admin", "POST", "/api/auth/2fa/disable", True),
],
)
def test_scope_matrix(scope, method, path, denied):
assert api_token_service.denies(scope, method, path) is denied
@pytest.mark.asyncio
async def test_create_needs_password_and_shows_token_once(client):
bad = await client.post("/api/tokens", json={"name": "x", "current_password": "wrong"})
assert bad.status_code == 401
assert bad.json()["detail"]["code"] == "AKTUELLES_PASSWORT_IST_FALSCH"
created = await _make(client, expires_in_days=30)
assert created["token"].startswith("opx_")
assert created["scope"] == "read"
assert created["prefix"] == created["token"][:10]
assert created["expires_at"] is not None
listed = (await client.get("/api/tokens")).json()
assert [t["id"] for t in listed] == [created["id"]]
assert "token" not in listed[0] and created["token"] not in str(listed)
@pytest.mark.asyncio
async def test_only_the_hash_is_stored(client):
created = await _make(client)
async with SessionLocal() as db:
row = await db.scalar(select(ApiToken))
assert row.token_hash == api_token_service.hash_token(created["token"])
assert created["token"] not in (row.token_hash, row.prefix)
@pytest.mark.asyncio
async def test_bearer_authenticates_without_a_session(client, anon_client):
created = await _make(client)
async with _bearer(created["token"]) as api:
assert (await api.get("/api/hosts")).status_code == 200
me = (await api.get("/api/auth/me")).json()
assert me["authenticated"] and me["via"] == "token" and me["scope"] == "read"
assert (await anon_client.get("/api/hosts")).status_code == 401
@pytest.mark.asyncio
async def test_read_scope_cannot_write(client):
created = await _make(client, "read")
async with _bearer(created["token"]) as api:
resp = await api.post("/api/groups", json={"name": "g"})
assert resp.status_code == 403
assert resp.json()["detail"]["code"] == "API_TOKEN_SCOPE_FORBIDDEN"
@pytest.mark.asyncio
async def test_deploy_scope_writes_fleet_objects_but_not_access_control(client):
created = await _make(client, "deploy")
async with _bearer(created["token"]) as api:
assert (await api.post("/api/groups", json={"name": "from-ci"})).status_code == 201
for method, path, body in [
("POST", "/api/users", {"username": "evil", "password": "evil-password-123", "role": "admin"}),
("POST", "/api/hosts", {}),
]:
resp = await api.request(method, path, json=body)
assert resp.status_code == 403, (path, resp.text)
assert resp.json()["detail"]["code"] == "API_TOKEN_SCOPE_FORBIDDEN"
@pytest.mark.asyncio
async def test_tokens_cannot_manage_tokens_or_accounts(client):
created = await _make(client, "admin")
async with _bearer(created["token"]) as api:
for method, path in [("GET", "/api/tokens"), ("POST", "/api/tokens"), ("DELETE", f"/api/tokens/{created['id']}"),
("POST", "/api/auth/change-password")]:
resp = await api.request(method, path, json={"name": "x", "current_password": PASSWORD})
assert resp.status_code == 403, (path, resp.text)
assert resp.json()["detail"]["code"] == "API_TOKEN_SCOPE_FORBIDDEN"
@pytest.mark.asyncio
async def test_admin_scope_acts_as_admin_and_is_audited_with_token_name(client):
created = await _make(client, "admin", name="terraform")
async with _bearer(created["token"]) as api:
resp = await api.post("/api/users", json={"username": "viaapi", "password": "via-api-password-1", "role": "read_only"})
assert resp.status_code == 201, resp.text
items = (await client.get("/api/audit", params={"kind": "user"})).json()["items"]
assert any(e["actor"] == "admin (API: terraform)" for e in items), items
@pytest.mark.asyncio
async def test_bad_or_expired_token_is_401_and_never_falls_back_to_the_cookie(client):
# `client` carries a valid session cookie; a broken bearer header must still fail.
resp = await client.get("/api/hosts", headers={"Authorization": "Bearer opx_doesnotexist"})
assert resp.status_code == 401
created = await _make(client)
async with SessionLocal() as db:
await db.execute(update(ApiToken).values(expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)))
await db.commit()
async with _bearer(created["token"]) as api:
assert (await api.get("/api/hosts")).status_code == 401
@pytest.mark.asyncio
async def test_non_token_bearer_values_are_ignored(client):
# Only opx_ values are API tokens; anything else leaves normal cookie auth alone.
resp = await client.get("/api/hosts", headers={"Authorization": "Bearer something-else"})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_revoked_token_stops_working(client):
created = await _make(client)
async with _bearer(created["token"]) as api:
assert (await api.get("/api/hosts")).status_code == 200
assert (await client.delete(f"/api/tokens/{created['id']}")).status_code == 204
assert (await api.get("/api/hosts")).status_code == 401
assert (await client.delete(f"/api/tokens/{created['id']}")).status_code == 404
@pytest.mark.asyncio
async def test_last_used_is_recorded(client):
created = await _make(client)
async with _bearer(created["token"]) as api:
await api.get("/api/hosts")
listed = (await client.get("/api/tokens")).json()[0]
assert listed["last_used_at"] is not None
@pytest.mark.asyncio
async def test_read_only_user_gets_only_read_tokens_and_sees_only_their_own(client):
resp = await client.post("/api/users", json={"username": "tokviewer", "password": "viewer-password-123", "role": "read_only"})
assert resp.status_code == 201, resp.text
admin_token = await _make(client, "admin", name="admins")
viewer = await _login("tokviewer", "viewer-password-123")
try:
denied = await viewer.post("/api/tokens", json={"name": "x", "scope": "deploy", "current_password": "viewer-password-123"})
assert denied.status_code == 403
assert denied.json()["detail"]["code"] == "API_TOKEN_SCOPE_TOO_HIGH"
ok = await viewer.post("/api/tokens", json={"name": "grafana", "scope": "read", "current_password": "viewer-password-123"})
assert ok.status_code == 201, ok.text
mine = (await viewer.get("/api/tokens")).json()
assert [t["name"] for t in mine] == ["grafana"]
# Not someone else's token.
assert (await viewer.delete(f"/api/tokens/{admin_token['id']}")).status_code == 404
finally:
await viewer.aclose()
# The admin sees both and may revoke the viewer's.
all_tokens = {t["name"]: t for t in (await client.get("/api/tokens")).json()}
assert set(all_tokens) == {"admins", "grafana"} and all_tokens["grafana"]["owner"] == "tokviewer"
assert (await client.delete(f"/api/tokens/{all_tokens['grafana']['id']}")).status_code == 204
@pytest.mark.asyncio
async def test_demoted_owner_makes_the_token_read_only(client):
users = await client.post("/api/users", json={"username": "demotee", "password": "demotee-password-1", "role": "admin"})
uid = users.json()["id"]
owner = await _login("demotee", "demotee-password-1")
try:
created = (await owner.post("/api/tokens", json={"name": "t", "scope": "admin", "current_password": "demotee-password-1"})).json()
finally:
await owner.aclose()
assert (await client.patch(f"/api/users/{uid}/role", json={"role": "read_only"})).status_code == 200
async with _bearer(created["token"]) as api:
resp = await api.post("/api/groups", json={"name": "g"})
assert resp.status_code == 403
assert resp.json()["detail"]["code"] == "READ_ONLY_ACCESS"
@pytest.mark.asyncio
async def test_password_reset_and_user_delete_revoke_tokens(client):
resp = await client.post("/api/users", json={"username": "resetme", "password": "resetme-password-1", "role": "admin"})
uid = resp.json()["id"]
owner = await _login("resetme", "resetme-password-1")
try:
t1 = (await owner.post("/api/tokens", json={"name": "a", "current_password": "resetme-password-1"})).json()
finally:
await owner.aclose()
async with _bearer(t1["token"]) as api:
assert (await api.get("/api/hosts")).status_code == 200
assert (await client.post(f"/api/users/{uid}/reset-password", json={"new_password": "brand-new-password-1"})).status_code == 200
assert (await api.get("/api/hosts")).status_code == 401
assert (await client.delete(f"/api/users/{uid}")).status_code == 204
async with SessionLocal() as db:
assert (await db.scalars(select(ApiToken))).all() == []
@pytest.mark.asyncio
async def test_token_count_is_limited(client, monkeypatch):
monkeypatch.setattr(api_token_service, "MAX_TOKENS_PER_USER", 2)
await _make(client, name="1")
await _make(client, name="2")
resp = await client.post("/api/tokens", json={"name": "3", "current_password": PASSWORD})
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "API_TOKEN_LIMIT_REACHED"
@pytest.mark.asyncio
async def test_wrong_password_attempts_are_throttled(client):
codes = []
for _ in range(15):
codes.append((await client.post("/api/tokens", json={"name": "x", "current_password": "nope"})).status_code)
assert 429 in codes
@pytest.mark.asyncio
async def test_openapi_schema_is_served_behind_auth_with_bearer_scheme(client, anon_client):
assert (await anon_client.get("/api/openapi.json")).status_code == 401
schema = (await client.get("/api/openapi.json")).json()
assert schema["components"]["securitySchemes"]["bearerAuth"]["scheme"] == "bearer"
assert "/api/tokens" in schema["paths"] and "/api/hosts" in schema["paths"]
created = await _make(client)
async with _bearer(created["token"]) as api:
assert (await api.get("/api/openapi.json")).status_code == 200
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
assert (await client.get("/docs")).status_code != 200
+6 -4
View File
@@ -92,13 +92,15 @@ async def test_health_stays_public_but_docs_require_auth(anon_client):
# /docs/redoc/openapi.json used to be public — now require a session
# like any other endpoint, since fully documenting a firewall-fleet
# API surface to an anonymous caller is free recon for an attacker.
for path in ("/docs", "/redoc", "/openapi.json"):
for path in ("/docs", "/redoc", "/openapi.json", "/api/openapi.json"):
resp = await anon_client.get(path)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_docs_reachable_once_logged_in(client):
async def test_openapi_schema_reachable_once_logged_in(client):
# The schema lives under /api (the proxy only routes /api to the backend); the
# interactive Swagger/ReDoc pages are gone (their CDN scripts violate the CSP).
assert (await client.get("/api/openapi.json")).status_code == 200
for path in ("/docs", "/redoc", "/openapi.json"):
resp = await client.get(path)
assert resp.status_code == 200
assert (await client.get(path)).status_code == 404
+214
View File
@@ -0,0 +1,214 @@
"""Security posture: check logic on config.xml shapes taken from real OPNsense 26.7 installs,
the stored-result API, and the metrics that follow from it."""
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from app.services import compliance_service as cs
from tests.test_api_tokens import _bearer, _make
from tests.test_rules import _create_host
def _mvc_rule(*, interface="lan", action="pass", src="any", dst="any", protocol="any", descr="r", seq=1,
enabled="1", dport="", direction="in", ipprotocol="inet", quick="1", src_not="0"):
return (f"<rule><enabled>{enabled}</enabled><action>{action}</action><quick>{quick}</quick><interface>{interface}</interface>"
f"<direction>{direction}</direction><ipprotocol>{ipprotocol}</ipprotocol><protocol>{protocol}</protocol>"
f"<source_net>{src}</source_net><source_not>{src_not}</source_not><destination_net>{dst}</destination_net>"
f"<destination_not>0</destination_not><destination_port>{dport}</destination_port>"
f"<description>{descr}</description><sequence>{seq}</sequence></rule>")
def _config(*, rules="", legacy="", protocol="https", ssh="", snmpd="", timeservers="0.pool.ntp.org", syslog="",
interfaces='<wan><if>vtnet0</if></wan><lan><if>vtnet1</if></lan>', ids="0"):
return (f"<opnsense><system><webgui><protocol>{protocol}</protocol></webgui>"
f"<timeservers>{timeservers}</timeservers>{ssh}</system><interfaces>{interfaces}</interfaces>"
f"<filter>{legacy}</filter>{snmpd}<OPNsense><Firewall><Filter><rules>{rules}</rules></Filter></Firewall>"
f"<IDS><general><enabled>{ids}</enabled></general></IDS><Syslog><destinations>{syslog}</destinations></Syslog>"
f"</OPNsense></opnsense>").encode()
def _run(xml, firmware="none", backup_age=3600.0):
return {f["id"]: f for f in cs.evaluate(xml, firmware_status=firmware, newest_backup_age_s=backup_age)}
def test_clean_config_passes_everything_and_scores_100():
findings = cs.evaluate(
_config(rules=_mvc_rule(descr="lan out", src="lan"), syslog="<destination><enabled>1</enabled></destination>", ids="1"),
firmware_status="none", newest_backup_age_s=3600,
)
assert [f["id"] for f in findings if f["passed"] is False] == []
assert cs.score(findings) == 100 and cs.grade(100) == "good"
def test_wan_any_any_is_critical_and_names_the_rule():
# What the OPNexus test firewalls have: a pass-everything rule on the WAN.
by_id = _run(_config(rules=_mvc_rule(interface="wan", descr="Allow ALL")))
assert by_id["wan_any_any"]["passed"] is False and by_id["wan_any_any"]["severity"] == "critical"
assert by_id["wan_any_any"]["params"] == {"count": 1, "examples": ["Allow ALL"]}
assert cs.score(list(by_id.values())) <= 70
def test_default_lan_rule_is_not_a_finding_but_a_lan_any_source_is():
assert _run(_config(rules=_mvc_rule(src="lan", descr="Default allow LAN to any rule")))["internal_any_any"]["passed"] is True
assert _run(_config(rules=_mvc_rule(src="any", descr="open")))["internal_any_any"]["passed"] is False
def test_a_wan_rule_with_a_port_or_protocol_is_not_any_any():
for extra in ({"dport": "443"}, {"protocol": "TCP"}):
assert _run(_config(rules=_mvc_rule(interface="wan", **extra)))["wan_any_any"]["passed"] is True
def test_disabled_rules_do_not_count():
assert _run(_config(rules=_mvc_rule(interface="wan", enabled="0")))["wan_any_any"]["passed"] is True
def test_legacy_rule_format_is_understood():
legacy = ("<rule><type>pass</type><interface>wan</interface><ipprotocol>inet</ipprotocol><descr>ALLOW ANY</descr>"
"<source><any>1</any></source><destination><any/></destination></rule>")
by_id = _run(_config(legacy=legacy))
assert by_id["wan_any_any"]["passed"] is False and by_id["wan_any_any"]["params"]["examples"] == ["ALLOW ANY"]
def test_interface_with_a_gateway_counts_as_wan():
interfaces = "<opt1><if>vtnet2</if><gateway>GW1</gateway></opt1><lan><if>vtnet1</if></lan>"
assert _run(_config(interfaces=interfaces, rules=_mvc_rule(interface="opt1")))["wan_any_any"]["passed"] is False
def test_shadowed_and_duplicate_rules():
rules = (
_mvc_rule(interface="lan", src="any", dst="any", action="block", descr="block all", seq=1)
+ _mvc_rule(interface="lan", src="10.0.0.0/8", dst="any", action="pass", descr="never reached", seq=2)
+ _mvc_rule(interface="dmz", src="dmz", dst="any", descr="a", seq=3)
+ _mvc_rule(interface="dmz", src="dmz", dst="any", descr="a again", seq=4)
)
by_id = _run(_config(rules=rules))
assert by_id["rules_shadowed"]["passed"] is False and by_id["rules_shadowed"]["params"]["examples"] == ["never reached"]
assert by_id["rules_duplicate"]["passed"] is False and by_id["rules_duplicate"]["params"]["examples"] == ["a again"]
def test_coverage_is_never_claimed_without_proof():
rules = (
_mvc_rule(interface="lan", src="lan", dst="any", dport="443", seq=1, descr="https only")
+ _mvc_rule(interface="lan", src="lan", dst="any", dport="80", seq=2, descr="http")
+ _mvc_rule(interface="lan", src="lan", dst="any", seq=3, descr="everything")
+ _mvc_rule(interface="lan", src="lan", dst="any", ipprotocol="inet6", seq=4, descr="v6")
+ _mvc_rule(interface="lan", src="lan", dst="any", src_not="1", seq=5, descr="negated")
)
by_id = _run(_config(rules=rules))
assert by_id["rules_shadowed"]["passed"] is True and by_id["rules_duplicate"]["passed"] is True
def test_rules_are_ordered_by_sequence_not_file_order():
rules = _mvc_rule(src="10.0.0.0/8", descr="narrow", seq=9) + _mvc_rule(src="any", descr="broad", seq=1, action="block")
assert _run(_config(rules=rules))["rules_shadowed"]["params"]["examples"] == ["narrow"]
def test_ssh_levels():
on = "<ssh><enabled>enabled</enabled><permitrootlogin>{}</permitrootlogin><passwordauth>{}</passwordauth></ssh>"
worst = _run(_config(ssh=on.format(1, 1)))["ssh_hardened"]
assert (worst["passed"], worst["severity"]) == (False, "high")
mid = _run(_config(ssh=on.format(0, 1)))["ssh_hardened"]
assert (mid["passed"], mid["severity"]) == (False, "medium")
assert _run(_config(ssh=on.format(0, 0)))["ssh_hardened"]["passed"] is True
assert _run(_config(ssh="<ssh><permitrootlogin>1</permitrootlogin><passwordauth>1</passwordauth></ssh>"))["ssh_hardened"]["passed"] is True
assert _run(_config())["ssh_hardened"]["passed"] is True
def test_webgui_snmp_ntp_logging_ids():
assert _run(_config(protocol="http"))["webgui_https"]["passed"] is False
assert "snmp_community" not in _run(_config())
assert _run(_config(snmpd="<snmpd><rocommunity>public</rocommunity></snmpd>"))["snmp_community"]["passed"] is False
assert _run(_config(snmpd="<snmpd><rocommunity>s3cret-ro</rocommunity></snmpd>"))["snmp_community"]["passed"] is True
assert _run(_config(timeservers=""))["ntp_configured"]["passed"] is False
assert _run(_config())["remote_logging"]["passed"] is False
assert _run(_config(ids="0"))["ids_enabled"]["severity"] == "info"
def test_firmware_and_backup_inputs():
assert _run(_config(), firmware="update")["firmware_current"]["passed"] is False
assert _run(_config(), firmware=None)["firmware_current"]["passed"] is None
assert _run(_config(), backup_age=None)["backup_recent"]["passed"] is False
assert _run(_config(), backup_age=8 * 86400)["backup_recent"]["passed"] is False
assert _run(_config(), backup_age=2 * 86400)["backup_recent"]["passed"] is True
def test_unknown_results_do_not_cost_points_and_info_is_free():
findings = cs.evaluate(_config(), firmware_status=None, newest_backup_age_s=0)
failed = [f["id"] for f in findings if f["passed"] is False]
assert cs.score(findings) == 100 - sum(cs.PENALTY[f["severity"]] for f in findings if f["passed"] is False)
assert "firmware_current" not in failed and "ids_enabled" in failed and cs.PENALTY["info"] == 0
@pytest.mark.parametrize("bad", [b"", b" ", b"<html/>", b"<opnsense>", b'<!DOCTYPE x [<!ENTITY a "b">]><opnsense>&a;</opnsense>'])
def test_invalid_or_hostile_input_is_rejected(bad):
with pytest.raises(Exception):
cs.evaluate(bad, firmware_status=None, newest_backup_age_s=None)
def test_findings_carry_no_config_secrets():
xml = _config(rules=_mvc_rule(interface="wan", descr="Allow ALL")).replace(
b"<opnsense>", b"<opnsense><system2><user><password>$2y$hash</password><apikey>KEY123</apikey></user></system2>")
assert b"KEY123" not in repr(cs.evaluate(xml, firmware_status=None, newest_backup_age_s=None)).encode()
# ── API ──────────────────────────────────────────────────────────────────
def _mock_host(respx_mock, xml, firmware_status="none"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=xml))
respx_mock.get(url__regex=r".*/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": firmware_status}))
@pytest.mark.asyncio
async def test_check_stores_and_lists_the_result(client, respx_mock):
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL")))
host = await _create_host(client, "fw1", "https://fw1.test")
assert (await client.get(f"/api/hosts/{host['id']}/compliance")).json()["checked_at"] is None
result = (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()
assert result["error"] is None and result["score"] < 100 and result["grade"] in ("fair", "poor")
assert result["counts"]["critical"] == 1
assert next(f for f in result["findings"] if f["id"] == "wan_any_any")["passed"] is False
stored = (await client.get(f"/api/hosts/{host['id']}/compliance")).json()
assert stored["score"] == result["score"] and stored["findings"] == result["findings"]
fleet = (await client.get("/api/compliance")).json()
assert [(h["host_name"], h["score"]) for h in fleet] == [("fw1", result["score"])] and "findings" not in fleet[0]
# Second check replaces the row instead of adding one.
await client.post(f"/api/hosts/{host['id']}/compliance/check")
assert len((await client.get("/api/compliance")).json()) == 1
@pytest.mark.asyncio
async def test_unreachable_host_is_reported_not_scored(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(500))
result = (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()
assert result["error"] == "unreachable" and result["score"] is None and result["grade"] is None
@pytest.mark.asyncio
async def test_garbage_config_is_reported_as_invalid(client, respx_mock):
_mock_host(respx_mock, b"<html>not a config</html>")
host = await _create_host(client, "fw1", "https://fw1.test")
assert (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()["error"] == "invalid_config"
@pytest.mark.asyncio
async def test_unknown_host_is_404_and_checks_need_write_rights(client):
import uuid
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/compliance")).status_code == 404
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
assert (await api.get("/api/compliance")).status_code == 200 # reading is fine
assert (await api.post("/api/compliance/check")).status_code == 403 # running a check is not
@pytest.mark.asyncio
async def test_metrics_expose_the_score(client, respx_mock):
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL")))
host = await _create_host(client, "fw1", "https://fw1.test")
result = (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()
text = (await client.get("/api/metrics")).text
assert f'opnexus_host_compliance_score{{host="fw1",cluster=""}} {result["score"]}' in text
assert 'opnexus_host_compliance_findings{severity="critical",host="fw1",cluster=""} 1' in text
+105
View File
@@ -0,0 +1,105 @@
"""Read-only MCP endpoint: protocol basics, tools, and that it cannot be used to escalate."""
import pytest
from tests.test_api_tokens import _bearer, _make
def _rpc(method, params=None, id_=1):
message = {"jsonrpc": "2.0", "method": method}
if id_ is not None:
message["id"] = id_
if params is not None:
message["params"] = params
return message
async def _call(api, name, **arguments):
resp = await api.post("/api/mcp", json=_rpc("tools/call", {"name": name, "arguments": arguments}))
assert resp.status_code == 200, resp.text
return resp.json()["result"]
@pytest.mark.asyncio
async def test_requires_authentication(anon_client):
assert (await anon_client.post("/api/mcp", json=_rpc("ping"))).status_code == 401
@pytest.mark.asyncio
async def test_handshake_and_tool_list_with_a_read_token(client):
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
init = (await api.post("/api/mcp", json=_rpc("initialize", {"protocolVersion": "2025-06-18"}))).json()["result"]
assert init["protocolVersion"] == "2025-06-18"
assert init["serverInfo"]["name"] == "opnexus" and "tools" in init["capabilities"]
# Unknown future version: answer with one we speak.
other = (await api.post("/api/mcp", json=_rpc("initialize", {"protocolVersion": "2099-01-01"}))).json()["result"]
assert other["protocolVersion"] == "2025-06-18"
# A notification gets no body.
note = await api.post("/api/mcp", json=_rpc("notifications/initialized", id_=None))
assert note.status_code == 202 and note.content == b""
tools = (await api.post("/api/mcp", json=_rpc("tools/list"))).json()["result"]["tools"]
assert {"fleet_overview", "list_hosts", "recent_audit", "search", "api_get"} <= {t["name"] for t in tools}
assert all("inputSchema" in t and "path" not in t for t in tools)
assert (await api.post("/api/mcp", json=_rpc("ping"))).json()["result"] == {}
unknown = (await api.post("/api/mcp", json=_rpc("resources/list"))).json()
assert unknown["error"]["code"] == -32601
@pytest.mark.asyncio
async def test_get_is_405_and_bad_json_is_a_parse_error(client):
assert (await client.get("/api/mcp")).status_code == 405
bad = await client.post("/api/mcp", content=b"{nope", headers={"Content-Type": "application/json"})
assert bad.status_code == 400 and bad.json()["error"]["code"] == -32700
@pytest.mark.asyncio
async def test_tools_read_through_the_callers_own_permissions(client):
await client.post("/api/groups", json={"name": "findable-group"})
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
hosts = await _call(api, "list_hosts")
assert hosts["isError"] is False and hosts["content"][0]["text"] == "[]"
found = await _call(api, "search", q="findable")
assert "findable-group" in found["content"][0]["text"]
audit = await _call(api, "recent_audit", limit=5)
assert audit["isError"] is False
overview = await _call(api, "fleet_overview")
assert "pending_actions" in overview["content"][0]["text"]
@pytest.mark.asyncio
async def test_generic_reader_cannot_reach_credentials_or_secrets_and_never_writes(client):
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
for path in ("/api/tokens", "/api/auth/me", "/api/mcp", "/etc/passwd", "/api/../x", "/api/hosts?x=1", "relative"):
result = await _call(api, "api_get", path=path)
assert result["isError"] is True, path
# Secret-bearing endpoint: refused by the caller's own (read) scope, not by the tool.
denied = await _call(api, "api_get", path="/api/notifications/settings")
assert denied["isError"] is True and "API_TOKEN_SCOPE_FORBIDDEN" in denied["content"][0]["text"]
# api_get is GET-only by construction: there is no way to name another method.
tools = (await api.post("/api/mcp", json=_rpc("tools/list"))).json()["result"]["tools"]
schema = next(t for t in tools if t["name"] == "api_get")["inputSchema"]
assert set(schema["properties"]) == {"path", "query"}
@pytest.mark.asyncio
async def test_unknown_tool_and_bad_arguments_are_tool_errors(client):
token = await _make(client, "read")
async with _bearer(token["token"]) as api:
assert (await _call(api, "delete_everything"))["isError"] is True
assert (await _call(api, "host_resources"))["isError"] is True
bad = await api.post("/api/mcp", json=_rpc("tools/call", {"arguments": {}}))
assert bad.json()["error"]["code"] == -32602
@pytest.mark.asyncio
async def test_read_only_users_session_can_use_it_too(client):
resp = await client.post("/api/users", json={"username": "mcpviewer", "password": "viewer-password-123", "role": "read_only"})
assert resp.status_code == 201
from tests.test_api_tokens import _login
viewer = await _login("mcpviewer", "viewer-password-123")
try:
assert (await viewer.post("/api/mcp", json=_rpc("ping"))).status_code == 200
finally:
await viewer.aclose()
+104
View File
@@ -0,0 +1,104 @@
"""Prometheus endpoint: format, content and that it never calls a firewall."""
import re
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from app.database import SessionLocal
from app.models.carp_event import CarpEvent
from app.models.notification import NotificationProblem
from app.models.resource_sample import HostResourceSample
from tests.test_api_tokens import _bearer, _make
from tests.test_rules import _create_host
_SAMPLE_LINE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*(\{([a-zA-Z_][a-zA-Z0-9_]*="([^"\\]|\\.)*"(,|(?=\})))*\})? -?\d+(\.\d+)?(e[+-]?\d+)?$')
def _parse(text: str) -> dict[str, float]:
"""name{labels} -> value, asserting the exposition grammar line by line."""
values = {}
for line in text.splitlines():
if not line or line.startswith("# HELP ") or line.startswith("# TYPE "):
continue
assert _SAMPLE_LINE.match(line), f"not valid exposition: {line!r}"
key, _, value = line.rpartition(" ")
values[key] = float(value)
return values
async def _seed(host_id: str, *, age_s: int, carp: str | None):
async with SessionLocal() as db:
db.add(HostResourceSample(
host_id=host_id, sampled_at=datetime.now(timezone.utc) - timedelta(seconds=age_s),
mem_used_pct=25.0, load_avg_1m=0.42, disk_used_pct=12.0, raw={},
))
if carp:
db.add(CarpEvent(host_id=host_id, host_name="fw1", cluster_name="ha", from_status=None, to_status=carp))
await db.commit()
@pytest.mark.asyncio
async def test_metrics_requires_auth_and_is_prometheus_text(client, anon_client):
assert (await anon_client.get("/api/metrics")).status_code == 401
resp = await client.get("/api/metrics")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/plain; version=0.0.4")
values = _parse(resp.text)
assert values["opnexus_hosts"] == 0
assert any(key.startswith('opnexus_info{version="') for key in values)
@pytest.mark.asyncio
async def test_host_metrics_from_stored_samples_without_calling_the_firewall(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
await _seed(host["id"], age_s=30, carp="MASTER")
before = len(respx_mock.calls)
values = _parse((await client.get("/api/metrics")).text)
# The scrape is DB-only: not a single request went to a firewall.
assert len(respx_mock.calls) == before
assert values["opnexus_hosts"] == 1
assert values['opnexus_host_up{host="fw1",cluster=""}'] == 1
assert values['opnexus_host_memory_used_percent{host="fw1",cluster=""}'] == 25
assert values['opnexus_host_load1{host="fw1",cluster=""}'] == 0.42
assert values['opnexus_host_disk_used_percent{host="fw1",cluster=""}'] == 12
assert values['opnexus_host_carp_master{host="fw1",cluster=""}'] == 1
@pytest.mark.asyncio
async def test_stale_sample_means_host_down_and_unknown_carp_is_omitted(client):
host = await _create_host(client, "fw1", "https://fw1.test")
await _seed(host["id"], age_s=600, carp=None)
text = (await client.get("/api/metrics")).text
values = _parse(text)
assert values['opnexus_host_up{host="fw1",cluster=""}'] == 0
assert "opnexus_host_carp_master{" not in text
@pytest.mark.asyncio
async def test_open_problems_are_counted_and_resolved_ones_are_not(client):
async with SessionLocal() as db:
db.add(NotificationProblem(problem_type="host_offline", dedup_key="a", status="open"))
db.add(NotificationProblem(problem_type="host_offline", dedup_key="b", status="open"))
db.add(NotificationProblem(problem_type="cert_expiry", dedup_key="c", status="resolved"))
await db.commit()
values = _parse((await client.get("/api/metrics")).text)
assert values['opnexus_problems_open{type="host_offline"}'] == 2
assert not any("cert_expiry" in key for key in values)
@pytest.mark.asyncio
async def test_label_values_are_escaped(client):
await _create_host(client, 'we"ird\\name', "https://fw1.test")
text = (await client.get("/api/metrics")).text
assert 'host="we\\"ird\\\\name"' in text
_parse(text)
@pytest.mark.asyncio
async def test_read_token_can_scrape(client):
created = await _make(client, "read", name="prometheus")
async with _bearer(created["token"]) as api:
resp = await api.get("/api/metrics")
assert resp.status_code == 200 and "opnexus_info" in resp.text
+335
View File
@@ -0,0 +1,335 @@
"""Scheduled maintenance with the opt-in CARP handover: MASTER -> maintenance -> partner MASTER ->
action -> node back -> leave maintenance. A tiny fake cluster stands in for the two firewalls."""
import uuid
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models import HostOperation
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import backup_store, scheduled_maintenance_service as svc
from tests.test_rules import _create_host
A, B = "https://fw-a.test", "https://fw-b.test"
class FakeCluster:
"""fw-a is the node being maintained (MASTER), fw-b the partner."""
def __init__(self, respx_mock, *, toggle_flips=True, partner_follows=True, toggle_error=None, firmware="update"):
self.mock = respx_mock
self.roles = {A: {"role": "MASTER", "maintenance": False}, B: {"role": "BACKUP", "maintenance": False}}
self.job = "ready" # what fw-a's firmware/running reports: ready | running | down
self.toggle_flips, self.partner_follows, self.toggle_error, self.firmware = toggle_flips, partner_follows, toggle_error, firmware
self.toggles = respx_mock.post(f"{A}/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=self._toggle)
self.update = respx_mock.post(f"{A}/api/core/firmware/update").respond(200, json={"status": "ok"})
self.reboot = respx_mock.post(f"{A}/api/core/system/reboot").respond(200, json={"status": "ok"})
self.backup = respx_mock.get(f"{A}/api/core/backup/download/this").respond(200, content=b"<opnsense/>")
for base in (A, B):
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=lambda r, b=base: self._vip(b))
respx_mock.get(f"{base}/api/core/firmware/info").respond(200, json={"product_version": "26.7.5"})
respx_mock.get(f"{base}/api/core/firmware/status").respond(
200, json={"status": firmware, "all_packages": {}, "status_msg": "x", "status_reboot": "0"})
respx_mock.get(f"{A}/api/core/firmware/running").mock(side_effect=self._running_a)
respx_mock.get(f"{B}/api/core/firmware/running").respond(200, json={"status": "ready"})
def _vip(self, base):
if base == A and self.job == "down":
raise httpx.ConnectError("rebooting")
cur = self.roles[base]
return httpx.Response(200, json={"rows": [{"vhid": "1", "status": cur["role"]}], "carp": {
"allow": "1", "maintenancemode": cur["maintenance"], "demotion": "240" if cur["maintenance"] else "0", "status_msg": ""}})
def _running_a(self, request):
if self.job == "down":
raise httpx.ConnectError("rebooting")
return httpx.Response(200, json={"status": self.job})
def _toggle(self, request):
if self.toggle_error:
raise self.toggle_error
cur = self.roles[A]
cur["maintenance"] = not cur["maintenance"]
entering = cur["maintenance"]
if self.toggle_flips:
cur["role"] = "BACKUP" if entering else "MASTER"
if self.partner_follows:
self.roles[B]["role"] = "MASTER" if entering else "BACKUP"
return httpx.Response(200, json={"status": "ok", "action": "enter_maintenance" if entering else "leave_maintenance"})
async def _setup(client, **kwargs):
a = await _create_host(client, "fw-a", A)
b = await _create_host(client, "fw-b", B)
await client.patch(f"/api/hosts/{a['id']}", json={"cluster_name": "ha"})
await client.patch(f"/api/hosts/{b['id']}", json={"cluster_name": "ha"})
return a, b
async def _plan(host, action="update", handover=True):
async with SessionLocal() as db:
s = ScheduledMaintenance(
host_id=uuid.UUID(host["id"]), host_name=host["name"], action=action, carp_handover=handover,
run_at=datetime.now(timezone.utc) - timedelta(minutes=1), actor="admin", created_at=datetime.now(timezone.utc))
db.add(s)
await db.commit()
return s.id
async def _fire():
async with SessionLocal() as db:
return await svc.fire_due(db)
async def _advance():
async with SessionLocal() as db:
return await svc.advance_running(db)
async def _get(sched_id):
async with SessionLocal() as db:
s = await db.get(ScheduledMaintenance, sched_id)
op = await db.get(HostOperation, s.host_operation_id) if s.host_operation_id else None
return s, op
async def _age(sched_id, *, phase=None, job=None):
"""Pretend the phase (or the job) started `phase`/`job` ago."""
async with SessionLocal() as db:
s = await db.get(ScheduledMaintenance, sched_id)
if phase is not None:
s.phase_since = datetime.now(timezone.utc) - phase
if job is not None:
s.progress = {**s.progress, "job_started_at": (datetime.now(timezone.utc) - job).isoformat()}
await db.commit()
# ── happy paths ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_update_with_handover_runs_end_to_end(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
assert (await _fire())["fired"] == 1
s, op = await _get(sid)
assert (s.status, s.phase, op.status) == ("running", "handover_wait", "starting")
assert cluster.backup.call_count == 1 and cluster.toggles.call_count == 1 and cluster.update.call_count == 0
assert cluster.roles[B]["role"] == "MASTER"
assert await _advance() == 1 # partner confirmed -> job submitted
s, op = await _get(sid)
assert (s.phase, op.status) == ("job_wait", "submitted") and cluster.update.call_count == 1
cluster.job = "running"
await _advance()
cluster.job = "ready"
await _advance()
assert (await _get(sid))[0].phase == "leave"
assert cluster.toggles.call_count == 1 # not back yet
await _advance() # leave: toggles once
assert cluster.toggles.call_count == 2 and (await _get(sid))[0].phase == "leave_wait"
assert await _advance() == 0 # roles back to normal -> done
s, op = await _get(sid)
assert s.status == "succeeded" and s.phase is None and s.result["handover"] is True
assert s.result["version_before"] == "26.7.5"
assert op.status == "acknowledged"
assert cluster.roles[A] == {"role": "MASTER", "maintenance": False}
assert cluster.update.call_count == 1 and cluster.toggles.call_count == 2
@pytest.mark.asyncio
async def test_reboot_with_handover_needs_to_see_the_node_go_down(client, respx_mock):
cluster = FakeCluster(respx_mock, firmware="none")
a, _b = await _setup(client)
sid = await _plan(a, "reboot")
await _fire()
assert cluster.backup.call_count == 0 # no backup for a plain reboot
await _advance()
assert cluster.reboot.call_count == 1
await _advance() # still "ready" and never seen down: not a finished reboot
assert (await _get(sid))[0].phase == "job_wait"
cluster.job = "down"
await _advance()
cluster.job = "ready"
await _advance()
assert (await _get(sid))[0].phase == "leave"
await _advance()
await _advance()
assert (await _get(sid))[0].status == "succeeded"
@pytest.mark.asyncio
async def test_handover_flag_on_a_backup_node_just_runs_the_action(client, respx_mock):
cluster = FakeCluster(respx_mock)
cluster.roles[A].update(role="BACKUP")
cluster.roles[B].update(role="MASTER")
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, _op = await _get(sid)
assert s.status == "succeeded" and s.phase is None
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 1
@pytest.mark.asyncio
async def test_without_the_flag_a_master_is_still_refused(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update", handover=False)
await _fire()
s, _ = await _get(sid)
assert s.status == "failed" and "CARP-BACKUP" in s.result["error"]
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 0
# ── failures: what happens to the node ────────────────────────────────────
@pytest.mark.asyncio
async def test_partner_never_takes_over_restores_normal_operation(client, respx_mock):
cluster = FakeCluster(respx_mock, toggle_flips=False)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
cluster.roles[A]["maintenance"] = True # the toggle was accepted, but no role change happened
await _advance() # too early: keep waiting
assert (await _get(sid))[0].phase == "handover_wait" and cluster.toggles.call_count == 1
await _age(sid, phase=timedelta(minutes=4))
await _advance()
s, op = await _get(sid)
assert s.status == "failed" and "wiederhergestellt" in s.result["error"] and op.status == "cancelled"
assert cluster.toggles.call_count == 2 and cluster.roles[A]["maintenance"] is False
assert cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_demoted_node_but_partner_not_master_never_starts_the_job(client, respx_mock):
"""The dangerous case: both nodes BACKUP, nobody carries traffic. The action must not start."""
cluster = FakeCluster(respx_mock, partner_follows=False)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
assert cluster.roles[A]["role"] == "BACKUP" and cluster.roles[B]["role"] == "BACKUP"
await _advance()
assert (await _get(sid))[0].phase == "handover_wait" and cluster.update.call_count == 0
await _age(sid, phase=timedelta(minutes=4))
await _advance()
s, op = await _get(sid)
assert s.status == "failed" and op.status == "cancelled" and cluster.update.call_count == 0
assert cluster.roles[A]["maintenance"] is False # normal operation restored
@pytest.mark.asyncio
async def test_unclear_toggle_answer_is_never_replayed(client, respx_mock):
cluster = FakeCluster(respx_mock, toggle_error=httpx.ReadTimeout("timeout"))
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, op = await _get(sid)
assert s.status == "failed" and op.status == "unknown" and "unklar" in s.result["error"]
assert await _advance() == 0
assert cluster.toggles.call_count == 1 and cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_job_timeout_leaves_the_node_demoted(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
await _advance()
cluster.job = "running"
await _age(sid, job=timedelta(minutes=61))
await _advance()
s, op = await _get(sid)
assert s.status == "failed" and op.status == "unknown" and "Wartungsmodus" in s.result["error"]
assert cluster.roles[A]["maintenance"] is True and cluster.toggles.call_count == 1 # the safe side: partner keeps traffic
@pytest.mark.asyncio
async def test_blocked_preconditions_change_nothing(client, respx_mock):
cluster = FakeCluster(respx_mock)
cluster.roles[B]["role"] = "MASTER" # no BACKUP partner to hand over to (split-brain-like)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, op = await _get(sid)
assert s.status == "failed" and op is None
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_failed_backup_aborts_before_any_switch(client, respx_mock):
cluster = FakeCluster(respx_mock)
respx_mock.get(f"{A}/api/core/backup/download/this").mock(side_effect=httpx.ConnectError("down"))
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
s, _ = await _get(sid)
assert s.status == "failed" and "nichts wurde verändert" in s.result["error"]
assert cluster.toggles.call_count == 0
@pytest.mark.asyncio
async def test_run_stops_when_a_human_closes_the_operation(client, respx_mock):
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
_s, op = await _get(sid)
async with SessionLocal() as db:
(await db.get(HostOperation, op.id)).status = "cancelled"
await db.commit()
assert await _advance() == 0
s, _ = await _get(sid)
assert s.status == "failed" and "von Hand" in s.result["error"]
assert cluster.toggles.call_count == 1 and cluster.update.call_count == 0
@pytest.mark.asyncio
async def test_step_survives_backend_restart(client, respx_mock):
"""Every step reads its state from the row; nothing lives in memory between ticks."""
cluster = FakeCluster(respx_mock)
a, _b = await _setup(client)
sid = await _plan(a, "update")
await _fire()
async with SessionLocal() as db: # a fresh session per step is what a restarted backend does
assert await svc.advance_running(db) == 1
async with SessionLocal() as db:
s = await db.get(ScheduledMaintenance, sid)
assert s.phase == "job_wait" and s.progress["job_started_at"]
# ── API ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_schedule_api_accepts_handover_for_the_master_only_with_the_flag(client, respx_mock):
FakeCluster(respx_mock)
a, _b = await _setup(client)
when = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
url = f"/api/hosts/{a['id']}/maintenance/schedule"
plain = await client.post(url, json={"action": "update", "run_at": when, "acknowledge_unattended": True})
assert plain.status_code == 409
ok = await client.post(url, json={"action": "update", "run_at": when, "acknowledge_unattended": True, "carp_handover": True})
assert ok.status_code == 200 and ok.json()["carp_handover"] is True and ok.json()["phase"] is None
@pytest.mark.asyncio
async def test_schedule_api_handover_needs_a_cluster(client, respx_mock):
respx_mock.get("https://solo.test/api/diagnostics/interface/getVipStatus").respond(200, json={"rows": []})
h = await _create_host(client, "solo", "https://solo.test")
when = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
r = await client.post(f"/api/hosts/{h['id']}/maintenance/schedule",
json={"action": "reboot", "run_at": when, "acknowledge_unattended": True, "carp_handover": True})
assert r.status_code == 409
assert r.json()["detail"]["code"] == "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
+165 -10
View File
@@ -1,6 +1,8 @@
import json
import uuid
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
@@ -76,25 +78,58 @@ async def test_fire_due_reboot_success(client, respx_mock):
assert reboot.call_count == 1
@pytest.mark.asyncio
async def test_fire_due_update_blocked_without_fresh_backup(client, respx_mock):
mock_reads(respx_mock, status="update")
h = await _create_host(client, "fw1", "https://fw1.test")
async def _due_update(h, action="update"):
async with SessionLocal() as db:
sched = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="update",
host_id=uuid.UUID(h["id"]), host_name="fw1", action=action,
run_at=datetime.now(timezone.utc) - timedelta(minutes=1),
actor="admin", created_at=datetime.now(timezone.utc),
)
db.add(sched)
await db.commit()
return sched.id
result = await scheduled_maintenance_service.fire_due(db)
assert result == {"due": 1, "fired": 0, "blocked_or_failed": 1}
refreshed = await db.get(ScheduledMaintenance, sched.id)
@pytest.mark.asyncio
async def test_fire_due_update_takes_its_own_backup_first(client, respx_mock):
mock_reads(respx_mock, status="update")
h = await _create_host(client, "fw1", "https://fw1.test")
download = respx_mock.get("https://fw1.test/api/core/backup/download/this").respond(200, content=b"<opnsense/>")
update = respx_mock.post("https://fw1.test/api/core/firmware/update").respond(200, json={"status": "ok"})
sched_id = await _due_update(h)
async with SessionLocal() as db:
assert await scheduled_maintenance_service.fire_due(db) == {"due": 1, "fired": 1, "blocked_or_failed": 0}
assert (await db.get(ScheduledMaintenance, sched_id)).status == "succeeded"
assert download.call_count == 1 and update.call_count == 1
assert len(backup_store.list_snapshots(uuid.UUID(h["id"]))) == 1
@pytest.mark.asyncio
async def test_fire_due_update_fails_closed_when_backup_fails(client, respx_mock):
mock_reads(respx_mock, status="update")
h = await _create_host(client, "fw1", "https://fw1.test")
respx_mock.get("https://fw1.test/api/core/backup/download/this").mock(side_effect=httpx.ConnectError("down"))
update = respx_mock.post("https://fw1.test/api/core/firmware/update").respond(200, json={"status": "ok"})
sched_id = await _due_update(h)
async with SessionLocal() as db:
assert await scheduled_maintenance_service.fire_due(db) == {"due": 1, "fired": 0, "blocked_or_failed": 1}
refreshed = await db.get(ScheduledMaintenance, sched_id)
assert refreshed.status == "failed"
assert "Konfigurationsbackup" in refreshed.result["error"]
assert "Backup" in refreshed.result["error"]
assert update.call_count == 0
@pytest.mark.asyncio
async def test_fire_due_reboot_takes_no_backup(client, respx_mock):
mock_reads(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
respx_mock.post("https://fw1.test/api/core/system/reboot").respond(200, json={"status": "ok"})
sched_id = await _due_update(h, action="reboot") # no backup route mocked: a download would raise
async with SessionLocal() as db:
await scheduled_maintenance_service.fire_due(db)
assert (await db.get(ScheduledMaintenance, sched_id)).status == "succeeded"
@pytest.mark.asyncio
@@ -126,3 +161,123 @@ async def test_fire_due_blocked_by_active_operation_on_cluster_peer(client, resp
assert result == {"due": 1, "fired": 0, "blocked_or_failed": 1}
refreshed = await db.get(ScheduledMaintenance, sched.id)
assert refreshed.status == "failed"
# ── CARP role is checked when the plan is created, not first at 3 a.m. ────
def _cluster_roles(respx_mock, roles):
for base, role in roles.items():
if role is None:
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=httpx.ConnectError("down"))
else:
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").respond(
200, json={"rows": [{"vhid": "1", "status": role}], "carp": {"allow": "1", "maintenancemode": "0", "demotion": "0"}}
)
respx_mock.get(f"{base}/api/core/firmware/running").respond(200, json={"status": "ready"})
async def _cluster(client, a_url="https://fw-a.test", b_url="https://fw-b.test"):
a = await _create_host(client, "fw-a", a_url)
b = await _create_host(client, "fw-b", b_url)
await client.patch(f"/api/hosts/{a['id']}", json={"cluster_name": "ha"})
await client.patch(f"/api/hosts/{b['id']}", json={"cluster_name": "ha"})
return a, b
@pytest.mark.asyncio
async def test_schedule_on_carp_master_is_rejected_up_front(client, respx_mock):
_cluster_roles(respx_mock, {"https://fw-a.test": "MASTER", "https://fw-b.test": "BACKUP"})
a, b = await _cluster(client)
rejected = await schedule(client, a, action="update")
assert rejected.status_code == 409, rejected.text
assert rejected.json()["detail"]["code"] == "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
assert (await client.get(f"/api/hosts/{a['id']}/maintenance/schedule")).json() == []
# The backup node is where unattended maintenance belongs.
accepted = await schedule(client, b, action="update")
assert accepted.status_code == 200, accepted.text
@pytest.mark.asyncio
async def test_schedule_accepted_when_role_cannot_be_determined(client, respx_mock):
# Unreachable now does not mean wrong at run time — the full check still runs when it fires.
_cluster_roles(respx_mock, {"https://fw-a.test": None, "https://fw-b.test": "MASTER"})
a, _b = await _cluster(client)
assert (await schedule(client, a)).status_code == 200
@pytest.mark.asyncio
async def test_schedule_on_standalone_host_skips_role_check(client, respx_mock):
mock_reads(respx_mock, carp="MASTER")
h = await _create_host(client, "fw1", "https://fw1.test")
assert (await schedule(client, h)).status_code == 200
# ── a plan that did not run is reported ───────────────────────────────────
async def _only_enable(db, flag):
from app.services import notification_settings_service
row = await notification_settings_service.get_settings(db)
for column in row.__table__.columns.keys():
if column.endswith("_enabled"):
setattr(row, column, column == flag)
row.webhook_enabled = True
row.webhook_url = "https://hooks.test/notify"
await db.commit()
@pytest.mark.asyncio
async def test_failed_schedule_is_notified_once_and_closes_silently(client, respx_mock):
from app.models.notification import NotificationProblem
from app.services.notification_service import run_notification_checks
mock_reads(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
hook = respx_mock.post("https://hooks.test/notify").respond(200)
async with SessionLocal() as db:
await _only_enable(db, "scheduled_maintenance_failed_enabled")
now = datetime.now(timezone.utc)
failed = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="update", run_at=now - timedelta(hours=2),
status="failed", actor="admin", created_at=now - timedelta(days=1),
result={"error": "Cluster-Wartung nur auf dem CARP-BACKUP."},
)
ok = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="reboot", run_at=now - timedelta(hours=1),
status="succeeded", actor="admin", created_at=now - timedelta(days=1),
)
old = ScheduledMaintenance(
host_id=uuid.UUID(h["id"]), host_name="fw1", action="reboot", run_at=now - timedelta(days=10),
status="failed", actor="admin", created_at=now - timedelta(days=11),
)
db.add_all([failed, ok, old])
await db.commit()
assert (await run_notification_checks(db))["opened"] == 1
assert hook.call_count == 1
sent = json.loads(hook.calls.last.request.content)
assert sent["problem_type"] == "scheduled_maintenance_failed"
assert sent["context"]["host_name"] == "fw1" and "CARP-BACKUP" in sent["context"]["message"]
# Still failed on the next poll: no second message.
assert (await run_notification_checks(db))["opened"] == 0
assert hook.call_count == 1
# Once it drops out of the window nothing was "fixed" — no "behoben" message.
failed.run_at = now - timedelta(days=4)
await db.commit()
result = await run_notification_checks(db)
assert result["resolved"] == 1
assert hook.call_count == 1
problem = (await db.execute(select(NotificationProblem).where(NotificationProblem.problem_type == "scheduled_maintenance_failed"))).scalar_one()
assert problem.status == "resolved"
@pytest.mark.asyncio
async def test_settings_roundtrip_scheduled_maintenance_failed_toggle(client):
assert (await client.get("/api/notifications/settings")).json()["scheduled_maintenance_failed_enabled"] is True
resp = await client.put("/api/notifications/settings", json={"scheduled_maintenance_failed_enabled": False})
assert resp.status_code == 200, resp.text
assert resp.json()["scheduled_maintenance_failed_enabled"] is False
+66
View File
@@ -0,0 +1,66 @@
"""First-run setup assistant: per-user "done" flag and the password reset tool."""
import pytest
from sqlalchemy import select
from app import reset_password
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser, Session
from app.services import auth_service
@pytest.mark.asyncio
async def test_setup_done_defaults_to_false_and_persists(client):
assert (await client.get("/api/settings")).json()["setup_done"] is False
resp = await client.put("/api/settings", json={"setup_done": True})
assert resp.status_code == 200
assert resp.json()["setup_done"] is True
assert (await client.get("/api/settings")).json()["setup_done"] is True
@pytest.mark.asyncio
async def test_other_preference_changes_keep_setup_done(client):
await client.put("/api/settings", json={"setup_done": True})
resp = await client.put("/api/settings", json={"theme": "dark", "language": "en"})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", True)
# ...and the other direction: saving setup_done leaves theme/language alone.
resp = await client.put("/api/settings", json={"setup_done": False})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", False)
@pytest.mark.asyncio
async def test_setup_done_rejects_non_boolean(client):
assert (await client.put("/api/settings", json={"setup_done": "maybe"})).status_code == 422
@pytest.mark.asyncio
async def test_reset_password_prints_new_password_and_ends_sessions(client, capsys):
async with SessionLocal() as db:
before = (await db.execute(select(Session))).scalars().all()
assert before, "the client fixture logged in, so a session must exist"
assert await reset_password.main(None) == 0
out = capsys.readouterr().out
lines = dict(line.split(": ", 1) for line in out.strip().splitlines())
assert lines["Username"] == settings.admin_username
new_password = lines["Password"]
assert len(new_password) >= 20 and new_password != settings.admin_password
async with SessionLocal() as db:
assert (await db.execute(select(Session))).scalars().all() == []
user = (await db.execute(select(AdminUser).where(AdminUser.username == settings.admin_username))).scalar_one()
assert await auth_service.verify_password_async(user.password_hash, new_password)
assert not await auth_service.verify_password_async(user.password_hash, settings.admin_password)
# Put the shared bootstrap admin back for the tests that follow.
user.password_hash = await auth_service.hash_password_async(settings.admin_password)
await db.commit()
@pytest.mark.asyncio
async def test_reset_password_unknown_user_fails(capsys):
assert await reset_password.main("does-not-exist") == 1
assert "No such user" in capsys.readouterr().err
+176
View File
@@ -0,0 +1,176 @@
import httpx
import pytest
from tests.test_users import _login_as
from app.config import settings
from app.services import update_check_service
from app.version import APP_VERSION
FEED = "https://feed.test/api/v1/repos/o/r/releases/latest"
UPDATER = "http://updater.test:8080"
def _bump(minor=0, patch=0, major=0):
a, b, c = (int(x) for x in APP_VERSION.split("."))
return f"{a + major}.{b + minor}.{c + patch}"
@pytest.fixture(autouse=True)
def _setup(monkeypatch, tmp_path):
token = tmp_path / "token"
token.write_text("t" * 40)
monkeypatch.setattr(settings, "update_check_url", FEED)
monkeypatch.setattr(settings, "updater_url", UPDATER)
monkeypatch.setattr(settings, "updater_token_file", str(token))
update_check_service.reset_cache()
yield
update_check_service.reset_cache()
def _feed(respx_mock, version):
respx_mock.get(FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{version}"}))
def _updater_status(respx_mock, **over):
body = {"mode": "release", "running_version": APP_VERSION, "job": None, "phases": [], "log": [],
"services": {"backend": "healthy"}, "rollback_available": False, "rollback_loses_data": False,
"last_update": None}
body.update(over)
return respx_mock.get(f"{UPDATER}/status").mock(return_value=httpx.Response(200, json=body))
@pytest.mark.asyncio
async def test_status_reports_update_and_can_update(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
_updater_status(respx_mock)
body = (await client.get("/api/system/updater")).json()
assert body["available"] is True and body["can_update"] is True
assert body["latest"] == _bump(minor=1) and body["major_change"] is False
@pytest.mark.asyncio
async def test_status_without_token_is_unavailable_not_an_error(client, respx_mock, monkeypatch, tmp_path):
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
_feed(respx_mock, _bump(minor=1))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "not_enabled" and body["can_update"] is False
assert body["update_available"] is True # der Hinweis bleibt trotzdem
@pytest.mark.asyncio
async def test_status_unreachable_updater(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
respx_mock.get(f"{UPDATER}/status").mock(side_effect=httpx.ConnectError("down"))
body = (await client.get("/api/system/updater")).json()
assert body["available"] is False and body["reason"] == "unreachable"
@pytest.mark.asyncio
async def test_major_change_is_not_offered_for_one_click(client, respx_mock):
_feed(respx_mock, _bump(major=1))
_updater_status(respx_mock)
body = (await client.get("/api/system/updater")).json()
assert body["major_change"] is True and body["can_update"] is False
resp = await client.post("/api/system/updater/update", json={"version": _bump(major=1)})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_MAJOR_CHANGE"
@pytest.mark.asyncio
async def test_local_build_install_cannot_update(client, respx_mock):
_feed(respx_mock, _bump(minor=1))
_updater_status(respx_mock, mode="local")
body = (await client.get("/api/system/updater")).json()
assert body["can_update"] is False and body["mode"] == "local"
@pytest.mark.asyncio
async def test_update_forwards_only_the_feed_version(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.status_code == 202
sent = route.calls.last.request
assert sent.headers["authorization"] == "Bearer " + "t" * 40
assert b'"version": "%s"' % newer.encode() in sent.content or newer.encode() in sent.content
@pytest.mark.asyncio
@pytest.mark.parametrize("version", ["0.0.1", "99.99.99", "1.0.0-rc1", "latest", "../../x"])
async def test_update_rejects_any_other_version(client, respx_mock, version):
_feed(respx_mock, _bump(patch=1))
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
resp = await client.post("/api/system/updater/update", json={"version": version})
assert resp.status_code in (409, 422)
assert route.call_count == 0
@pytest.mark.asyncio
async def test_update_when_no_update_available(client, respx_mock):
_feed(respx_mock, APP_VERSION)
resp = await client.post("/api/system/updater/update", json={"version": APP_VERSION})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_NOT_OFFERED"
@pytest.mark.asyncio
async def test_busy_and_rejected_map_to_error_codes(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
route = respx_mock.post(f"{UPDATER}/update")
route.mock(return_value=httpx.Response(409, json={"error": "busy"}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.json()["detail"]["code"] == "UPDATER_BUSY"
route.mock(return_value=httpx.Response(409, json={"error": "not_release_mode"}))
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.json()["detail"] == {"code": "UPDATER_REJECTED", "params": {"reason": "not_release_mode"}}
@pytest.mark.asyncio
async def test_update_without_updater_is_503(client, respx_mock, monkeypatch, tmp_path):
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
newer = _bump(patch=1)
_feed(respx_mock, newer)
resp = await client.post("/api/system/updater/update", json={"version": newer})
assert resp.status_code == 503 and resp.json()["detail"]["code"] == "UPDATER_UNAVAILABLE"
@pytest.mark.asyncio
async def test_rollback_is_forwarded(client, respx_mock):
route = respx_mock.post(f"{UPDATER}/rollback").mock(return_value=httpx.Response(202, json={"job": {"id": "r"}}))
resp = await client.post("/api/system/updater/rollback")
assert resp.status_code == 202 and route.call_count == 1
@pytest.mark.asyncio
async def test_read_only_user_cannot_read_or_start_updates(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
status = _updater_status(respx_mock)
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
await client.post("/api/users", json={"username": "viewer-upd", "password": "password-123456", "role": "read_only"})
viewer = await _login_as("viewer-upd", "password-123456")
try:
assert (await viewer.get("/api/system/updater")).status_code == 403
assert (await viewer.post("/api/system/updater/update", json={"version": newer})).status_code == 403
assert (await viewer.post("/api/system/updater/rollback")).status_code == 403
finally:
await viewer.aclose()
assert status.call_count == 0 and route.call_count == 0
@pytest.mark.asyncio
async def test_anonymous_is_rejected(anon_client):
assert (await anon_client.get("/api/system/updater")).status_code == 401
assert (await anon_client.post("/api/system/updater/rollback")).status_code == 401
@pytest.mark.asyncio
async def test_update_request_is_audited(client, respx_mock):
newer = _bump(patch=1)
_feed(respx_mock, newer)
respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
assert (await client.post("/api/system/updater/update", json={"version": newer})).status_code == 202
events = (await client.get("/api/audit?kind=system")).json()["items"]
assert {(e["action"], e["status"]) for e in events} >= {("update", "requested"), ("update", "accepted")}
+3
View File
@@ -16,3 +16,6 @@ services:
proxy:
build: ./proxy
updater:
build: ./updater
+38 -6
View File
@@ -28,9 +28,8 @@ services:
logging: *default-logging
# Docker's default 64MB /dev/shm is too small for Postgres's parallel
# vacuum/query workers on anything but a toy dataset — found the hard
# way running VACUUM on a multi-GB table (see docs/HANDBUCH.md's "Backup &
# Restore" section for how that table got that large in the first
# place). Official Postgres image docs recommend raising this.
# way running VACUUM on a multi-GB table (see docs/HANDBUCH.md's "Logs" section for how
# large that table gets). Official Postgres image docs recommend raising this.
shm_size: "256mb"
environment:
# Datenbank- und Benutzername. Wirken nur beim allerersten Start eines
@@ -62,8 +61,8 @@ services:
# path at all. Reuses the postgres image directly (pg_dump is already
# in it) rather than adding a new image to build/maintain.
# Excludes firewall_log_entries/host_resource_samples: high-volume,
# disposable operational history (see docs/HANDBUCH.md's "Backup & Restore"
# section for exactly how large this got in practice) rather than
# disposable operational history (see docs/HANDBUCH.md's "Logs" section for
# how large these get) rather than
# config a disaster recovery actually needs — including them would
# make the backup itself multi-GB and a disk-usage risk in its own
# right, working against the point of this service.
@@ -120,6 +119,9 @@ services:
ALLOW_INSECURE_SMTP_AUTH: ${ALLOW_INSECURE_SMTP_AUTH:-false}
SMTP_CA_FILE: ${SMTP_CA_FILE:-}
UPDATE_CHECK_INTERVAL_HOURS: ${UPDATE_CHECK_INTERVAL_HOURS:-12}
# Optionaler Updater-Dienst (Profil "updater", siehe unten). Ohne ihn
# zeigt das Webinterface nur an, wie man ihn aktiviert.
UPDATER_URL: ${UPDATER_URL:-http://updater:8080}
volumes:
- secrets:/data/opnsense-secrets
- backups:/data/opnsense-backups
@@ -133,10 +135,13 @@ services:
# Gleiches Volume wie der postgres-backup-Service: hier landet das
# automatische Sicherheits-Backup vor einer Schema-Migration.
- postgres_backups:/backups
# Zugriffstoken des Updaters (nur lesen). Leer, solange der Updater
# nicht aktiviert ist.
- updater_state:/updater:ro
# Bound to loopback only by default — reachable from this host, not
# the network. Set BIND_ADDRESS in .env (e.g. to 0.0.0.0, or a
# specific interface IP) for network-wide access instead of editing
# this file — see docs/HANDBUCH.md's "Produktivbetrieb" for the TLS-proxy
# this file — see docs/HANDBUCH.md's "Zugriff aus dem Netz und TLS" for the TLS-proxy
# alternative, which is safer than widening this directly.
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${BACKEND_PORT:-8100}:8000"
@@ -195,7 +200,34 @@ services:
- caddy_data:/data
- caddy_config:/config
# ── Optional: Updates aus dem Webinterface ──────────────────────────
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=updater in
# .env und `docker compose up -d`. Dies ist der einzige Container mit
# Zugriff auf den Docker-Socket (= root-gleiche Rechte auf diesem Host): er
# hat keine veröffentlichten Ports, ist nur mit seinem Token ansprechbar
# und kann nur "aktualisieren"/"zurückrollen" (updater/server.py). Das
# Backend selbst bekommt den Socket nie. Nur für Release-Images
# (OPNEXUS_IMAGE_REPO gesetzt).
updater:
profiles: ["updater"]
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-updater:${OPNEXUS_VERSION:-latest}
restart: unless-stopped
logging: *default-logging
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Das Verzeichnis mit docker-compose.yml und .env (der Updater schreibt
# nur OPNEXUS_VERSION und .opnexus-update-state hinein).
- .:/project
- updater_state:/state
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
volumes:
updater_state:
postgres_data:
postgres_backups:
secrets:
+1094 -3437
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

+1 -1
View File
@@ -5,7 +5,7 @@ import { api, AuditEvent, AuditPage } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Input, Select } from "@/components/ui/Field";
import { useLanguage } from "@/components/LanguageProvider";
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification"];
const kindKeys = ["rule","alias","port_forward","one_to_one","outbound_nat","host","user","auth","backup","ipsec_tunnel","openvpn_instance","wireguard_tunnel","certificate","acme","notification","system"];
const actionKeys = ["deploy","confirm","rollback","remove","retry-rollback","delete","create","edit","reboot","update"];
const statusKeys = ["requested","accepted","rejected","interrupted","success","pending","error","reverted","revert_failed","removed","submitted","acknowledged","starting","unknown","denied"];
export default function AuditHistory() {
+13 -2
View File
@@ -18,6 +18,7 @@ import {
ShieldAlert,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useLanguage } from "@/components/LanguageProvider";
import { labelFor, type Language } from "@/lib/i18n";
@@ -110,6 +111,7 @@ function certProblemLabel(context: Record<string, unknown>, t: (key: string, par
export default function DashboardPage() {
const { language, t, errorMessage } = useLanguage();
const router = useRouter();
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
@@ -133,6 +135,15 @@ export default function DashboardPage() {
.then(async (data) => {
if (!active) return;
setSummary(data);
// Fresh install: nothing to manage yet and the setup assistant was
// never finished or skipped, so open it instead of an empty dashboard.
if (data.hosts.length === 0) {
Promise.all([api.me(), api.settings()])
.then(([me, prefs]) => {
if (active && me.role !== "read_only" && !prefs.setup_done) router.replace("/setup");
})
.catch(() => {});
}
const reachableHosts = data.hosts.filter((h) => h.status !== "offline" && h.status !== "disabled");
const results = await Promise.all(
reachableHosts.map(async (host) => {
@@ -164,7 +175,7 @@ export default function DashboardPage() {
return () => {
active = false;
};
}, [errorMessage]);
}, [errorMessage, router]);
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
const securityWarningsLoading = problems === null || firmwareWarnings === null;
@@ -212,7 +223,7 @@ export default function DashboardPage() {
{summary === null ? (
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
) : summary.hosts.length === 0 ? (
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link>} />
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<span className="flex flex-wrap justify-center gap-x-4 gap-y-1"><Link href="/setup" className="text-sm font-medium text-sky-700 hover:underline">{t("dashboard.startSetup")}</Link><Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link></span>} />
) : (
<ul className="divide-y divide-slate-100">
{summary.hosts.map((host) => (
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { InfoTip } from "@/components/ui/Tooltip";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { api, ComplianceFinding, ComplianceHost, Severity } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { useReadOnly } from "@/lib/useReadOnly";
import { CheckCircle2, CircleHelp, Info, RefreshCw, ShieldAlert, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
const SEVERITY_TONE: Record<Severity, "danger" | "warning" | "info" | "neutral"> = {
critical: "danger", high: "danger", medium: "warning", low: "info", info: "neutral",
};
const SEVERITY_RANK: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
const GRADE_TONE = { good: "success", fair: "warning", poor: "danger" } as const;
function ScoreBadge({ host }: { host: ComplianceHost }) {
const { t } = useLanguage();
if (host.score === null || host.grade === null) return <Badge tone="neutral">{host.error ? t(`security.error.${host.error}`) : t("security.notChecked")}</Badge>;
return <Badge tone={GRADE_TONE[host.grade]}>{host.score} / 100</Badge>;
}
export default function SecurityPage() {
const { t, errorMessage, locale } = useLanguage();
const readOnly = useReadOnly();
const toast = useToast();
const [hosts, setHosts] = useState<ComplianceHost[] | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [detail, setDetail] = useState<ComplianceHost | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
try {
const list = await api.listCompliance();
setHosts(list);
setSelected((current) => current ?? [...list].filter((h) => h.score !== null).sort((a, b) => (a.score ?? 0) - (b.score ?? 0))[0]?.host_id ?? list[0]?.host_id ?? null);
setError(null);
} catch (e) {
setError(errorMessage(e, "security.loadFailed"));
}
}
useEffect(() => {
load();
}, []);
useEffect(() => {
if (!selected) { setDetail(null); return; }
let active = true;
api.getHostCompliance(selected).then((d) => active && setDetail(d)).catch((e) => active && setError(errorMessage(e, "security.loadFailed")));
return () => { active = false; };
}, [selected, hosts]);
async function runCheck(all: boolean) {
setBusy(true);
try {
if (all || !selected) await api.checkFleetCompliance();
else await api.checkHostCompliance(selected);
await load();
toast.show(t("security.checked"), "success");
} catch (e) {
setError(errorMessage(e, "security.checkFailed"));
} finally {
setBusy(false);
}
}
const findings = [...(detail?.findings ?? [])].sort(
(a, b) => Number(a.passed === true) - Number(b.passed === true) || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],
);
return (
<div>
<div className="mb-6 flex items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-semibold text-slate-900">
{t("security.title")}
<InfoTip text={t("security.intro")} size={18} />
</h1>
<fieldset disabled={readOnly} className="contents">
<Button onClick={() => runCheck(true)} disabled={busy} loading={busy}>
<RefreshCw size={16} />
{t("security.checkAll")}
</Button>
</fieldset>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
{hosts === null && <Skeleton className="h-24 w-full" />}
{hosts?.length === 0 && <EmptyState icon={ShieldAlert} title={t("security.noHosts")} description={t("security.noHostsHint")} />}
{hosts && hosts.length > 0 && (
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{hosts.map((host) => (
<button
key={host.host_id}
type="button"
onClick={() => setSelected(host.host_id)}
aria-pressed={selected === host.host_id}
className={`rounded-2xl border bg-white p-4 text-left transition-colors hover:border-slate-400 ${selected === host.host_id ? "border-slate-900 ring-1 ring-slate-900" : "border-slate-200"}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-slate-900">{host.host_name}</span>
<ScoreBadge host={host} />
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-xs">
{host.counts && (["critical", "high", "medium", "low"] as Severity[]).filter((s) => host.counts![s] > 0).map((s) => (
<Badge key={s} tone={SEVERITY_TONE[s]}>{host.counts![s]} {t(`security.severity.${s}`)}</Badge>
))}
{host.counts && Object.entries(host.counts).every(([k, v]) => k === "info" || v === 0) && host.score !== null && (
<span className="text-slate-500">{t("security.allGood")}</span>
)}
</div>
<p className="mt-2 text-xs text-slate-400">
{host.checked_at ? t("security.checkedAt", { when: new Date(host.checked_at).toLocaleString(locale) }) : t("security.neverChecked")}
</p>
</button>
))}
</div>
)}
{detail && (
<Card className="p-0">
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-5 py-3">
<h2 className="font-medium text-slate-900">{detail.host_name}</h2>
<fieldset disabled={readOnly} className="contents">
<Button variant="outline" onClick={() => runCheck(false)} disabled={busy}>
<RefreshCw size={14} />
{t("security.checkHost")}
</Button>
</fieldset>
</div>
{detail.error && <p className="px-5 py-4 text-sm text-red-600">{t(`security.errorHint.${detail.error}`)}</p>}
{!detail.error && findings.length === 0 && <p className="px-5 py-6 text-sm text-slate-500">{t("security.neverChecked")}</p>}
<ul className="divide-y divide-slate-100">
{findings.map((finding) => (
<FindingRow key={finding.id} finding={finding} />
))}
</ul>
</Card>
)}
</div>
);
}
function FindingRow({ finding }: { finding: ComplianceFinding }) {
const { t } = useLanguage();
const failed = finding.passed === false;
// A note (severity "info") is a suggestion, not an error: neutral icon instead of the red cross.
const note = failed && finding.severity === "info";
const Icon = finding.passed === null ? CircleHelp : note ? Info : failed ? XCircle : CheckCircle2;
const color = finding.passed === null || note ? "text-slate-400" : failed ? "text-red-600" : "text-emerald-600";
const { examples = [], count = 0, reason, protocol, age_days } = finding.params;
const vars = { count, reason: reason ? t(`security.reason.${reason}`) : "", protocol: protocol ?? "", days: age_days ?? "" };
return (
<li className="flex gap-3 px-5 py-3">
<Icon size={18} className={`mt-0.5 shrink-0 ${color}`} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-slate-900">{t(`security.checks.${finding.id}.title`)}</span>
{failed && <Badge tone={SEVERITY_TONE[finding.severity]}>{t(`security.severity.${finding.severity}`)}</Badge>}
<span className="sr-only">{finding.passed === null ? t("security.unknown") : failed ? t("security.failed") : t("security.passed")}</span>
</div>
<p className="text-sm text-slate-600">{t(`security.checks.${finding.id}.${failed ? "fail" : finding.passed === null ? "unknown" : "ok"}`, vars)}</p>
{failed && examples.length > 0 && (
<ul className="mt-1 list-disc pl-5 text-sm text-slate-700">
{examples.map((example, i) => <li key={i} className="break-words">{example}</li>)}
{count > examples.length && <li className="list-none text-slate-400">{t("security.andMore", { n: count - examples.length })}</li>}
</ul>
)}
{failed && <p className="mt-1 text-sm text-slate-500">{t(`security.checks.${finding.id}.fix`)}</p>}
</div>
</li>
);
}
+22 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { TwoFactorSection } from "@/components/TwoFactorSection";
import { OpnexusUpdateSection } from "@/components/OpnexusUpdateSection";
import { InfoTip, Tooltip } from "@/components/ui/Tooltip";
import { useEffect, useState } from "react";
import { api, AppSettings, LogSettings, NotificationSettings, SmtpSecurity, Theme } from "@/lib/api";
@@ -11,6 +12,21 @@ import { useReadOnly } from "@/lib/useReadOnly";
import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
import { translate } from "@/lib/i18n";
function SetupAssistantSection() {
const { t } = useLanguage();
const readOnly = useReadOnly();
if (readOnly) return null;
return (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-5">
<div>
<h2 className="text-lg font-medium">{t("settings.setup.title")}</h2>
<p className="mt-1 text-sm text-slate-600">{t("settings.setup.intro")}</p>
</div>
<Button href="/setup" variant="outline">{t("settings.setup.open")}</Button>
</section>
);
}
function NotificationsSection() {
const { t, errorMessage } = useLanguage();
const readOnly = useReadOnly();
@@ -44,6 +60,7 @@ function NotificationsSection() {
const [natDrift, setNatDrift] = useState(true);
const [networkDrift, setNetworkDrift] = useState(true);
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
const [scheduledFailed, setScheduledFailed] = useState(true);
const [pollInterval, setPollInterval] = useState(300);
function applyFromServer(data: NotificationSettings) {
@@ -66,6 +83,7 @@ function NotificationsSection() {
setNatDrift(data.nat_drift_enabled);
setNetworkDrift(data.network_drift_enabled);
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
setScheduledFailed(data.scheduled_maintenance_failed_enabled);
setPollInterval(data.poll_interval_seconds);
setWebhookSecret("");
setClearWebhookSecret(false);
@@ -106,6 +124,7 @@ function NotificationsSection() {
nat_drift_enabled: natDrift,
network_drift_enabled: networkDrift,
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
scheduled_maintenance_failed_enabled: scheduledFailed,
poll_interval_seconds: pollInterval,
});
applyFromServer(data);
@@ -132,7 +151,7 @@ function NotificationsSection() {
}
return (
<section className="space-y-5 rounded-xl border border-slate-200 bg-white p-5">
<section id="notifications" className="scroll-mt-4 space-y-5 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 text-lg font-medium">{t("settings.notify.title")}<InfoTip text={t("settings.notify.intro", { seconds: pollInterval })} /></h2>
<form onSubmit={save} className="space-y-5">
@@ -237,6 +256,7 @@ function NotificationsSection() {
[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.scheduledFailed"), scheduledFailed, setScheduledFailed],
].map(([label, value, setter]) => (
<label key={label as string} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={value as boolean} onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)} />
@@ -376,5 +396,5 @@ export default function SettingsPage(){
async function saveTheme(value:Theme){setSaving(true);setError('');setNotice('');const old=theme;setTheme(value);try{setSettings(await api.saveSettings({theme:value,language}));setNotice(t('settings.appearanceSaved'));}catch(e){setTheme(old);setError(errorMessage(e));}finally{setSaving(false);}}
async function saveLanguage(value:"de"|"en"){setSaving(true);setError('');setNotice('');const old=language;setLanguage(value);try{setSettings(await api.saveSettings({theme,language:value}));setNotice(translate(value,'settings.language.saved'));}catch(e){setLanguage(old);setError(errorMessage(e));}finally{setSaving(false);}}
async function changePassword(e:React.FormEvent){e.preventDefault();setError('');setNotice('');if(password!==repeat){setError(t('settings.passwordMismatch'));return;}setSaving(true);try{await api.changePassword(current,password);setCurrent('');setPassword('');setRepeat('');setNotice(t('settings.passwordChanged'));}catch(e){setError(errorMessage(e,'settings.passwordFailed'));}finally{setSaving(false);}}
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><TwoFactorSection/><NotificationsSection/><LogIngestSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SetupWizard } from "@/components/SetupWizard";
export default function SetupPage() {
return <SetupWizard />;
}
+243
View File
@@ -0,0 +1,243 @@
"use client";
import { InfoTip } from "@/components/ui/Tooltip";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { api, ApiToken, TokenScope } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { Copy, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
const SCOPE_TONE: Record<TokenScope, "neutral" | "info" | "warning"> = { read: "neutral", deploy: "info", admin: "warning" };
export default function TokensPage() {
const { t, errorMessage, locale } = useLanguage();
const toast = useToast();
const [tokens, setTokens] = useState<ApiToken[] | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [created, setCreated] = useState<(ApiToken & { token: string }) | null>(null);
async function load() {
try {
const [list, me] = await Promise.all([api.listApiTokens(), api.me()]);
setTokens(list);
setIsAdmin(me.role === "admin");
setError(null);
} catch (e) {
setError(errorMessage(e, "tokens.loadFailed"));
}
}
useEffect(() => {
load();
}, []);
async function handleRevoke(token: ApiToken) {
if (!confirm(t("tokens.revokeConfirm", { name: token.name }))) return;
try {
await api.revokeApiToken(token.id);
await load();
toast.show(t("tokens.revoked", { name: token.name }), "success");
} catch (e) {
setError(errorMessage(e, "common.deleteFailed"));
}
}
const when = (value: string | null) => (value ? new Date(value).toLocaleString(locale) : null);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="flex items-center gap-2 text-2xl font-semibold text-slate-900">
{t("tokens.title")}
<InfoTip text={t("tokens.intro")} size={18} />
</h1>
<Button onClick={() => setShowCreate(true)}>
<Plus size={16} />
{t("tokens.new")}
</Button>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<Card className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-5 py-3 font-medium">{t("tokens.colName")}</th>
<th className="px-5 py-3 font-medium">{t("tokens.colScope")}</th>
{isAdmin && <th className="px-5 py-3 font-medium">{t("tokens.colOwner")}</th>}
<th className="px-5 py-3 font-medium">{t("tokens.colExpires")}</th>
<th className="px-5 py-3 font-medium">{t("tokens.colLastUsed")}</th>
<th className="px-5 py-3 font-medium"><span className="sr-only">{t("common.actions")}</span></th>
</tr>
</thead>
<tbody>
{tokens === null &&
[0, 1].map((i) => (
<tr key={i} className="border-b border-slate-100 last:border-0">
<td className="px-5 py-3"><Skeleton className="h-4 w-28" /></td>
<td className="px-5 py-3"><Skeleton className="h-4 w-14 rounded-full" /></td>
<td className="px-5 py-3"><Skeleton className="h-4 w-24" /></td>
<td className="px-5 py-3"><Skeleton className="h-4 w-24" /></td>
<td className="px-5 py-3" />
</tr>
))}
{tokens?.length === 0 && (
<tr>
<td colSpan={6} className="px-5 py-8 text-center text-slate-500">{t("tokens.empty")}</td>
</tr>
)}
{tokens?.map((token) => (
<tr key={token.id} className="border-b border-slate-100 last:border-0">
<td className="px-5 py-3">
<div className="font-medium text-slate-900">{token.name}</div>
<div className="font-mono text-xs text-slate-400">{token.prefix}…</div>
</td>
<td className="px-5 py-3"><Badge tone={SCOPE_TONE[token.scope]}>{t(`tokens.scope.${token.scope}`)}</Badge></td>
{isAdmin && <td className="px-5 py-3 text-slate-600">{token.owner}</td>}
<td className="px-5 py-3 text-slate-500">{when(token.expires_at) ?? t("tokens.never")}</td>
<td className="px-5 py-3 text-slate-500">
{when(token.last_used_at) ?? <span className="text-slate-300">{t("tokens.neverUsed")}</span>}
</td>
<td className="px-5 py-3 text-right">
<Button variant="dangerSubtle" onClick={() => handleRevoke(token)}>
<Trash2 size={14} />
{t("tokens.revoke")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
{showCreate && (
<CreateTokenModal
isAdmin={isAdmin}
onClose={() => setShowCreate(false)}
onCreated={async (token) => {
setShowCreate(false);
setCreated(token);
await load();
}}
/>
)}
{created && <TokenCreatedModal token={created} onClose={() => setCreated(null)} />}
</div>
);
}
function CreateTokenModal({
isAdmin,
onClose,
onCreated,
}: {
isAdmin: boolean;
onClose: () => void;
onCreated: (token: ApiToken & { token: string }) => void;
}) {
const { t, errorMessage } = useLanguage();
const [name, setName] = useState("");
const [scope, setScope] = useState<TokenScope>("read");
const [days, setDays] = useState("90");
const [password, setPassword] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const token = await api.createApiToken({
name: name.trim(),
scope,
expires_in_days: days === "never" ? null : Number(days),
current_password: password,
});
onCreated(token);
} catch (err) {
setError(errorMessage(err, "tokens.createFailed"));
} finally {
setSaving(false);
}
}
return (
<Modal title={t("tokens.new")} onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4">
<FieldGroup label={t("tokens.colName")} hint={t("tokens.nameHint")}>
<Input required maxLength={80} value={name} onChange={(e) => setName(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("tokens.colScope")} hint={t(`tokens.scopeHint.${scope}`)}>
<Select value={scope} onChange={(e) => setScope(e.target.value as TokenScope)}>
<option value="read">{t("tokens.scope.read")}</option>
{isAdmin && <option value="deploy">{t("tokens.scope.deploy")}</option>}
{isAdmin && <option value="admin">{t("tokens.scope.admin")}</option>}
</Select>
</FieldGroup>
<FieldGroup label={t("tokens.colExpires")}>
<Select value={days} onChange={(e) => setDays(e.target.value)}>
<option value="30">{t("tokens.days", { n: 30 })}</option>
<option value="90">{t("tokens.days", { n: 90 })}</option>
<option value="365">{t("tokens.days", { n: 365 })}</option>
<option value="never">{t("tokens.never")}</option>
</Select>
</FieldGroup>
<FieldGroup label={t("tokens.password")} hint={t("tokens.passwordHint")}>
<Input required type="password" autoComplete="current-password" maxLength={1024} value={password} onChange={(e) => setPassword(e.target.value)} />
</FieldGroup>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={saving} loading={saving}>
{saving ? t("common.saving") : t("tokens.create")}
</Button>
</div>
</form>
</Modal>
);
}
function TokenCreatedModal({ token, onClose }: { token: ApiToken & { token: string }; onClose: () => void }) {
const { t } = useLanguage();
const toast = useToast();
const example = `curl -H "Authorization: Bearer ${token.token}" ${typeof window === "undefined" ? "" : window.location.origin}/api/hosts`;
function copy(text: string) {
void navigator.clipboard?.writeText(text);
toast.show(t("tokens.copied"), "success");
}
return (
<Modal title={t("tokens.createdTitle", { name: token.name })} onClose={onClose}>
<div className="space-y-4">
<p className="text-sm text-amber-700">{t("tokens.shownOnce")}</p>
<div className="flex items-start gap-2">
<code className="flex-1 break-all rounded-md border border-slate-200 bg-slate-50 p-2 font-mono text-xs text-slate-900">{token.token}</code>
<Button type="button" variant="outline" onClick={() => copy(token.token)}>
<Copy size={14} />
{t("tokens.copy")}
</Button>
</div>
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-slate-500">{t("tokens.example")}</p>
<code className="block break-all rounded-md border border-slate-200 bg-slate-50 p-2 font-mono text-xs text-slate-700">{example}</code>
</div>
<div className="flex justify-end pt-2">
<Button onClick={onClose}>{t("tokens.done")}</Button>
</div>
</div>
</Modal>
);
}
+6 -5
View File
@@ -21,22 +21,23 @@ export function HostMaintenance({host}: {host:Host}) {
const [name,setName]=useState('');const [ack,setAck]=useState(false);const [log,setLog]=useState('');
const [cancelOp,setCancelOp]=useState<HostOperation|null>(null);const [reason,setReason]=useState('');
const [scheduled,setScheduled]=useState<ScheduledMaintenanceItem[]>([]);
const [schedule,setSchedule]=useState<{action:'reboot'|'update'|'major_update';runAt:string;ack:boolean}|null>(null);
const [schedule,setSchedule]=useState<{action:'reboot'|'update'|'major_update';runAt:string;ack:boolean;handover:boolean}|null>(null);
async function load(){setError('');try{setData(await api.hostMaintenance(host.id));}catch(e){setError(errorMessage(e,'maint.loadFailed'));}}
async function loadScheduled(){try{setScheduled(await api.listScheduledMaintenance(host.id));}catch{/* non-fatal, section just stays empty */}}
useEffect(()=>{if(open){load();loadScheduled();}},[open,host.id]);
async function createSchedule(){if(!schedule)return;setBusy(true);setError('');try{await api.createScheduledMaintenance(host.id,schedule.action,new Date(schedule.runAt).toISOString());setSchedule(null);await loadScheduled();}catch(e){setError(errorMessage(e,'maint.scheduleFailed'));}finally{setBusy(false);}}
async function createSchedule(){if(!schedule)return;setBusy(true);setError('');try{await api.createScheduledMaintenance(host.id,schedule.action,new Date(schedule.runAt).toISOString(),schedule.handover);setSchedule(null);await loadScheduled();}catch(e){setError(errorMessage(e,'maint.scheduleFailed'));}finally{setBusy(false);}}
async function cancelSchedule(id:string){setBusy(true);setError('');try{await api.cancelScheduledMaintenance(host.id,id);await loadScheduled();}catch(e){setError(errorMessage(e,'maint.cancelFailed'));}finally{setBusy(false);}}
useEffect(()=>{if(!open || !data?.operations.some(o=>['starting','submitted','unknown'].includes(o.status)))return;const timer=setInterval(load,15000);return()=>clearInterval(timer);},[open,host.id,data?.operations.map(o=>o.status).join(',')]);
useEffect(()=>{if(!open || !data?.operations.some(o=>['starting','submitted','unknown'].includes(o.status)))return;const timer=setInterval(()=>{load();loadScheduled();},15000);return()=>clearInterval(timer);},[open,host.id,data?.operations.map(o=>o.status).join(',')]);
async function preview(action:'reboot'|'update'|'major_update'){setBusy(true);setError('');try{setPlan(await api.previewMaintenance(host.id,action));setName('');setAck(false);}catch(e){setError(errorMessage(e,'maint.previewFailed'));}finally{setBusy(false);}}
async function execute(){if(!plan)return;setBusy(true);setError('');try{const op=await api.executeMaintenance(host.id,plan.preview_id,name,ack);setNotice(op.message ? ts(op.message) : t('maint.jobSubmitted'));setPlan(null);await load();}catch(e){setError(errorMessage(e,'maint.outcomeUnclear'));setPlan(null);await load();}finally{setBusy(false);}}
async function closeOperation(){if(!finish)return;setBusy(true);setError('');try{await api.acknowledgeMaintenance(host.id,finish.id,name);setFinish(null);await load();}catch(e){setError(errorMessage(e,'maint.finishFailed'));}finally{setBusy(false);}}
async function cancelOperation(){if(!cancelOp)return;setBusy(true);setError('');try{await api.cancelMaintenance(host.id,cancelOp.id,name,reason);setCancelOp(null);await load();}catch(e){setError(errorMessage(e,'maint.cancelFailed'));}finally{setBusy(false);}}
async function check(){setBusy(true);setError('');try{await api.checkHostUpdates(host.id);setNotice(t('maint.checkRequested'));await load();}catch(e){setError(errorMessage(e,'maint.checkFailed'));}finally{setBusy(false);}}
return <section className="my-6 rounded-xl border border-slate-200 bg-white p-5"><div className="flex flex-wrap items-center justify-between gap-2"><h2 className="flex items-center gap-2 font-semibold">{t('maint.title')}<InfoTip text={t('maint.intro')} /></h2><Button variant="ghost" onClick={()=>setOpen(v=>!v)}>{open?t('maint.close'):t('maint.open')}</Button></div>{open && <div className="mt-4 space-y-4">{error && <p role="alert" className="text-sm text-red-600">{error}</p>}{notice && <p role="status" className="text-sm text-sky-700">{notice}</p>}{!data && !error && <p>{t('common.loading')}</p>}{data?.error && <p role="alert" className="text-sm text-red-600">{ts(data.error)}</p>}{data?.state && <><div className="grid gap-2 text-sm sm:grid-cols-2"><p>{t('maint.installed',{version:''})}<strong>{data.state.version}</strong></p><p>{t('maint.firmwareJob',{state:data.state.running})}</p><p className="sm:col-span-2">{ts(data.state.firmware_message)}</p><p>{t('maint.lastCheck',{value:data.state.last_check || t('maint.unknown')})}</p><p>{t('maint.backup',{value:data.state.backup?new Date(data.state.backup.created_at).toLocaleString(locale):t('maint.noBackup')})}</p></div><div className="flex flex-wrap items-center gap-2"><Tooltip content={t('updates.tipCheck')}><Button variant="secondary" disabled={busy || data.state.running!=='ready' || !host.enabled || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onClick={check}>{t('maint.checkUpdates')}</Button></Tooltip><CarpSwitchControl host={host} state={data.state} disabled={busy || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onChanged={load}/><Button variant="danger" disabled={busy || data.state.actions.reboot.length>0} onClick={()=>preview('reboot')}>{t('maint.previewReboot')}</Button><Tooltip content={t('updates.tipPrepare')}><Button disabled={busy || data.state.actions.update.length>0} onClick={()=>preview('update')}>{t('maint.previewUpdate')}</Button></Tooltip><Tooltip content={t('updates.tipPrepareMajor')}><Button variant="danger" disabled={busy || data.state.actions.major_update.length>0} onClick={()=>preview('major_update')}>{t('maint.previewMajor')}</Button></Tooltip><Button variant="ghost" disabled={busy || !host.enabled} onClick={()=>setSchedule({action:'reboot',runAt:'',ack:false})}>{t('maint.scheduleLater')}</Button><Button variant="ghost" className="ml-auto" disabled={busy} onClick={load}>{t('maint.refreshStatus')}</Button></div>{readableReasons(data.state,host,t,ts).length>0 && <Tooltip focusable placement="bottom" content={<span className="block space-y-1">{readableReasons(data.state,host,t,ts).map(reason=><span key={reason} className="block">{reason}</span>)}</span>}><Badge tone="warning">{t('maint.hints')} ({readableReasons(data.state,host,t,ts).length})</Badge></Tooltip>}{scheduled.length>0 && <div className="space-y-2"><h3 className="text-sm font-semibold text-slate-700">{t('maint.scheduled')}</h3>{scheduled.map(s=><div key={s.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 p-3 text-sm"><div><strong>{labels[s.action]}</strong> · {new Date(s.run_at).toLocaleString(locale)} · {t(`maint.scheduleStatus.${s.status}`)}{s.result?.error && <span className="text-red-600"> — {ts(s.result.error)}</span>}</div>{s.status==='pending' && <Button variant="ghost" disabled={busy} onClick={()=>cancelSchedule(s.id)}>{t('common.cancel')}</Button>}</div>)}</div>}</>}{data?.operations && data.operations.length>0 && <h3 className="text-sm font-semibold text-slate-700">{t('maint.history')}</h3>}{data?.operations.map(op=><div key={op.id} className="rounded-lg border border-slate-200 p-3 text-sm"><strong>{labels[op.action]} · {['acknowledged','cancelled','unknown','starting'].includes(op.status)?t(`maint.opStatus.${op.status}`):t('maint.opStatus.submitted')}</strong><p className="text-slate-500">{new Date(op.created_at).toLocaleString(locale)} · {op.actor}</p><p className="mt-1">{ts(op.message ?? "")}</p>{['starting','submitted','unknown'].includes(op.status) && <div className="mt-2 flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={()=>{setFinish(op);setName('');setAck(false);}}>{t('maint.confirmFinish')}</Button><Button variant="danger" disabled={busy} onClick={()=>{setCancelOp(op);setName('');setReason('');}}>{t('maint.cancelOperation')}</Button></div>}</div>)}<Button variant="ghost" disabled={busy} onClick={async()=>{try{const p=await api.hostUpdateProgress(host.id);setLog(p.log || p.status);}catch(e){setError(errorMessage(e,'maint.logFailed'));}}}>{t('maint.readLog')}</Button>{log && <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-slate-50 p-3 text-xs">{log}</pre>}</div>}
const masterBlocked=!!data?.state?.cluster_name && data.state.carp_status==='MASTER' && !schedule?.handover;
return <section className="my-6 rounded-xl border border-slate-200 bg-white p-5"><div className="flex flex-wrap items-center justify-between gap-2"><h2 className="flex items-center gap-2 font-semibold">{t('maint.title')}<InfoTip text={t('maint.intro')} /></h2><Button variant="ghost" onClick={()=>setOpen(v=>!v)}>{open?t('maint.close'):t('maint.open')}</Button></div>{open && <div className="mt-4 space-y-4">{error && <p role="alert" className="text-sm text-red-600">{error}</p>}{notice && <p role="status" className="text-sm text-sky-700">{notice}</p>}{!data && !error && <p>{t('common.loading')}</p>}{data?.error && <p role="alert" className="text-sm text-red-600">{ts(data.error)}</p>}{data?.state && <><div className="grid gap-2 text-sm sm:grid-cols-2"><p>{t('maint.installed',{version:''})}<strong>{data.state.version}</strong></p><p>{t('maint.firmwareJob',{state:data.state.running})}</p><p className="sm:col-span-2">{ts(data.state.firmware_message)}</p><p>{t('maint.lastCheck',{value:data.state.last_check || t('maint.unknown')})}</p><p>{t('maint.backup',{value:data.state.backup?new Date(data.state.backup.created_at).toLocaleString(locale):t('maint.noBackup')})}</p></div><div className="flex flex-wrap items-center gap-2"><Tooltip content={t('updates.tipCheck')}><Button variant="secondary" disabled={busy || data.state.running!=='ready' || !host.enabled || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onClick={check}>{t('maint.checkUpdates')}</Button></Tooltip><CarpSwitchControl host={host} state={data.state} disabled={busy || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onChanged={load}/><Button variant="danger" disabled={busy || data.state.actions.reboot.length>0} onClick={()=>preview('reboot')}>{t('maint.previewReboot')}</Button><Tooltip content={t('updates.tipPrepare')}><Button disabled={busy || data.state.actions.update.length>0} onClick={()=>preview('update')}>{t('maint.previewUpdate')}</Button></Tooltip><Tooltip content={t('updates.tipPrepareMajor')}><Button variant="danger" disabled={busy || data.state.actions.major_update.length>0} onClick={()=>preview('major_update')}>{t('maint.previewMajor')}</Button></Tooltip><Button variant="ghost" disabled={busy || !host.enabled} onClick={()=>setSchedule({action:'reboot',runAt:'',ack:false,handover:false})}>{t('maint.scheduleLater')}</Button><Button variant="ghost" className="ml-auto" disabled={busy} onClick={load}>{t('maint.refreshStatus')}</Button></div>{readableReasons(data.state,host,t,ts).length>0 && <Tooltip focusable placement="bottom" content={<span className="block space-y-1">{readableReasons(data.state,host,t,ts).map(reason=><span key={reason} className="block">{reason}</span>)}</span>}><Badge tone="warning">{t('maint.hints')} ({readableReasons(data.state,host,t,ts).length})</Badge></Tooltip>}{scheduled.length>0 && <div className="space-y-2"><h3 className="text-sm font-semibold text-slate-700">{t('maint.scheduled')}</h3>{scheduled.map(s=><div key={s.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 p-3 text-sm"><div><strong>{labels[s.action]}</strong> · {new Date(s.run_at).toLocaleString(locale)} · {t(`maint.scheduleStatus.${s.status==='succeeded' && s.result?.handover ? 'completed' : s.status}`)}{s.status==='running' && s.phase && <span> ({t(`maint.phase.${s.phase}`)})</span>}{s.carp_handover && <Badge tone="info" className="ml-2">{t('maint.handoverBadge')}</Badge>}{s.result?.error && <span className="text-red-600"> — {ts(s.result.error)}</span>}</div>{s.status==='pending' && <Button variant="ghost" disabled={busy} onClick={()=>cancelSchedule(s.id)}>{t('common.cancel')}</Button>}</div>)}</div>}</>}{data?.operations && data.operations.length>0 && <h3 className="text-sm font-semibold text-slate-700">{t('maint.history')}</h3>}{data?.operations.map(op=><div key={op.id} className="rounded-lg border border-slate-200 p-3 text-sm"><strong>{labels[op.action]} · {['acknowledged','cancelled','unknown','starting'].includes(op.status)?t(`maint.opStatus.${op.status}`):t('maint.opStatus.submitted')}</strong><p className="text-slate-500">{new Date(op.created_at).toLocaleString(locale)} · {op.actor}</p><p className="mt-1">{ts(op.message ?? "")}</p>{['starting','submitted','unknown'].includes(op.status) && <div className="mt-2 flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={()=>{setFinish(op);setName('');setAck(false);}}>{t('maint.confirmFinish')}</Button><Button variant="danger" disabled={busy} onClick={()=>{setCancelOp(op);setName('');setReason('');}}>{t('maint.cancelOperation')}</Button></div>}</div>)}<Button variant="ghost" disabled={busy} onClick={async()=>{try{const p=await api.hostUpdateProgress(host.id);setLog(p.log || p.status);}catch(e){setError(errorMessage(e,'maint.logFailed'));}}}>{t('maint.readLog')}</Button>{log && <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-slate-50 p-3 tLine truncated
{plan && <Modal title={t('maint.confirmTitle',{action:labels[plan.action]})} onClose={()=>{if(!busy)setPlan(null);}}><div className="space-y-4 text-sm"><p><strong>{plan.state.host_name}</strong> · {plan.state.base_url} · {plan.state.version}</p>{plan.action==='major_update' && <p className="flex items-center gap-2 font-medium text-red-700"><AlertTriangle size={16}/>{t('updates.majorWarnShort')}<InfoTip text={t('updates.majorWarnTip')}/></p>}<p className="flex items-center gap-2 font-medium text-flame-800"><Clock3 size={16}/>{t('updates.downtimeShort')}<InfoTip text={t('maint.downtimeNote',{reboot:plan.state.reboot_required?t('maint.andReboot'):''})}/></p><p className="text-xs text-slate-500">{t('updates.validUntilShort',{time:new Date(plan.expires_at).toLocaleTimeString(locale)})}</p><label className="block"><span className="flex items-center gap-1">{t('maint.confirmHostname')}<InfoTip text={t('updates.tipConfirmName')} size={13}/></span><Input className="mt-1" autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.confirmHostnameAria')} value={name} placeholder={host.name} onChange={e=>setName(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!busy&&name===host.name&&ack)void execute();}}/></label><label className="flex items-start gap-2"><input type="checkbox" checked={ack} onChange={e=>setAck(e.target.checked)}/>{t('maint.windowReady')}</label><Tooltip focusable={busy||name!==host.name||!ack} content={name!==host.name?t('updates.confirmRunDisabledName'):!ack?t('updates.confirmRunDisabledAck'):null}><Button variant="danger" disabled={busy || name!==host.name || !ack} onClick={execute}>{busy?t('maint.submitting'):t('maint.executeNow',{action:labels[plan.action]})}</Button></Tooltip></div></Modal>}
{finish && <Modal title={finish.action.startsWith('carp_')?t('maint.finishCarpTitle'):t('maint.finishTitle')} onClose={()=>{if(!busy)setFinish(null);}}><div className="space-y-4 text-sm"><p className="flex items-start gap-1">{finish.action.startsWith('carp_')?t('maint.finishCarpCheck'):<>{t('maint.finishCheckPre')}<strong>{host.name}</strong>. </>}<InfoTip text={t('updates.finishNoteTip')} size={13} className="mt-0.5"/></p><Input autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.finishHostnameAria')} value={name} onChange={e=>setName(e.target.value)} placeholder={host.name}/><label className="flex gap-2"><input type="checkbox" checked={ack} onChange={e=>setAck(e.target.checked)}/>{t('maint.finishChecked')}</label>{error && <p className="text-red-600">{error}</p>}<Tooltip focusable={busy||name!==host.name||!ack} content={name!==host.name?t('updates.confirmRunDisabledName'):!ack?t('updates.confirmRunDisabledAck'):null}><Button disabled={busy || name!==host.name || !ack} onClick={closeOperation}>{t('maint.saveFinish')}</Button></Tooltip></div></Modal>}
{cancelOp && <Modal title={t('maint.cancelOperation')} onClose={()=>{if(!busy)setCancelOp(null);}}><div className="space-y-4 text-sm"><p className="flex items-center gap-2 font-medium text-flame-800"><AlertTriangle size={16}/>{t('updates.cancelWarnShort')}<InfoTip text={t('updates.cancelWarnTip')}/></p><Input autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.cancelHostnameAria')} value={name} onChange={e=>setName(e.target.value)} placeholder={host.name}/><label className="block"><span className="flex items-center gap-1">{t('maint.reasonLabel')}<InfoTip text={t('updates.reasonMinHint')} size={13}/></span><textarea className="mt-1 w-full rounded-lg border border-slate-300 p-2" rows={2} maxLength={500} value={reason} onChange={e=>setReason(e.target.value)} placeholder={t('maint.reasonPlaceholder')}/></label>{error && <p className="text-red-600">{error}</p>}<Button variant="danger" disabled={busy || name!==host.name || reason.trim().length<3} onClick={cancelOperation}>{t('maint.closeWithoutConfirm')}</Button></div></Modal>}
{schedule && <Modal title={t('maint.scheduleTitle')} onClose={()=>{if(!busy)setSchedule(null);}}><div className="space-y-4 text-sm"><label className="block"><span className="block">{t('maint.actionLabel')}</span><select className="mt-1 w-full rounded-lg border border-slate-300 p-2" value={schedule.action} onChange={e=>setSchedule({...schedule,action:e.target.value as typeof schedule.action})}><option value="reboot">{t('maint.action.reboot')}</option><option value="update">{t('maint.action.update')}</option><option value="major_update">{t('maint.action.major_update')}</option></select></label><label className="block"><span className="block">{t('maint.timeLabel')}</span><Input className="mt-1" aria-label={t('maint.timeLabel')} type="datetime-local" value={schedule.runAt} onChange={e=>setSchedule({...schedule,runAt:e.target.value})}/></label><p className="flex items-center gap-2 font-medium text-flame-800"><AlertTriangle size={16}/>{t('maint.unattendedShort')}<InfoTip text={t('maint.unattendedWarning')}/></p><label className="flex items-start gap-2"><input type="checkbox" checked={schedule.ack} onChange={e=>setSchedule({...schedule,ack:e.target.checked})}/>{t('maint.unattendedAck')}</label>{error && <p className="text-red-600">{error}</p>}<Button variant="danger" disabled={busy || !schedule.runAt || !schedule.ack} onClick={createSchedule}>{busy?t('maint.scheduling'):t('maint.scheduleIt')}</Button></div></Modal>}
{schedule && <Modal title={t('maint.scheduleTitle')} onClose={()=>{if(!busy)setSchedule(null);}}><div className="space-y-4 text-sm"><label className="block"><span className="block">{t('maint.actionLabel')}</span><select className="mt-1 w-full rounded-lg border border-slate-300 p-2" value={schedule.action} onChange={e=>setSchedule({...schedule,action:e.target.value as typeof schedule.action})}><option value="reboot">{t('maint.action.reboot')}</option><option value="update">{t('maint.action.update')}</option><option value="major_update">{t('maint.action.major_update')}</option></select></label><label className="block"><span className="block">{t('maint.timeLabel')}</span><Input className="mt-1" aria-label={t('maint.timeLabel')} type="datetime-local" value={schedule.runAt} onChange={e=>setSchedule({...schedule,runAt:e.target.value})}/></label>{host.cluster_name && <label className="flex items-start gap-2"><input type="checkbox" className="mt-1" checked={schedule.handover} onChange={e=>setSchedule({...schedule,handover:e.target.checked})}/><span>{t('maint.handoverOption')}<InfoTip text={t('maint.handoverHint')}/></span></label>}<p className="flex items-center gap-2 font-medium text-flame-800"><AlertTriangle size={16}/>{t('maint.unattendedShort')}<InfoTip text={t('maint.unattendedWarning')}/></p><label className="flex items-start gap-2"><input type="checkbox" checked={schedule.ack} onChange={e=>setSchedule({...schedule,ack:e.target.checked})}/>{t('maint.unattendedAck')}</label>{masterBlocked && <p role="alert" className="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-red-700"><AlertTriangle size={16} className="mt-0.5 shrink-0"/>{t('maint.scheduleMasterBlocked')}</p>}{error && <p className="text-red-600">{error}</p>}<Button variant="danger" disabled={busy || masterBlocked || !schedule.runAt || !schedule.ack} onClick={createSchedule}>{busy?t('maint.scheduling'):t('maint.scheduleIt')}</Button></div></Modal>}
</section>;
}
+15
View File
@@ -18,6 +18,7 @@ import {
KeyRound,
LayoutDashboard,
Layers,
ListChecks,
LogOut,
Network,
RefreshCw,
@@ -28,6 +29,7 @@ import {
Server,
Settings,
Shield,
ShieldAlert,
ShieldCheck,
Tags,
Users,
@@ -120,6 +122,7 @@ export const navGroups: NavGroup[] = [
icon: ScrollText,
items: [
{ href: "/logs", label: "Logs", icon: ScrollText, keywords: ["Firewall-Logs", "Geblockt", "Blocked", "Top Ports"] },
{ href: "/security", label: "Sicherheitslage", icon: ShieldAlert, keywords: ["Compliance", "Härtung", "Score", "Regel-Hygiene", "Any-Any", "SSH", "Sicherheit", "Prüfung"] },
{ href: "/audit", label: "Audit", icon: ScrollText, keywords: ["Deploy-Historie", "Protokoll", "Verlauf"] },
],
},
@@ -162,6 +165,18 @@ export const navGroups: NavGroup[] = [
icon: Users,
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
},
{
href: "/tokens",
label: "API-Tokens",
icon: KeyRound,
keywords: ["API", "Token", "Bearer", "Prometheus", "Metriken", "Automatisierung", "CI", "Skript", "MCP", "Schlüssel"],
},
{
href: "/setup",
label: "Einrichtung",
icon: ListChecks,
keywords: ["Assistent", "Wizard", "Setup", "Erste Schritte", "Onboarding", "Firewall verbinden", "API-Key", "Einführung"],
},
],
},
];
@@ -0,0 +1,258 @@
"use client";
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, UpdaterStatus } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { useReadOnly } from "@/lib/useReadOnly";
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
const POLL_MS = 2000;
const RELOAD_DELAY_MS = 3500;
/**
* Settings card "OPNexus aktualisieren": zeigt die verfügbare Version und löst
* das Update über den optionalen Updater-Container aus (siehe updater/server.py).
* Das Backend selbst fasst den Host nie an. Während des Updates starten Backend
* und Proxy neu — die Karte fragt weiter nach und übersteht die Lücke.
*/
export function OpnexusUpdateSection() {
const { t, errorMessage, locale } = useLanguage();
const readOnly = useReadOnly();
const [status, setStatus] = useState<UpdaterStatus | null>(null);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState<"update" | "rollback" | null>(null);
const [busy, setBusy] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
// Wurde in dieser Sitzung ein laufender Auftrag gesehen? Nur dann ist ein
// "succeeded" Anlass, die Seite neu zu laden (neue Frontend-Version).
const sawRunning = useRef(false);
const reloadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(async () => {
try {
const next = await api.updaterStatus();
setStatus(next);
setReconnecting(false);
setError("");
if (next.job?.state === "running") sawRunning.current = true;
return next;
} catch (e) {
if (sawRunning.current) {
setReconnecting(true); // Dienste starten gerade neu — weiter versuchen
} else {
setError(errorMessage(e, "common.requestFailed"));
}
return null;
}
}, [errorMessage]);
useEffect(() => {
if (readOnly) return;
void load();
}, [load, readOnly]);
const running = status?.job?.state === "running" || reconnecting;
useEffect(() => {
if (!running) return;
const timer = setInterval(() => void load(), POLL_MS);
return () => clearInterval(timer);
}, [running, load]);
const job = status?.job ?? null;
const finishedNow = !!job && job.state === "succeeded" && sawRunning.current;
useEffect(() => {
if (!finishedNow) return;
reloadTimer.current = setTimeout(() => window.location.reload(), RELOAD_DELAY_MS);
return () => {
if (reloadTimer.current) clearTimeout(reloadTimer.current);
};
}, [finishedNow]);
if (readOnly) return null;
async function start(kind: "update" | "rollback") {
setBusy(true);
setError("");
try {
if (kind === "update" && status?.latest) await api.updaterUpdate(status.latest);
else if (kind === "rollback") await api.updaterRollback();
sawRunning.current = true;
setConfirm(null);
await load();
} catch (e) {
setConfirm(null);
setError(errorMessage(e, "settings.update.startFailed"));
} finally {
setBusy(false);
}
}
const phases = status?.phases ?? [];
const steps = phases.filter((p, i) => p !== phases[i - 1]);
const isRollbackJob = job?.action === "rollback";
const finishedAt = job?.finished_at ? new Date(job.finished_at).toLocaleString(locale) : "";
const releaseUrl = status?.release_url && /^https?:\/\//.test(status.release_url) ? status.release_url : null;
return (
<section id="opnexus-update" className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 text-lg font-medium">
{t("settings.update.title")}
<InfoTip text={t("settings.update.hint")} />
</h2>
{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
{!status && !error && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{status && (
<>
<dl className="grid grid-cols-2 gap-3 text-sm">
<dt>{t("settings.update.installed")}</dt>
<dd>{status.current}</dd>
<dt>{t("settings.update.latest")}</dt>
<dd className="flex flex-wrap items-center gap-2">
{status.latest ?? "—"}
{status.update_available && <Badge tone="info">{t("settings.update.badge")}</Badge>}
{releaseUrl && (
<a href={releaseUrl} target="_blank" rel="noopener noreferrer" className="font-medium text-brand-600 underline">
{t("settings.update.notes")}
</a>
)}
</dd>
</dl>
{!status.feed_enabled && <p className="text-sm text-slate-600">{t("settings.update.feedOff")}</p>}
{status.feed_enabled && status.feed_error && <p className="text-sm text-amber-700">{t("settings.update.feedError")}</p>}
{status.feed_enabled && !status.feed_error && !status.update_available && (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 size={16} aria-hidden="true" /> {t("settings.update.upToDate")}
{status.checked_at && <span className="text-slate-500">· {t("settings.update.checked", { time: new Date(status.checked_at).toLocaleString(locale) })}</span>}
</p>
)}
{!status.available && (
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
{status.reason === "not_enabled" || status.reason === "unauthorized" ? (
<>
<p className="font-medium">{t("settings.update.notEnabledTitle")}</p>
<p>{t("settings.update.notEnabledBody")}</p>
<p>{t("settings.update.notEnabledStep1")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">COMPOSE_PROFILES=updater</pre>
<p>{t("settings.update.notEnabledStep2")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">docker compose up -d</pre>
<p className="text-slate-500">{t("settings.update.notEnabledCli")}</p>
</>
) : (
<p>{t("settings.update.unreachable")}</p>
)}
</div>
)}
{status.available && status.mode === "local" && (
<p className="text-sm text-slate-600">{t("settings.update.localMode")}</p>
)}
{status.available && status.major_change && status.latest && (
<p className={clsx("flex items-start gap-2 rounded-lg border p-3 text-sm", badgeToneClasses.warning)}>
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
{t("settings.update.majorChange", { latest: status.latest })}
</p>
)}
{running && (
<div role="status" className={clsx("space-y-2 rounded-lg border p-3 text-sm", badgeToneClasses.info)}>
<p className="flex items-center gap-2 font-medium">
<Loader2 size={16} className="animate-spin motion-reduce:animate-none" aria-hidden="true" />
{isRollbackJob ? t("settings.update.rollingBack") : t("settings.update.running", { target: job?.target ?? status.latest ?? "" })}
</p>
{steps.length > 0 && (
<ol className="list-inside list-decimal text-xs">
{steps.map((step) => (
<li key={step}>{t(`settings.update.phase.${step}`)}</li>
))}
</ol>
)}
{reconnecting && <p className="text-xs">{t("settings.update.reconnecting")}</p>}
</div>
)}
{!running && job && job.state === "succeeded" && sawRunning.current && (
<p role="status" className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 size={16} aria-hidden="true" />
{isRollbackJob
? t("settings.update.result.succeededRollback", { version: status.current })
: t("settings.update.result.succeededUpdate", { version: status.current })}
</p>
)}
{!running && job?.state === "rolled_back" && (
<p role="alert" className={clsx("rounded-lg border p-3 text-sm", badgeToneClasses.warning)}>
{t("settings.update.result.rolledBack", { target: job.target ?? "" })}
{finishedAt && <span className="mt-1 block text-xs opacity-80">{t("settings.update.resultTime", { time: finishedAt })}</span>}
</p>
)}
{!running && job?.state === "failed" && (
<p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
{t("settings.update.result.failed")}
{finishedAt && <span className="mt-1 block text-xs opacity-80">{t("settings.update.resultTime", { time: finishedAt })}</span>}
</p>
)}
{status.log.length > 0 && (job?.state !== "succeeded" || running) && (
<details className="text-xs" open={!running && job?.state !== "succeeded"}>
<summary className="cursor-pointer text-slate-600">{t("settings.update.showLog")}</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-slate-50 p-3">{status.log.join("\n")}</pre>
</details>
)}
{status.can_update && status.latest && !running && (
<Button onClick={() => setConfirm("update")} disabled={busy}>
{t("settings.update.button", { latest: status.latest })}
</Button>
)}
{status.available && status.mode === "release" && status.rollback_available && !running && (
<div className="flex flex-wrap items-center gap-3 border-t border-slate-100 pt-3 text-sm">
<Button variant="outline" onClick={() => setConfirm("rollback")} disabled={busy}>
{t("settings.update.rollback.button")}
</Button>
<span className="text-slate-500">{t("settings.update.rollback.hint", { version: status.rollback_version ?? "—" })}</span>
</div>
)}
</>
)}
{confirm === "update" && status?.latest && (
<Modal title={t("settings.update.confirmTitle", { latest: status.latest })} onClose={() => setConfirm(null)}>
<div className="space-y-3 text-sm text-slate-700">
<p>{t("settings.update.confirmBody")}</p>
<p>{t("settings.update.confirmSafety")}</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void start("update")} loading={busy}>{t("settings.update.confirm")}</Button>
</div>
</div>
</Modal>
)}
{confirm === "rollback" && status && (
<Modal title={t("settings.update.rollback.confirmTitle", { version: status.rollback_version ?? "—" })} onClose={() => setConfirm(null)}>
<div className="space-y-3 text-sm text-slate-700">
<p className={status.rollback_loses_data ? clsx("rounded-lg border p-3", badgeToneClasses.warning) : ""}>
{status.rollback_loses_data ? t("settings.update.rollback.dataLoss") : t("settings.update.rollback.noDataLoss")}
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>{t("common.cancel")}</Button>
<Button variant={status.rollback_loses_data ? "danger" : "primary"} onClick={() => void start("rollback")} loading={busy}>
{t("settings.update.rollback.confirm")}
</Button>
</div>
</div>
</Modal>
)}
</section>
);
}
+462
View File
@@ -0,0 +1,462 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { TlsPinControl, TlsPinValue } from "@/components/TlsPinControl";
import { TwoFactorSection } from "@/components/TwoFactorSection";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { FieldGroup, Input } from "@/components/ui/Field";
import { StatusDot } from "@/components/ui/StatusDot";
import { useToast } from "@/components/ui/Toast";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, AppSettings, Host } from "@/lib/api";
import clsx from "clsx";
import { ArrowLeft, ArrowRight, Bell, Check, DownloadCloud, ExternalLink, Shield, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
const STEPS = ["welcome", "account", "firewall", "done"] as const;
type Step = (typeof STEPS)[number];
const emptyHostForm = { name: "", base_url: "", api_key: "", api_secret: "", cluster_name: "" };
const noPin: TlsPinValue = { pem: null, insecure: false, clear: false };
function Stepper({ step, onGo }: { step: number; onGo: (index: number) => void }) {
const { t } = useLanguage();
return (
<nav aria-label={t("setup.stepsLabel")}>
<ol className="flex flex-wrap gap-2">
{STEPS.map((key, index) => {
const current = index === step;
const done = index < step;
return (
<li key={key}>
<button
type="button"
aria-current={current ? "step" : undefined}
onClick={() => onGo(index)}
className={clsx(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500",
current ? "border-brand-500 bg-brand-500 text-white" : done ? "border-slate-300 text-slate-700 hover:bg-slate-100" : "border-slate-200 text-slate-500 hover:bg-slate-100"
)}
>
<span
aria-hidden="true"
className={clsx("flex h-5 w-5 items-center justify-center rounded-full text-xs", current ? "bg-white/25" : done ? "bg-emerald-100 text-emerald-700" : "bg-slate-100")}
>
{done ? <Check size={12} /> : index + 1}
</span>
{t(`setup.steps.${key}`)}
</button>
</li>
);
})}
</ol>
</nav>
);
}
function WelcomeStep({ settings, onSettings }: { settings: AppSettings | null; onSettings: (s: AppSettings) => void }) {
const { t, errorMessage, language, setLanguage } = useLanguage();
const [error, setError] = useState("");
async function pick(value: "de" | "en") {
const previous = language;
setLanguage(value);
setError("");
try {
onSettings(await api.saveSettings({ language: value }));
} catch (e) {
setLanguage(previous);
setError(errorMessage(e, "common.saveFailed"));
}
}
return (
<div className="space-y-5">
<p className="text-sm text-slate-600">{t("setup.welcome.intro")}</p>
<ul className="space-y-2 text-sm text-slate-700">
{(["account", "firewall", "done"] as const).map((key) => (
<li key={key} className="flex items-start gap-2">
<Check size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
<span>{t(`setup.welcome.points.${key}`)}</span>
</li>
))}
</ul>
<p className="flex items-start gap-2 rounded-xl bg-slate-50 p-3 text-sm text-slate-600">
<ShieldCheck size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
{t("setup.welcome.readOnlyNote")}
</p>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-slate-700">{t("settings.language.label")}</legend>
<div role="radiogroup" aria-label={t("settings.language.label")} className="flex flex-wrap gap-3">
{(["de", "en"] as const).map((value) => (
<label
key={value}
className={clsx("flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2.5 text-sm", language === value ? "border-sky-500 bg-sky-50 text-sky-700" : "border-slate-200")}
>
<input type="radio" name="setup-language" checked={language === value} disabled={!settings} onChange={() => void pick(value)} />
{t(`settings.language.${value}`)}
</label>
))}
</div>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
</fieldset>
</div>
);
}
function AccountStep() {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [current, setCurrent] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [busy, setBusy] = useState(false);
const [changed, setChanged] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (password !== repeat) {
setError(t("settings.passwordMismatch"));
return;
}
setBusy(true);
try {
await api.changePassword(current, password);
setCurrent("");
setPassword("");
setRepeat("");
setChanged(true);
toast.show(t("settings.passwordChanged"), "success");
} catch (err) {
setError(errorMessage(err, "settings.passwordFailed"));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<section aria-labelledby="setup-password" className="space-y-3">
<h3 id="setup-password" className="flex items-center gap-2 text-base font-semibold text-slate-900">
{t("setup.account.passwordTitle")}
{changed && <Badge tone="success"><Check size={12} />{t("setup.account.passwordDone")}</Badge>}
</h3>
<p className="text-sm text-slate-600">{t("setup.account.passwordIntro")}</p>
<form onSubmit={submit} className="max-w-md space-y-3">
<FieldGroup label={t("settings.currentPassword")} hint={t("setup.account.currentHint")}>
<Input type="password" autoComplete="current-password" required value={current} onChange={(e) => setCurrent(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("settings.newPassword")} hint={t("setup.account.newHint")}>
<Input type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={(e) => setPassword(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("settings.repeatPassword")}>
<Input type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={(e) => setRepeat(e.target.value)} />
</FieldGroup>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<Button type="submit" variant="secondary" loading={busy}>{t("settings.changePassword")}</Button>
</form>
</section>
<section aria-labelledby="setup-2fa" className="space-y-3">
<h3 id="setup-2fa" className="text-base font-semibold text-slate-900">{t("setup.account.twofaTitle")}</h3>
<p className="text-sm text-slate-600">{t("setup.account.twofaIntro")}</p>
<TwoFactorSection />
</section>
</div>
);
}
function FirewallStep({ hosts, onHostsChanged }: { hosts: Host[] | null; onHostsChanged: () => Promise<void> }) {
const { t, ts, errorMessage } = useLanguage();
const toast = useToast();
const [form, setForm] = useState(emptyHostForm);
const [pin, setPin] = useState<TlsPinValue>(noPin);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [checking, setChecking] = useState<string | null>(null);
const firstHost = !hosts || hosts.length === 0;
async function create(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError("");
try {
const created = await api.createHost({
name: form.name.trim(),
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
api_secret: form.api_secret.trim(),
verify_tls: !pin.insecure,
tls_ca_pem: pin.pem,
enabled: true,
cluster_name: form.cluster_name.trim() || null,
policy_hierarchy_group_id: null,
});
toast.show(t("hosts.created", { name: created.name }), "success");
setForm(emptyHostForm);
setPin(noPin);
await onHostsChanged();
} catch (err) {
setError(errorMessage(err, "hosts.createFailed"));
} finally {
setSaving(false);
}
}
async function recheck(id: string) {
setChecking(id);
try {
await onHostsChanged();
} finally {
setChecking(null);
}
}
return (
<div className="space-y-6">
<p className="text-sm text-slate-600">{t("setup.firewall.intro")}</p>
{hosts && hosts.length > 0 && (
<section aria-labelledby="setup-hosts" className="space-y-2">
<h3 id="setup-hosts" className="text-base font-semibold text-slate-900">{t("setup.firewall.connected")}</h3>
<ul className="divide-y divide-slate-100 rounded-xl border border-slate-200">
{hosts.map((host) => (
<li key={host.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
<Link href={`/hosts/${host.id}`} className="font-medium text-sky-700 hover:underline">{host.name}</Link>
<span className="text-slate-500">{host.base_url}</span>
<span className="ml-auto flex items-center gap-3">
<StatusDot status={host.status} />
{host.status === "offline" && (
<Button type="button" variant="outline" loading={checking === host.id} onClick={() => void recheck(host.id)}>
{t("setup.firewall.recheck")}
</Button>
)}
</span>
{host.status === "offline" && (
<div className="basis-full space-y-0.5 text-xs">
{host.status_detail && <p className="text-slate-500">{ts(host.status_detail).slice(0, 160)}</p>}
<p className="text-flame-700">{t("setup.firewall.offlineHelp")}</p>
</div>
)}
</li>
))}
</ul>
</section>
)}
<section aria-labelledby="setup-key" className="space-y-3 rounded-xl border border-slate-200 p-4">
<h3 id="setup-key" className="text-base font-semibold text-slate-900">{t("setup.firewall.keyTitle")}</h3>
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-slate-700">
{(["menu", "user", "key", "rights"] as const).map((key) => (
<li key={key}>{t(`setup.firewall.keySteps.${key}`)}</li>
))}
</ol>
<p className="flex items-start gap-1.5 text-xs text-slate-500">
<DownloadCloud size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
{t("setup.firewall.keyFileNote")}
</p>
</section>
<form onSubmit={create} className="space-y-4" aria-labelledby="setup-add">
<h3 id="setup-add" className="text-base font-semibold text-slate-900">
{firstHost ? t("setup.firewall.addFirst") : t("setup.firewall.addAnother")}
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup label="Name" hint={t("setup.firewall.nameHint")}>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="fw-primary" />
</FieldGroup>
<FieldGroup label={t("hosts.baseUrl")} hint={t("setup.firewall.urlHint")}>
<Input required value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} placeholder="https://192.168.1.1" />
</FieldGroup>
<FieldGroup label="API-Key">
<Input required autoComplete="off" value={form.api_key} onChange={(e) => setForm({ ...form, api_key: e.target.value })} />
</FieldGroup>
<FieldGroup label="API-Secret">
<Input required type="password" autoComplete="off" value={form.api_secret} onChange={(e) => setForm({ ...form, api_secret: e.target.value })} />
</FieldGroup>
<FieldGroup label={t("hosts.clusterOptional")} hint={t("setup.firewall.clusterHint")}>
<Input value={form.cluster_name} onChange={(e) => setForm({ ...form, cluster_name: e.target.value })} placeholder={t("hosts.clusterPlaceholder")} />
</FieldGroup>
</div>
<TlsPinControl baseUrl={form.base_url} value={pin} onChange={setPin} />
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" loading={saving}>{saving ? t("hosts.saving") : t("setup.firewall.connect")}</Button>
<span className="text-xs text-slate-500">{t("setup.firewall.connectNote")}</span>
</div>
</form>
</div>
);
}
function DoneStep({ hosts, settings }: { hosts: Host[] | null; settings: AppSettings | null }) {
const { t } = useLanguage();
const [twofa, setTwofa] = useState<boolean | null>(null);
useEffect(() => {
api.twoFactorStatus().then((s) => setTwofa(s.enabled)).catch(() => setTwofa(null));
}, []);
const online = hosts?.filter((h) => h.status === "online").length ?? 0;
const total = hosts?.length ?? 0;
const seconds = settings?.deploy_confirm_window_seconds ?? 0;
const next: { href: string; icon: typeof Shield; key: string; params?: Record<string, string | number> }[] = [
{ href: "/rules", icon: Shield, key: "rules", params: { seconds } },
{ href: "/settings#notifications", icon: Bell, key: "notify" },
{ href: "/settings#opnexus-update", icon: DownloadCloud, key: "update" },
];
return (
<div className="space-y-6">
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<Badge tone={total > 0 && online === total ? "success" : total > 0 ? "warning" : "neutral"}>
{total > 0 ? t("setup.done.hostsOnline", { online, total }) : t("setup.done.noHosts")}
</Badge>
</li>
<li className="flex items-center gap-2">
<Badge tone={twofa ? "success" : "neutral"}>{twofa ? t("setup.done.twofaOn") : t("setup.done.twofaOff")}</Badge>
</li>
</ul>
<section aria-labelledby="setup-next" className="space-y-3">
<h3 id="setup-next" className="text-base font-semibold text-slate-900">{t("setup.done.nextTitle")}</h3>
<ul className="grid gap-3 sm:grid-cols-3">
{next.map(({ href, icon: Icon, key, params }) => (
<li key={key}>
<Link href={href} className="flex h-full flex-col gap-1 rounded-xl border border-slate-200 p-4 text-sm transition-colors hover:border-brand-500 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500">
<span className="flex items-center gap-2 font-medium text-slate-900"><Icon size={16} aria-hidden="true" />{t(`setup.done.next.${key}.title`)}</span>
<span className="text-slate-600">{t(`setup.done.next.${key}.text`, params)}</span>
</Link>
</li>
))}
</ul>
<p className="text-sm text-slate-600">
{t("setup.done.backupBefore")} <code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs">./opnexus backup</code>
<InfoTip text={t("setup.done.backupHelp")} size={13} className="ml-1 align-middle" />
</p>
<p className="text-sm text-slate-500">
<a href="https://opnexus.dev/docs/cli/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-sky-700 hover:underline">
{t("setup.done.docs")} <ExternalLink size={13} aria-hidden="true" />
</a>
</p>
</section>
</div>
);
}
export function SetupWizard() {
const { t } = useLanguage();
const router = useRouter();
const [index, setIndex] = useState(0);
const [role, setRole] = useState<string | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [hosts, setHosts] = useState<Host[] | null>(null);
const [finishing, setFinishing] = useState(false);
const [error, setError] = useState("");
const headingRef = useRef<HTMLHeadingElement>(null);
const mounted = useRef(false);
async function loadHosts() {
try {
setHosts(await api.listHosts());
} catch {
setHosts([]);
}
}
useEffect(() => {
api.me().then((s) => setRole(s.role ?? "admin")).catch(() => setRole("admin"));
api.settings().then(setSettings).catch(() => {});
void loadHosts();
}, []);
// Move focus to the step heading on navigation so screen-reader users hear
// where they are; skipped on the very first render.
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
headingRef.current?.focus();
}, [index]);
async function leave(markDone: boolean) {
setFinishing(true);
setError("");
try {
if (markDone && !settings?.setup_done) await api.saveSettings({ setup_done: true });
router.push("/dashboard");
} catch {
setError(t("common.saveFailed"));
setFinishing(false);
}
}
const step: Step = STEPS[index];
const last = index === STEPS.length - 1;
if (role === "read_only") {
return (
<div className="mx-auto max-w-3xl space-y-3">
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
<p className="text-sm text-slate-600">{t("setup.readOnly")}</p>
<Button href="/dashboard" variant="outline">{t("setup.toDashboard")}</Button>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
<p className="mt-1 text-sm text-slate-500">{t("setup.stepOf", { current: index + 1, total: STEPS.length })}</p>
</div>
<Button type="button" variant="ghost" loading={finishing} onClick={() => void leave(true)}>
{t("setup.skip")}
</Button>
</div>
<Stepper step={index} onGo={setIndex} />
<Card className="space-y-5 p-5 sm:p-6">
<div>
<h2 ref={headingRef} tabIndex={-1} className="text-xl font-semibold text-slate-900 outline-none">
{t(`setup.${step}.title`)}
</h2>
</div>
{step === "welcome" && <WelcomeStep settings={settings} onSettings={setSettings} />}
{step === "account" && <AccountStep />}
{step === "firewall" && <FirewallStep hosts={hosts} onHostsChanged={loadHosts} />}
{step === "done" && <DoneStep hosts={hosts} settings={settings} />}
</Card>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="outline" disabled={index === 0} onClick={() => setIndex(index - 1)}>
<ArrowLeft size={16} aria-hidden="true" />
{t("setup.back")}
</Button>
{last ? (
<Button type="button" loading={finishing} onClick={() => void leave(true)}>
{t("setup.finish")}
</Button>
) : (
<Button type="button" onClick={() => setIndex(index + 1)}>
{t("setup.next")}
<ArrowRight size={16} aria-hidden="true" />
</Button>
)}
</div>
</div>
);
}
+4 -1
View File
@@ -65,7 +65,10 @@ export function UpdateBanner() {
<div className="flex-1">
<p className="font-medium">{t("updateBanner.title", { latest: check.latest, current: check.current })}</p>
<p className="text-xs">
{t("updateBanner.how")}
{t("updateBanner.how")}{" "}
<a href="/settings#opnexus-update" className="font-medium underline">
{t("updateBanner.settings")}
</a>
{releaseUrl && (
<>
{" "}
+92 -5
View File
@@ -670,6 +670,42 @@ export interface AuthState {
role?: UserRole;
}
export type TokenScope = "read" | "deploy" | "admin";
export interface ApiToken {
id: string;
name: string;
scope: TokenScope;
prefix: string;
owner: string;
created_at: string;
expires_at: string | null;
last_used_at: string | null;
last_used_ip: string | null;
}
export type Severity = "critical" | "high" | "medium" | "low" | "info";
export interface ComplianceFinding {
id: string;
severity: Severity;
passed: boolean | null;
params: { count?: number; examples?: string[]; reason?: string; protocol?: string; age_days?: number | null };
}
export interface ComplianceHost {
host_id: string;
host_name: string;
cluster_name: string | null;
enabled: boolean;
checked_at: string | null;
score: number | null;
grade: "good" | "fair" | "poor" | null;
error: "unreachable" | "invalid_config" | null;
counts: Record<Severity, number> | null;
findings?: ComplianceFinding[];
}
export interface User {
id: string;
username: string;
@@ -1061,10 +1097,44 @@ export interface VersionCheck {
error: string | null;
}
export interface UpdaterJob {
id: string;
action: "update" | "rollback";
target: string | null;
state: "running" | "succeeded" | "failed" | "rolled_back";
started_at: string;
finished_at: string | null;
exit_code: number | null;
from_version: string | null;
}
export interface UpdaterStatus {
/** false = Updater nicht aktiviert/erreichbar (`reason` sagt warum). */
available: boolean;
reason: "not_enabled" | "unreachable" | "unauthorized" | null;
mode: "release" | "local" | null;
current: string;
latest: string | null;
update_available: boolean;
release_url: string | null;
feed_enabled: boolean;
feed_error: string | null;
checked_at: string | null;
can_update: boolean;
major_change: boolean;
job: UpdaterJob | null;
phases: string[];
log: string[];
services: Record<string, string>;
rollback_available: boolean;
rollback_loses_data: boolean;
rollback_version: string | null;
}
export const api = {
search: (q: string, options?: RequestInit) => request<SearchHit[]>(`/search?${new URLSearchParams({ q })}`, options),
settings: () => request<AppSettings>("/settings"),
saveSettings: (partial:{theme?:Theme;language?:Language}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
saveSettings: (partial:{theme?:Theme;language?:Language;setup_done?:boolean}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
notificationSettings: () => request<NotificationSettings>("/notifications/settings"),
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
logSettings: () => request<LogSettings>("/logs/settings"),
@@ -1088,11 +1158,14 @@ export const api = {
stopService: (id:string, name:string) => request<{result:string}>(`/hosts/${id}/services/${encodeURIComponent(name)}/stop`,{method:"POST"}),
restartService: (id:string, name:string) => request<{result:string}>(`/hosts/${id}/services/${encodeURIComponent(name)}/restart`,{method:"POST"}),
listScheduledMaintenance: (id:string) => request<ScheduledMaintenanceItem[]>(`/hosts/${id}/maintenance/schedule`),
createScheduledMaintenance: (id:string, action:"reboot"|"update"|"major_update", run_at:string) => request<ScheduledMaintenanceItem>(`/hosts/${id}/maintenance/schedule`,{method:"POST",body:JSON.stringify({action,run_at,acknowledge_unattended:true})}),
createScheduledMaintenance: (id:string, action:"reboot"|"update"|"major_update", run_at:string, carp_handover=false) => request<ScheduledMaintenanceItem>(`/hosts/${id}/maintenance/schedule`,{method:"POST",body:JSON.stringify({action,run_at,acknowledge_unattended:true,carp_handover})}),
cancelScheduledMaintenance: (id:string, scheduleId:string) => request<ScheduledMaintenanceItem>(`/hosts/${id}/maintenance/schedule/${scheduleId}`,{method:"DELETE"}),
audit: (filters: Record<string,string | number> = {}) => request<AuditPage>(`/audit?${new URLSearchParams(Object.entries(filters).filter(([,v])=>v!=="").map(([k,v])=>[k,String(v)]))}`),
dashboard: () => request<DashboardSummary>("/dashboard"),
versionCheck: () => request<VersionCheck>("/system/version-check"),
updaterStatus: () => request<UpdaterStatus>("/system/updater"),
updaterUpdate: (version: string) => request<{ job: UpdaterJob }>("/system/updater/update", { method: "POST", body: JSON.stringify({ version }) }),
updaterRollback: () => request<{ job: UpdaterJob }>("/system/updater/rollback", { method: "POST" }),
getOutboundMode: (id: string) => request<OutboundMode>(`/outbound-nat/hosts/${id}/mode`),
listPortForwards: () => request<PortForward[]>(`/port-forward`),
createPortForward: (data: PortForwardCreate) => request<PortForward>(`/port-forward`, { method: "POST", body: JSON.stringify(data) }),
@@ -1249,6 +1322,16 @@ export const api = {
request<User>(`/users/${id}/reset-password`, { method: "POST", body: JSON.stringify({ new_password }) }),
deleteUser: (id: string) => request<void>(`/users/${id}`, { method: "DELETE" }),
listCompliance: () => request<ComplianceHost[]>("/compliance"),
getHostCompliance: (id: string) => request<ComplianceHost>(`/hosts/${id}/compliance`),
checkHostCompliance: (id: string) => request<ComplianceHost>(`/hosts/${id}/compliance/check`, { method: "POST" }),
checkFleetCompliance: () => request<ComplianceHost[]>("/compliance/check", { method: "POST" }),
listApiTokens: () => request<ApiToken[]>("/tokens"),
createApiToken: (data: { name: string; scope: TokenScope; expires_in_days: number | null; current_password: string }) =>
request<ApiToken & { token: string }>("/tokens", { method: "POST", body: JSON.stringify(data) }),
revokeApiToken: (id: string) => request<void>(`/tokens/${id}`, { method: "DELETE" }),
listPolicyHierarchyGroups: () => request<PolicyHierarchyGroup[]>("/policy-hierarchy/groups"),
createPolicyHierarchyGroup: (data: PolicyHierarchyGroupWrite) => request<PolicyHierarchyGroup>("/policy-hierarchy/groups", { method: "POST", body: JSON.stringify(data) }),
updatePolicyHierarchyGroup: (id: string, data: PolicyHierarchyGroupWrite) => request<PolicyHierarchyGroup>(`/policy-hierarchy/groups/${id}`, { method: "PUT", body: JSON.stringify(data) }),
@@ -1451,6 +1534,7 @@ export interface FirmwarePackageChange {
export interface MaintenanceState {
host_name: string;
base_url: string;
cluster_name?: string | null;
version: string;
product_latest?: string | null;
carp_status?: string | null;
@@ -1470,7 +1554,7 @@ export interface MaintenanceState {
export interface HostOperation { id:string; host_id:string; action:"reboot"|"update"|"major_update"|"carp_enter"|"carp_leave"; status:string; created_at:string; expires_at:string; actor:string; message:string|null; }
export interface OpnsenseService { id:string; name:string; description:string; locked:boolean|number; running:boolean|number; }
export interface HostResourceSample { id:string; sampled_at:string; mem_used_pct:number|null; load_avg_1m:number|null; disk_used_pct:number|null; raw:Record<string,unknown>; }
export interface ScheduledMaintenanceItem { id:string; host_id:string; action:"reboot"|"update"|"major_update"; run_at:string; status:"pending"|"running"|"succeeded"|"failed"|"cancelled"; actor:string; created_at:string; result:{error?:string;response?:unknown}; host_operation_id:string|null; }
export interface ScheduledMaintenanceItem { id:string; host_id:string; action:"reboot"|"update"|"major_update"; run_at:string; status:"pending"|"running"|"succeeded"|"failed"|"cancelled"; actor:string; created_at:string; result:{error?:string;response?:unknown;handover?:boolean;version_before?:string|null;version_after?:string|null;reboot_required?:boolean}; host_operation_id:string|null; carp_handover:boolean; phase:"handover_wait"|"job_wait"|"leave"|"leave_wait"|null; }
export interface MaintenanceRead {state:MaintenanceState|null;error:string|null;operations:HostOperation[];}
export interface MaintenancePreview {preview_id:string;expires_at:string;action:"reboot"|"update"|"major_update";state:MaintenanceState;}
export interface CarpSwitchState {
@@ -1490,7 +1574,7 @@ export interface CarpSwitchState {
export interface CarpSwitchPreview {preview_id:string;expires_at:string;action:"carp_enter"|"carp_leave";state:CarpSwitchState;}
export type Theme="light"|"dark"|"system";
export interface AppSettings {username:string;theme:Theme;language:Language;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
export interface AppSettings {username:string;theme:Theme;language:Language;setup_done:boolean;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
export type SmtpSecurity = "none" | "starttls" | "ssl";
@@ -1515,6 +1599,7 @@ export interface NotificationSettings {
nat_drift_enabled: boolean;
network_drift_enabled: boolean;
carp_auto_revert_failed_enabled: boolean;
scheduled_maintenance_failed_enabled: boolean;
poll_interval_seconds: number;
updated_at: string;
}
@@ -1542,6 +1627,7 @@ export interface NotificationSettingsUpdate {
nat_drift_enabled?: boolean;
network_drift_enabled?: boolean;
carp_auto_revert_failed_enabled?: boolean;
scheduled_maintenance_failed_enabled?: boolean;
poll_interval_seconds?: number;
}
@@ -1564,7 +1650,8 @@ export interface LogSettingsUpdate {
export type ProblemType =
| "host_offline" | "cert_expiry" | "rule_drift" | "revert_failed" | "maintenance_unknown"
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift";
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift"
| "scheduled_maintenance_failed";
export interface CarpEvent {
id: string;
+337 -11
View File
@@ -63,6 +63,7 @@
"carpHealth": "CARP-Zustand kritisch (Split-Brain / kein Master)",
"natDrift": "NAT-Drift im Cluster",
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
"scheduledFailed": "Geplante Wartung nicht ausgeführt",
"networkDrift": "Netzwerk-Abweichung im Cluster"
},
"pollInterval": "Prüfintervall (Sekunden)",
@@ -110,7 +111,66 @@
"about": "Über OPNexus",
"version": "Version",
"development": "Entwicklung",
"changelogNote": "Änderungsverlauf und Versionsschema stehen in {file}."
"changelogNote": "Änderungsverlauf und Versionsschema stehen in {file}.",
"update": {
"title": "OPNexus aktualisieren",
"hint": "Lädt die neuen Images, wechselt die Dienste und prüft, ob alles gesund ist. Schlägt das fehl, wird automatisch auf die vorherige Version zurückgerollt.",
"installed": "Installiert",
"latest": "Neueste Version",
"upToDate": "OPNexus ist auf dem neuesten Stand.",
"notes": "Release-Notes",
"checked": "Zuletzt geprüft: {time}",
"feedOff": "Die Versionsprüfung ist aus. Trage UPDATE_CHECK_URL in der .env des Servers ein, damit OPNexus neue Versionen erkennt.",
"feedError": "Der Release-Feed ist gerade nicht erreichbar.",
"notEnabledTitle": "Updates aus dem Webinterface sind nicht aktiviert.",
"notEnabledBody": "Dafür läuft ein eigener kleiner Dienst (Updater) mit Zugriff auf Docker — er ist bewusst optional. Aktivieren:",
"notEnabledStep1": "In der .env auf dem Server eintragen:",
"notEnabledStep2": "Dann ausführen:",
"notEnabledCli": "Bis dahin geht das Update auf dem Server mit „opnexus update“.",
"unreachable": "Der Updater antwortet nicht. Auf dem Server prüfen: docker compose ps updater",
"localMode": "Diese Installation baut OPNexus lokal aus dem Quellcode (kein Release-Image). Aktualisieren auf dem Server mit git pull und docker compose up -d --build.",
"majorChange": "Version {latest} ist ein Hauptversions-Sprung. Bitte erst die Release-Notes lesen und dann auf dem Server mit „opnexus update {latest}“ aktualisieren.",
"button": "Auf {latest} aktualisieren",
"confirmTitle": "Auf {latest} aktualisieren?",
"confirmBody": "OPNexus lädt die neuen Images und startet sich neu. Die Oberfläche ist dabei etwa ein bis zwei Minuten nicht erreichbar; die verwalteten Firewalls sind nicht betroffen.",
"confirmSafety": "Vor einer Datenbank-Migration wird automatisch ein Backup angelegt. Wird die neue Version nicht gesund, setzt OPNexus selbst auf die vorherige Version zurück.",
"confirm": "Jetzt aktualisieren",
"startFailed": "Update konnte nicht gestartet werden",
"running": "Update auf {target} läuft …",
"rollingBack": "Rollback läuft …",
"reconnecting": "Verbindung wird neu aufgebaut — beim Neustart der Dienste ist das normal.",
"phase": {
"pulling": "Neue Images laden",
"switching": "Dienste wechseln",
"waiting": "Auf gesunde Dienste warten",
"stopping": "Dienste anhalten",
"restoring": "Datenbank zurückspielen",
"rolled_back": "Zurückgerollt",
"done": "Fertig"
},
"result": {
"succeededUpdate": "OPNexus läuft jetzt auf {version}. Die Seite wird neu geladen …",
"succeededRollback": "OPNexus wurde auf {version} zurückgesetzt. Die Seite wird neu geladen …",
"rolledBack": "Das Update auf {target} ist fehlgeschlagen. OPNexus wurde automatisch auf die vorherige Version zurückgesetzt.",
"failed": "Der Vorgang ist fehlgeschlagen. Protokoll und Serverzustand prüfen (auf dem Server: opnexus status)."
},
"showLog": "Protokoll",
"rollback": {
"button": "Auf Vorversion zurücksetzen",
"hint": "Macht das letzte Update rückgängig (zurück auf {version}).",
"confirmTitle": "Auf {version} zurücksetzen?",
"dataLoss": "Das Update hat die Datenbank migriert. Beim Zurücksetzen wird der Stand vor dem Update wiederhergestellt: Änderungen seit dem Update und die Log-Historie gehen verloren.",
"noDataLoss": "Die Datenbank wurde nicht migriert — es gehen keine Daten verloren.",
"confirm": "Zurücksetzen"
},
"badge": "Update verfügbar",
"resultTime": "Ergebnis vom {time}"
},
"setup": {
"title": "Einrichtungsassistent",
"intro": "Führt Schritt für Schritt durch Passwort, Zwei-Faktor-Anmeldung und das Verbinden einer Firewall.",
"open": "Assistent öffnen"
}
},
"nav": {
"aboutTitle": "Über OPNexus — Fleet-Status & Versionen",
@@ -152,16 +212,19 @@
"certificates": "Zertifikate",
"logs": "Logs",
"audit": "Audit",
"security": "Sicherheitslage",
"updates": "Update Center",
"settings": "Einstellungen",
"users": "Benutzer",
"tokens": "API-Tokens",
"network-devices": "Geräte",
"network-compare": "Abgleich",
"hostsystem-tunables": "Tunables",
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnose"
"hostsystem-diagnostics": "Diagnose",
"setup": "Einrichtung"
}
},
"dashboard": {
@@ -173,6 +236,7 @@
"fleetHealth": "Fleet-/CARP-Gesundheit",
"noHosts": "Noch keine Hosts angelegt",
"toHosts": "Zu den Hosts",
"startSetup": "Einrichtungsassistent starten",
"nothingPending": "Nichts offen",
"nothingPendingDescription": "Keine ausstehenden Bestätigungen, Fehler oder unklaren Wartungsvorgänge.",
"securityAndExpiryWarnings": "Sicherheits- & Ablauf-Warnungen",
@@ -407,6 +471,8 @@
"FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998": "Für diesen Tunnel ist noch kein Server-Privatschlüssel hinterlegt",
"FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR": "Für dieses Ziel liegt kein wiederholbarer Rollback vor",
"GEPLANTE_WARTUNG_NICHT_GEFUNDEN": "Geplante Wartung nicht gefunden",
"GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH": "Geplante Wartung ist nur auf dem CARP-BACKUP möglich. Diese Firewall ist gerade MASTER – zuerst den CARP-Wartungsmodus aktivieren oder den Partner-Knoten einplanen",
"CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER": "Die automatische CARP-Umschaltung gibt es nur für Hosts in einem Cluster",
"GROUP_HAS_ASSIGNED_ITEMS": "Gruppe hat noch zugeordnete {label}. Diese zuerst verschieben oder löschen.",
"GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN": "Gruppenmitglieder oder Regeln wurden geändert. Auswirkungen erneut prüfen.",
"GRUPPENNAME_BEREITS_VERGEBEN": "Gruppenname bereits vergeben",
@@ -509,7 +575,17 @@
"NETWORK_BACKUP_FAILED": "Backup von „{host}“ vor der Änderung fehlgeschlagen – es wurde nichts geändert",
"NETWORK_CHANGE_BLOCKED": "Die Änderung ist nicht möglich ({reasons})",
"NETWORK_FIELD_INVALID": "Ungültiges Feld: {field}",
"NETWORK_ITEM_NOT_FOUND": "Das Objekt existiert auf der Firewall nicht (mehr)"
"NETWORK_ITEM_NOT_FOUND": "Das Objekt existiert auf der Firewall nicht (mehr)",
"UPDATER_UNAVAILABLE": "Der Updater ist nicht aktiviert oder nicht erreichbar.",
"UPDATER_BUSY": "Es läuft bereits ein Update oder Rollback.",
"UPDATER_REJECTED": "Der Updater hat die Anfrage abgelehnt ({reason}).",
"UPDATE_NOT_OFFERED": "Diese Version wird nicht zur Installation angeboten — nur die neueste stabile Version.",
"UPDATE_MAJOR_CHANGE": "Hauptversions-Sprünge bitte bewusst auf dem Server mit „opnexus update“ ausführen.",
"ADMIN_REQUIRED": "Diese Funktion erfordert die Rolle „Admin“.",
"API_TOKEN_LIMIT_REACHED": "Es sind höchstens {limit} API-Tokens pro Benutzer möglich. Widerrufe zuerst nicht mehr benötigte Tokens.",
"API_TOKEN_NOT_FOUND": "API-Token nicht gefunden",
"API_TOKEN_SCOPE_FORBIDDEN": "Dieses API-Token (Bereich „{scope}“) darf diese Aktion nicht ausführen.",
"API_TOKEN_SCOPE_TOO_HIGH": "Nur-Lese-Konten können nur Tokens mit dem Bereich „Lesen“ anlegen."
},
"workflow": {
"attention": "Fehler prüfen",
@@ -561,8 +637,9 @@
"updateBanner": {
"title": "OPNexus {latest} ist verfügbar (installiert: {current}).",
"notes": "Release-Notes",
"how": "Aktualisieren auf dem Server mit „opnexus update“.",
"dismiss": "Hinweis für diese Version ausblenden"
"how": "Aktualisieren in den Einstellungen oder auf dem Server mit „opnexus update“.",
"dismiss": "Hinweis für diese Version ausblenden",
"settings": "Zu den Einstellungen"
},
"acme": {
"externalCaWarning": "Kontaktiert die echte Zertifizierungsstelle (z. B. Let's Encrypt) über das Internet — keine rein lokale Änderung. Zählt gegen deren Rate-Limits (z. B. Ausstellungen pro Domain und Woche). Für Tests eine *_test-CA (z. B. letsencrypt_test) verwenden.",
@@ -794,7 +871,8 @@
"running": "läuft",
"succeeded": "übergeben",
"cancelled": "abgebrochen",
"failed": "fehlgeschlagen"
"failed": "fehlgeschlagen",
"completed": "abgeschlossen"
},
"history": "Verlauf",
"opStatus": {
@@ -835,11 +913,16 @@
"scheduleTitle": "Wartung später ausführen",
"actionLabel": "Aktion",
"timeLabel": "Zeitpunkt",
"unattendedWarning": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt. Voraussetzungen (z. B. frisches Backup für ein Update) werden erst dann erneut geprüft — ist eine nicht erfüllt, wird die Aktion nicht ausgeführt und als fehlgeschlagen markiert, ohne erneuten Versuch.",
"unattendedWarning": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt. Für Updates erstellt OPNexus unmittelbar davor selbst ein Konfigurationsbackup. Die übrigen Voraussetzungen werden erst dann erneut geprüft — ist eine nicht erfüllt, wird die Aktion nicht ausgeführt und als fehlgeschlagen markiert, ohne erneuten Versuch.",
"unattendedAck": "Mir ist bewusst, dass niemand anwesend sein wird, wenn die Aktion ausgeführt wird.",
"scheduling": "Wird geplant…",
"scheduleIt": "Wartung einplanen",
"unattendedShort": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt"
"unattendedShort": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt",
"scheduleMasterBlocked": "Diese Firewall ist gerade CARP-MASTER. Geplante Wartung läuft nur auf dem BACKUP-Knoten, weil sonst der Verkehr ausfällt. Plane stattdessen den Partner-Knoten ein, oder aktiviere zuerst den CARP-Wartungsmodus an diesem Host.",
"handoverOption": "Vorher automatisch auf den Partner umschalten und danach zurückschalten (CARP)",
"handoverHint": "Ablauf: Wartungsmodus auf diesem Host, Partner wird MASTER (wird geprüft), dann läuft die Aktion, nach Rückkehr des Hosts wird der Wartungsmodus wieder verlassen. Bei Problemen bleibt der Partner MASTER und es kommt eine Benachrichtigung. Beim Zurückschalten kann der Host je nach CARP-Einstellung die MASTER-Rolle zurückholen (kurzer zweiter Wechsel).",
"handoverBadge": "mit CARP-Umschaltung",
"phase": {"handover_wait": "wartet auf Übernahme durch den Partner", "job_wait": "Auftrag läuft", "leave": "schaltet zurück", "leave_wait": "prüft den Normalbetrieb"}
},
"restore": {
"handoffWarning": "OPNexus führt die Wiederherstellung NICHT selbst aus. Nach der Übergabe musst du die Sicherung manuell im OPNsense-Webinterface importieren. Solange dieser Vorgang offen ist, sind Regel-/Alias-/NAT-Pushes und Host-Wartung für diesen Host gesperrt.",
@@ -1347,6 +1430,164 @@
"peerPsk": "Peer-PSK",
"pskPlaceholder": "PSK einfügen"
},
"security": {
"title": "Sicherheitslage",
"intro": "Prüft die Konfiguration jeder Firewall auf typische Schwachstellen (offene Regeln, schwaches SSH/SNMP, fehlende Backups …) und vergibt einen Wert von 0 bis 100. Nur lesend: an den Firewalls wird nichts geändert. Automatisch alle 6 Stunden geprüft.",
"loadFailed": "Sicherheitslage konnte nicht geladen werden",
"checkFailed": "Die Prüfung ist fehlgeschlagen",
"checked": "Prüfung abgeschlossen",
"checkAll": "Alle prüfen",
"checkHost": "Erneut prüfen",
"noHosts": "Noch keine Firewalls",
"noHostsHint": "Verbinde eine Firewall, um ihre Sicherheitslage zu sehen.",
"notChecked": "Nicht geprüft",
"neverChecked": "Noch nicht geprüft",
"checkedAt": "Geprüft {when}",
"allGood": "Keine offenen Befunde",
"andMore": "… und {n} weitere",
"unknown": "unbekannt",
"failed": "nicht bestanden",
"passed": "bestanden",
"severity": {
"critical": "kritisch",
"high": "hoch",
"medium": "mittel",
"low": "niedrig",
"info": "Hinweis"
},
"error": {
"unreachable": "nicht erreichbar",
"invalid_config": "nicht lesbar"
},
"errorHint": {
"unreachable": "Die Firewall war nicht erreichbar und konnte deshalb nicht geprüft werden.",
"invalid_config": "Die Konfiguration konnte nicht gelesen werden."
},
"reason": {
"root_password": "root darf sich per Passwort über SSH anmelden",
"password": "SSH erlaubt die Anmeldung per Passwort"
},
"checks": {
"webgui_https": {
"title": "Web-Oberfläche nur über HTTPS",
"ok": "Die Verwaltungsoberfläche läuft über HTTPS.",
"fail": "Die Verwaltungsoberfläche läuft über unverschlüsseltes HTTP. Zugangsdaten sind im Netz mitlesbar.",
"fix": "System › Einstellungen › Administration: Protokoll auf HTTPS stellen."
},
"ssh_hardened": {
"title": "SSH abgesichert",
"ok": "SSH ist aus oder erlaubt nur Schlüssel.",
"fail": "{reason}.",
"fix": "SSH ausschalten, oder unter System › Einstellungen › Administration Root-Login und Passwort-Anmeldung abschalten und Schlüssel nutzen."
},
"snmp_community": {
"title": "SNMP-Community ist nicht „public“",
"ok": "SNMP nutzt eine eigene Community.",
"fail": "SNMP nutzt die voreingestellte Community „public“. Jeder im Netz kann Systemdaten lesen.",
"fix": "Eine eigene, lange Community setzen oder SNMP ausschalten."
},
"wan_any_any": {
"title": "Keine Allow-all-Regel am WAN",
"ok": "Am WAN erlaubt keine Regel allen Verkehr überallhin.",
"fail": "{count} Regel(n) am WAN erlauben jeden Verkehr zu jedem Ziel — die Firewall schützt dort nichts.",
"fix": "Regel auf die nötigen Quellen, Ziele und Ports beschränken oder löschen."
},
"internal_any_any": {
"title": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
"ok": "Keine interne Regel erlaubt beliebige Quellen zu beliebigen Zielen.",
"fail": "{count} Regel(n) erlauben beliebige Quellen zu beliebigen Zielen.",
"fix": "Die Quelle auf das jeweilige Netz beschränken."
},
"rules_shadowed": {
"title": "Keine verdeckten Regeln",
"ok": "Jede Regel wird erreicht.",
"fail": "{count} Regel(n) werden nie erreicht, weil eine frühere Regel sie schon abdeckt.",
"fix": "Reihenfolge prüfen; die überflüssige Regel entfernen oder davor einsortieren."
},
"rules_duplicate": {
"title": "Keine doppelten Regeln",
"ok": "Keine Regel kommt doppelt vor.",
"fail": "{count} Regel(n) sind identisch zu einer früheren.",
"fix": "Die doppelte Regel löschen."
},
"rules_documented": {
"title": "Regeln sind beschriftet",
"ok": "Alle aktiven Regeln haben eine Beschreibung.",
"fail": "{count} aktive Regel(n) haben keine Beschreibung.",
"fix": "Beschreibung ergänzen, damit später klar bleibt, wofür die Regel da ist."
},
"ntp_configured": {
"title": "Zeitserver gesetzt",
"ok": "Zeitserver sind eingetragen.",
"fail": "Kein Zeitserver konfiguriert. Logs und Zertifikate brauchen die richtige Uhrzeit.",
"fix": "Zeitserver in den Zeiteinstellungen eintragen."
},
"remote_logging": {
"title": "Logs liegen auch woanders",
"ok": "Ein externes Syslog-Ziel ist eingetragen.",
"fail": "Logs liegen nur auf der Firewall. Nach einem Einbruch oder Ausfall fehlen sie.",
"fix": "Unter System › Einstellungen › Logging einen Syslog-Server eintragen."
},
"ids_enabled": {
"title": "Einbruchserkennung aktiv",
"ok": "Die Einbruchserkennung ist an.",
"fail": "Die Einbruchserkennung (IDS/IPS) ist nicht eingeschaltet.",
"fix": "Optional: Dienste › Intrusion Detection aktivieren."
},
"firmware_current": {
"title": "Firmware aktuell",
"ok": "Keine Updates offen.",
"fail": "Es stehen Updates bereit.",
"fix": "Im Update Center einspielen.",
"unknown": "Der Updatestatus konnte nicht geprüft werden."
},
"backup_recent": {
"title": "Aktuelles Backup",
"ok": "Das neueste Backup ist {days} Tag(e) alt.",
"fail": "Es gibt kein Backup aus den letzten 7 Tagen.",
"fix": "Auf der Host-Seite „Jetzt sichern“ ausführen oder eine Wartung planen."
}
}
},
"tokens": {
"title": "API-Tokens",
"new": "Neues Token",
"intro": "Mit Tokens nutzen Skripte, CI-Jobs, Prometheus und KI-Assistenten die OPNexus-API ohne Anmeldung. Jedes Token gehört dir, hat einen Bereich und lässt sich jederzeit widerrufen. Es bekommt nie mehr Rechte als dein Konto.",
"loadFailed": "API-Tokens konnten nicht geladen werden",
"createFailed": "Token konnte nicht erstellt werden",
"empty": "Noch keine Tokens.",
"colName": "Name",
"colScope": "Bereich",
"colOwner": "Besitzer",
"colExpires": "Läuft ab",
"colLastUsed": "Zuletzt benutzt",
"never": "Nie",
"neverUsed": "Noch nicht benutzt",
"days": "{n} Tage",
"nameHint": "Wofür das Token gedacht ist, z. B. „Grafana“ oder „CI-Deploy“.",
"scope": {
"read": "Lesen",
"deploy": "Ausrollen",
"admin": "Admin"
},
"scopeHint": {
"read": "Nur lesen: alles ansehen, nichts ändern. Für Dashboards, Prometheus und KI-Assistenten.",
"deploy": "Lesen plus Regeln, Aliase, NAT, VPN und Wartung ausrollen. Kann keine Benutzer, Einstellungen oder Hosts verwalten.",
"admin": "Alles, was dein Konto darf, außer Tokens und dein eigenes Konto zu verwalten."
},
"password": "Dein Passwort",
"passwordHint": "Zum Erstellen eines Tokens ist dein aktuelles Passwort nötig.",
"create": "Token erstellen",
"revoke": "Widerrufen",
"revokeConfirm": "Token „{name}“ widerrufen? Alles, was es nutzt, funktioniert sofort nicht mehr.",
"revoked": "Token „{name}“ widerrufen",
"createdTitle": "Token „{name}“ erstellt",
"shownOnce": "Kopiere das Token jetzt. Es wird nur einmal angezeigt und lässt sich nicht erneut anzeigen.",
"copy": "Kopieren",
"copied": "Kopiert",
"example": "Beispiel",
"done": "Fertig"
},
"users": {
"roleAdmin": "Admin",
"roleReadOnly": "Nur-Lesen",
@@ -1452,7 +1693,8 @@
"wireguard_tunnel": "WireGuard",
"certificate": "Zertifikate",
"acme": "ACME",
"notification": "Benachrichtigungen"
"notification": "Benachrichtigungen",
"system": "OPNexus-System"
},
"action": {
"deploy": "Push",
@@ -1787,7 +2029,7 @@
"managementNote": "OPNexus erreicht diese Firewall über {device}. Änderungen an dieser Schnittstelle können die Verbindung trennen und werden später besonders abgesichert.",
"managementUnknown": "Über welche Schnittstelle OPNexus diese Firewall erreicht, lässt sich nicht bestimmen (z. B. über NAT oder eine CARP-Adresse).",
"unavailable": "Nicht abrufbar von dieser Firewall: {sections}.",
"cannotConfigure": "Diese OPNsense-Version erlaubt keine IP-Konfiguration von Schnittstellen per API (ab ca. 26.7.6).",
"cannotConfigure": "Diese Firewall ist zu alt, um Schnittstellen-IPs per API zu ändern (möglich ab OPNsense 26.7.6). Ändere die IP direkt in OPNsense oder aktualisiere die Firewall.",
"sections": {
"assignments": "Schnittstellen-Zuweisung",
"vlans": "VLANs",
@@ -1998,7 +2240,7 @@
"blocks": {
"api_unavailable": "Diese Firewall stellt dafür keine API bereit ({section}).",
"nothing_to_change": "Es gibt nichts zu ändern.",
"interface_api_unavailable": "Diese OPNsense-Version kann Schnittstellen nicht per API konfigurieren (ab ca. 26.7.6).",
"interface_api_unavailable": "Diese Firewall ist zu alt, um Schnittstellen per API zu konfigurieren (möglich ab OPNsense 26.7.6). Bitte direkt in OPNsense ändern oder aktualisieren.",
"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.",
"tunable_default_row": "Das ist eine Vorgabe der Firewall. Zum Ändern einen eigenen Eintrag mit „Überschreiben“ anlegen."
@@ -2133,5 +2375,89 @@
"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"
}
}
}
+337 -11
View File
@@ -63,6 +63,7 @@
"carpHealth": "CARP state critical (split-brain / no master)",
"natDrift": "NAT drift in the cluster",
"carpAutoRevertFailed": "CARP auto-revert failed",
"scheduledFailed": "Scheduled maintenance did not run",
"networkDrift": "Network differences in the cluster"
},
"pollInterval": "Check interval (seconds)",
@@ -110,7 +111,66 @@
"about": "About OPNexus",
"version": "Version",
"development": "Development",
"changelogNote": "The change history and version scheme are in {file}."
"changelogNote": "The change history and version scheme are in {file}.",
"update": {
"title": "Update OPNexus",
"hint": "Pulls the new images, switches the services and checks that everything is healthy. If that fails, OPNexus rolls back to the previous version automatically.",
"installed": "Installed",
"latest": "Latest version",
"upToDate": "OPNexus is up to date.",
"notes": "Release notes",
"checked": "Last checked: {time}",
"feedOff": "The version check is off. Set UPDATE_CHECK_URL in the server's .env so OPNexus can detect new versions.",
"feedError": "The release feed is currently unreachable.",
"notEnabledTitle": "Updates from the web interface are not enabled.",
"notEnabledBody": "They need a small separate service (the updater) with access to Docker — deliberately optional. To enable it:",
"notEnabledStep1": "Add to the .env on the server:",
"notEnabledStep2": "Then run:",
"notEnabledCli": "Until then, update on the server with “opnexus update”.",
"unreachable": "The updater does not respond. Check on the server: docker compose ps updater",
"localMode": "This installation builds OPNexus locally from source (no release image). Update on the server with git pull and docker compose up -d --build.",
"majorChange": "Version {latest} is a major version jump. Please read the release notes first, then update on the server with “opnexus update {latest}”.",
"button": "Update to {latest}",
"confirmTitle": "Update to {latest}?",
"confirmBody": "OPNexus pulls the new images and restarts itself. The interface is unavailable for about one to two minutes; the managed firewalls are not affected.",
"confirmSafety": "A backup is taken automatically before a database migration. If the new version does not become healthy, OPNexus rolls back to the previous version on its own.",
"confirm": "Update now",
"startFailed": "Could not start the update",
"running": "Updating to {target} …",
"rollingBack": "Rolling back …",
"reconnecting": "Reconnecting — this is normal while the services restart.",
"phase": {
"pulling": "Pulling new images",
"switching": "Switching services",
"waiting": "Waiting for healthy services",
"stopping": "Stopping services",
"restoring": "Restoring the database",
"rolled_back": "Rolled back",
"done": "Done"
},
"result": {
"succeededUpdate": "OPNexus is now running {version}. Reloading the page …",
"succeededRollback": "OPNexus was rolled back to {version}. Reloading the page …",
"rolledBack": "The update to {target} failed. OPNexus was rolled back to the previous version automatically.",
"failed": "The operation failed. Check the log and the server state (on the server: opnexus status)."
},
"showLog": "Log",
"rollback": {
"button": "Roll back to previous version",
"hint": "Undoes the last update (back to {version}).",
"confirmTitle": "Roll back to {version}?",
"dataLoss": "The update migrated the database. Rolling back restores the state from before the update: changes made since the update and the log history are lost.",
"noDataLoss": "The database was not migrated — no data is lost.",
"confirm": "Roll back"
},
"badge": "Update available",
"resultTime": "Result from {time}"
},
"setup": {
"title": "Setup assistant",
"intro": "Walks you step by step through password, two-factor sign-in and connecting a firewall.",
"open": "Open assistant"
}
},
"nav": {
"aboutTitle": "About OPNexus — fleet status & versions",
@@ -152,16 +212,19 @@
"certificates": "Certificates",
"logs": "Logs",
"audit": "Audit",
"security": "Security posture",
"updates": "Update Center",
"settings": "Settings",
"users": "Users",
"tokens": "API tokens",
"network-devices": "Devices",
"network-compare": "Compare",
"hostsystem-tunables": "Tunables",
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnostics"
"hostsystem-diagnostics": "Diagnostics",
"setup": "Setup"
}
},
"dashboard": {
@@ -173,6 +236,7 @@
"fleetHealth": "Fleet/CARP health",
"noHosts": "No hosts yet",
"toHosts": "Go to hosts",
"startSetup": "Start the setup assistant",
"nothingPending": "Nothing pending",
"nothingPendingDescription": "No pending confirmations, errors, or unclear maintenance operations.",
"securityAndExpiryWarnings": "Security & expiry warnings",
@@ -407,6 +471,8 @@
"FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998": "No server private key has been stored for this tunnel",
"FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR": "No repeatable rollback exists for this target",
"GEPLANTE_WARTUNG_NICHT_GEFUNDEN": "Scheduled maintenance not found",
"GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH": "Scheduled maintenance is only possible on the CARP BACKUP. This firewall is currently MASTER – enable CARP maintenance mode first or schedule the partner node",
"CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER": "Automatic CARP switchover is only available for hosts in a cluster",
"GROUP_HAS_ASSIGNED_ITEMS": "The group still has assigned {label}. Move or delete them first.",
"GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN": "Group members or rules changed. Review the impact again.",
"GRUPPENNAME_BEREITS_VERGEBEN": "Group name is already in use",
@@ -509,7 +575,17 @@
"NETWORK_BACKUP_FAILED": "Backup of “{host}” before the change failed – nothing was changed",
"NETWORK_CHANGE_BLOCKED": "The change is not possible ({reasons})",
"NETWORK_FIELD_INVALID": "Invalid field: {field}",
"NETWORK_ITEM_NOT_FOUND": "The object does not exist on the firewall (any more)"
"NETWORK_ITEM_NOT_FOUND": "The object does not exist on the firewall (any more)",
"UPDATER_UNAVAILABLE": "The updater is not enabled or not reachable.",
"UPDATER_BUSY": "An update or rollback is already running.",
"UPDATER_REJECTED": "The updater rejected the request ({reason}).",
"UPDATE_NOT_OFFERED": "This version is not offered for installation — only the latest stable version.",
"UPDATE_MAJOR_CHANGE": "Please run major version jumps deliberately on the server with “opnexus update”.",
"ADMIN_REQUIRED": "This function requires the “Admin” role.",
"API_TOKEN_LIMIT_REACHED": "At most {limit} API tokens are allowed per user. Revoke tokens you no longer need first.",
"API_TOKEN_NOT_FOUND": "API token not found",
"API_TOKEN_SCOPE_FORBIDDEN": "This API token (scope “{scope}”) is not allowed to perform this action.",
"API_TOKEN_SCOPE_TOO_HIGH": "Read-only accounts can only create tokens with the “read” scope."
},
"workflow": {
"attention": "Check errors",
@@ -561,8 +637,9 @@
"updateBanner": {
"title": "OPNexus {latest} is available (installed: {current}).",
"notes": "Release notes",
"how": "Update on the server with “opnexus update”.",
"dismiss": "Hide this notice for this version"
"how": "Update in the settings or on the server with “opnexus update”.",
"dismiss": "Hide this notice for this version",
"settings": "Go to settings"
},
"acme": {
"externalCaWarning": "Contacts the real certificate authority (e.g. Let's Encrypt) over the internet — not a purely local change. Counts against its rate limits (e.g. issuances per domain per week). Use a *_test CA (e.g. letsencrypt_test) for testing.",
@@ -794,7 +871,8 @@
"running": "running",
"succeeded": "submitted",
"cancelled": "cancelled",
"failed": "failed"
"failed": "failed",
"completed": "completed"
},
"history": "History",
"opStatus": {
@@ -835,11 +913,16 @@
"scheduleTitle": "Run maintenance later",
"actionLabel": "Action",
"timeLabel": "Time",
"unattendedWarning": "Runs unattended at the chosen time. Prerequisites (e.g. a fresh backup for an update) are only re-checked then — if one is not met, the action is not executed and is marked as failed, without a retry.",
"unattendedWarning": "Runs unattended at the chosen time. For updates, OPNexus takes a configuration backup itself right before. The other prerequisites are only re-checked then — if one is not met, the action is not executed and is marked as failed, without a retry.",
"unattendedAck": "I understand that nobody will be present when the action runs.",
"scheduling": "Scheduling…",
"scheduleIt": "Schedule maintenance",
"unattendedShort": "Runs unattended at the chosen time"
"unattendedShort": "Runs unattended at the chosen time",
"scheduleMasterBlocked": "This firewall is currently the CARP MASTER. Scheduled maintenance only runs on the BACKUP node, otherwise traffic would drop. Schedule the partner node instead, or enable CARP maintenance mode on this host first.",
"handoverOption": "Switch to the partner first and switch back afterwards (CARP)",
"handoverHint": "Flow: maintenance mode on this host, the partner becomes MASTER (verified), then the action runs, and once the host is back the maintenance mode is left again. If anything goes wrong the partner stays MASTER and a notification is sent. When switching back, the host may take the MASTER role back depending on the CARP settings (a short second switchover).",
"handoverBadge": "with CARP switchover",
"phase": {"handover_wait": "waiting for the partner to take over", "job_wait": "job running", "leave": "switching back", "leave_wait": "verifying normal operation"}
},
"restore": {
"handoffWarning": "OPNexus does NOT perform the restore itself. After the handover you must import the backup manually in the OPNsense web interface. While this operation is open, rule/alias/NAT pushes and host maintenance are blocked for this host.",
@@ -1347,6 +1430,164 @@
"peerPsk": "Peer PSK",
"pskPlaceholder": "Paste PSK"
},
"security": {
"title": "Security posture",
"intro": "Checks every firewall's configuration for common weaknesses (open rules, weak SSH/SNMP, missing backups…) and gives a score from 0 to 100. Read-only: nothing is changed on the firewalls. Checked automatically every 6 hours.",
"loadFailed": "Could not load the security posture",
"checkFailed": "The check failed",
"checked": "Check finished",
"checkAll": "Check all",
"checkHost": "Check again",
"noHosts": "No firewalls yet",
"noHostsHint": "Connect a firewall to see its security posture.",
"notChecked": "Not checked",
"neverChecked": "Not checked yet",
"checkedAt": "Checked {when}",
"allGood": "No open findings",
"andMore": "… and {n} more",
"unknown": "unknown",
"failed": "failed",
"passed": "passed",
"severity": {
"critical": "critical",
"high": "high",
"medium": "medium",
"low": "low",
"info": "note"
},
"error": {
"unreachable": "unreachable",
"invalid_config": "unreadable"
},
"errorHint": {
"unreachable": "The firewall could not be reached, so it could not be checked.",
"invalid_config": "The configuration could not be read."
},
"reason": {
"root_password": "root may log in over SSH with a password",
"password": "SSH accepts password login"
},
"checks": {
"webgui_https": {
"title": "Web interface over HTTPS only",
"ok": "The management interface is served over HTTPS.",
"fail": "The management interface runs over unencrypted HTTP. Credentials can be read on the network.",
"fix": "System › Settings › Administration: set the protocol to HTTPS."
},
"ssh_hardened": {
"title": "SSH locked down",
"ok": "SSH is off or accepts keys only.",
"fail": "{reason}.",
"fix": "Switch SSH off, or disable root login and password login under System › Settings › Administration and use keys."
},
"snmp_community": {
"title": "SNMP community is not “public”",
"ok": "SNMP uses a custom community.",
"fail": "SNMP uses the default community “public”. Anyone on the network can read system data.",
"fix": "Set your own long community or turn SNMP off."
},
"wan_any_any": {
"title": "No allow-all rule on the WAN",
"ok": "No rule on the WAN allows all traffic to everywhere.",
"fail": "{count} rule(s) on the WAN allow all traffic to any destination — the firewall protects nothing there.",
"fix": "Restrict the rule to the sources, destinations and ports you need, or delete it."
},
"internal_any_any": {
"title": "No allow-all rule from any source (internal)",
"ok": "No internal rule allows any source to any destination.",
"fail": "{count} rule(s) allow any source to any destination.",
"fix": "Limit the source to the network the rule is meant for."
},
"rules_shadowed": {
"title": "No shadowed rules",
"ok": "Every rule can be reached.",
"fail": "{count} rule(s) are never reached because an earlier rule already covers them.",
"fix": "Check the order; remove the useless rule or move it up."
},
"rules_duplicate": {
"title": "No duplicate rules",
"ok": "No rule is repeated.",
"fail": "{count} rule(s) are identical to an earlier one.",
"fix": "Delete the duplicate."
},
"rules_documented": {
"title": "Rules are described",
"ok": "All active rules have a description.",
"fail": "{count} active rule(s) have no description.",
"fix": "Add a description so it stays clear later what the rule is for."
},
"ntp_configured": {
"title": "Time server set",
"ok": "Time servers are configured.",
"fail": "No time server is configured. Logs and certificates need the right time.",
"fix": "Enter NTP servers in the time settings."
},
"remote_logging": {
"title": "Logs are kept elsewhere too",
"ok": "A remote syslog target is configured.",
"fail": "Logs exist only on the firewall. After a break-in or a failure they are gone.",
"fix": "Add a syslog server under System › Settings › Logging."
},
"ids_enabled": {
"title": "Intrusion detection active",
"ok": "Intrusion detection is on.",
"fail": "Intrusion detection (IDS/IPS) is not switched on.",
"fix": "Optional: enable Services › Intrusion Detection."
},
"firmware_current": {
"title": "Firmware up to date",
"ok": "No updates are pending.",
"fail": "Updates are available.",
"fix": "Install them in the Update Center.",
"unknown": "Could not determine the update status."
},
"backup_recent": {
"title": "Recent backup",
"ok": "The newest backup is {days} day(s) old.",
"fail": "There is no backup from the last 7 days.",
"fix": "Use “Back up now” on the host page or schedule maintenance."
}
}
},
"tokens": {
"title": "API tokens",
"new": "New token",
"intro": "Tokens let scripts, CI jobs, Prometheus and AI assistants use the OPNexus API without a sign-in. Each token belongs to you, has a scope and can be revoked at any time. It never gets more rights than your account.",
"loadFailed": "Could not load API tokens",
"createFailed": "Could not create the token",
"empty": "No tokens yet.",
"colName": "Name",
"colScope": "Scope",
"colOwner": "Owner",
"colExpires": "Expires",
"colLastUsed": "Last used",
"never": "Never",
"neverUsed": "Not used yet",
"days": "{n} days",
"nameHint": "What the token is for, e.g. “Grafana” or “CI deploy”.",
"scope": {
"read": "Read",
"deploy": "Deploy",
"admin": "Admin"
},
"scopeHint": {
"read": "Read only: view everything, change nothing. For dashboards, Prometheus and AI assistants.",
"deploy": "Read plus roll out rules, aliases, NAT, VPN and maintenance. Cannot manage users, settings or hosts.",
"admin": "Everything your account may do, except managing tokens and your own account."
},
"password": "Your password",
"passwordHint": "Creating a token needs your current password.",
"create": "Create token",
"revoke": "Revoke",
"revokeConfirm": "Revoke token “{name}”? Anything using it stops working immediately.",
"revoked": "Token “{name}” revoked",
"createdTitle": "Token “{name}” created",
"shownOnce": "Copy the token now. It is shown only once and cannot be displayed again.",
"copy": "Copy",
"copied": "Copied",
"example": "Example",
"done": "Done"
},
"users": {
"roleAdmin": "Admin",
"roleReadOnly": "Read-only",
@@ -1452,7 +1693,8 @@
"wireguard_tunnel": "WireGuard",
"certificate": "Certificates",
"acme": "ACME",
"notification": "Notifications"
"notification": "Notifications",
"system": "OPNexus system"
},
"action": {
"deploy": "Push",
@@ -1787,7 +2029,7 @@
"managementNote": "OPNexus reaches this firewall through {device}. Changes to this interface can cut the connection and will get extra safeguards later.",
"managementUnknown": "Which interface OPNexus uses to reach this firewall cannot be determined (e.g. via NAT or a CARP address).",
"unavailable": "Not available from this firewall: {sections}.",
"cannotConfigure": "This OPNsense version does not allow configuring interface IPs through its API (from about 26.7.6).",
"cannotConfigure": "This firewall is too old to change interface IPs through its API (possible from OPNsense 26.7.6). Change the IP directly in OPNsense or update the firewall.",
"sections": {
"assignments": "interface assignments",
"vlans": "VLANs",
@@ -1998,7 +2240,7 @@
"blocks": {
"api_unavailable": "This firewall does not offer an API for this ({section}).",
"nothing_to_change": "There is nothing to change.",
"interface_api_unavailable": "This OPNsense version cannot configure interfaces through its API (from about 26.7.6).",
"interface_api_unavailable": "This firewall is too old to configure interfaces through its API (possible from OPNsense 26.7.6). Please change it directly in OPNsense or update the firewall.",
"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.",
"tunable_default_row": "This is a built-in default of the firewall. To change it, create your own entry with “Override”."
@@ -2133,5 +2375,89 @@
"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"
}
}
}
+19
View File
@@ -120,6 +120,25 @@ export const serverMessagesEn: Array<[string | RegExp, string]> = [
["Unerwarteter Fehler beim Ausführen", "Unexpected error during execution"],
["Host nicht gefunden oder deaktiviert", "Host not found or disabled"],
[/^Host nicht erreichbar: (?<detail>[\s\S]*)$/, "Host unreachable: {detail}"],
[/^Automatisches Backup vor dem Update fehlgeschlagen: (?<detail>[\s\S]*)$/, "Automatic backup before the update failed: {detail}"],
[/^Vorbereitung der CARP-Übergabe fehlgeschlagen, nichts wurde verändert: (?<detail>[\s\S]*)$/, "Preparing the CARP handover failed, nothing was changed: {detail}"],
[/^(?<name>.+) steht bereits im CARP-Wartungsmodus\. Rollen prüfen\.$/, "{name} is already in CARP maintenance mode. Check the roles."],
[/^CARP-Umschalten mit unklarem Ergebnis: (?<detail>[\s\S]*)\. Nicht automatisch wiederholt — Rollen von (?<name>.+) und Partner prüfen\.$/, "CARP switch with an unclear result: {detail}. Not retried automatically — check the roles of {name} and its partner."],
[/^Auftrag „(?<action>[^“]+)“ mit unklarem Ergebnis: (?<detail>[\s\S]*)\. Nicht wiederholt\. (?<name>.+) bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr\.$/, "Job “{action}” with an unclear result: {detail}. Not retried. {name} stays in CARP maintenance mode, the partner carries the traffic."],
[/^CARP-Übergabe nicht bestätigt, und der Normalbetrieb konnte nicht wiederhergestellt werden \((?<detail>[\s\S]*)\)\. (?<name>.+) prüfen\.$/, "CARP handover not confirmed, and normal operation could not be restored ({detail}). Check {name}."],
["CARP-Übergabe nicht bestätigt (der Partner wurde nicht MASTER). Normalbetrieb wiederhergestellt, nichts aktualisiert.", "CARP handover not confirmed (the partner did not become MASTER). Normal operation restored, nothing was updated."],
["CARP-Übergabe hat nicht stattgefunden (Wartungsmodus nicht aktiv). Nichts aktualisiert.", "The CARP handover did not happen (maintenance mode not active). Nothing was updated."],
[/^CARP-Übergabe nicht bestätigt und (?<name>.+) nicht erreichbar\. Rollen prüfen\.$/, "CARP handover not confirmed and {name} is unreachable. Check the roles."],
[/^Auftrag „(?<action>[^“]+)“ auf (?<name>.+) nicht rechtzeitig abgeschlossen\. .+ bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr\. Bitte prüfen und von Hand zurückschalten\.$/, "Job “{action}” on {name} did not finish in time. {name} stays in CARP maintenance mode, the partner carries the traffic. Please check and switch back by hand."],
[/^(?<name>.+) ist nach dem Auftrag nicht wieder erreichbar\. Der Partner trägt den Verkehr\.$/, "{name} is not reachable again after the job. The partner carries the traffic."],
[/^Zurückschalten mit unklarem Ergebnis: (?<detail>[\s\S]*)\. Nicht automatisch wiederholt — (?<name>.+) prüfen, ggf\. „CARP-Normalbetrieb herstellen“\.$/, "Switching back with an unclear result: {detail}. Not retried automatically — check {name}, if needed use “Restore CARP normal operation”."],
[/^Normalbetrieb nach dem Zurückschalten nicht bestätigt\. Rollen von (?<name>.+) und Partner prüfen\.$/, "Normal operation not confirmed after switching back. Check the roles of {name} and its partner."],
[/^Unerwarteter Fehler während der CARP-Übergabe \((?<detail>[^)]*)\)\. (?<name>.+) kann im CARP-Wartungsmodus verblieben sein — Rollen prüfen\.$/, "Unexpected error during the CARP handover ({detail}). {name} may have stayed in CARP maintenance mode — check the roles."],
[/^Vorgang von Hand beendet, Ablauf gestoppt\. CARP-Rolle von (?<name>.+) prüfen\.$/, "Operation closed by hand, run stopped. Check the CARP role of {name}."],
["Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt.", "Completed automatically: handover, job and switch-back confirmed."],
["Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt. Ein Neustart ist noch erforderlich.", "Completed automatically: handover, job and switch-back confirmed. A reboot is still required."],
["CARP-Übergabe angefordert, warte auf Bestätigung.", "CARP handover requested, waiting for confirmation."],
["Übergabe bestätigt, Auftrag gestartet.", "Handover confirmed, job started."],
["Manuell vor Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet.", "Manually switched back to normal operation before the window expired."],
["Host wurde gelöscht, bevor das Zeitfenster ablief.", "The host was deleted before the window expired."],
["Host war beim Ablauf bereits nicht mehr im CARP-Wartungsmodus.", "The host was no longer in CARP maintenance mode when the window expired."],
+2 -2
View File
@@ -1,6 +1,6 @@
// Manually bumped whenever a notable change ships — mirrors the dated
// entries already kept in the project README/CHANGELOG.md, no build
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
export const APP_VERSION = "1.14.0";
export const APP_CODENAME = "Eagle";
export const APP_VERSION = "1.18.0";
export const APP_CODENAME = "Kestrel";
export const APP_MAINTAINER = "Julian Boldt";
+3 -3
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Baut die drei Release-Images (backend, frontend, proxy) aus dem Stand eines
# Baut die Release-Images (backend, frontend, proxy, updater) aus dem Stand eines
# Tags und pusht sie optional — für den Fall, dass (noch) kein CI-Runner
# existiert. Tags und Namen entsprechen .github/workflows/release.yml:
# <REPO>/opnexus-{backend,frontend,proxy}:X.Y.Z, :X.Y, :X (+ :latest bei stabilen Versionen)
# <REPO>/opnexus-{backend,frontend,proxy,updater}:X.Y.Z, :X.Y, :X (+ :latest bei stabilen Versionen)
#
# scripts/build_release_images.sh v1.10.0 # nur bauen (Architektur dieses Hosts)
# docker login git.opnexus.dev
@@ -35,7 +35,7 @@ if [[ "$version" != *-* ]]; then # nur stabile Versionen bekommen Kurz-Tags un
tags+=("$major.$minor" "$major" latest)
fi
for svc in backend frontend proxy; do
for svc in backend frontend proxy updater; do
image="$repo/opnexus-$svc"
targs=(); for t in "${tags[@]}"; do targs+=(-t "$image:$t"); done
echo "==> $image (${tags[*]})"
+7 -1
View File
@@ -38,11 +38,17 @@ OPNexus $version
1. cp .env.example .env und POSTGRES_PASSWORD (und was sonst nötig ist) eintragen
2. docker compose pull && docker compose up -d --no-build
3. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
3. Anmelden: ./opnexus password (zeigt das Admin-Passwort, wenn ADMIN_PASSWORD leer war)
dann im Browser öffnen — der Einrichtungsassistent führt durch den Rest
4. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
./opnexus update 1.10.1 (feste Version)
Zurück: ./opnexus rollback
Sichern: ./opnexus backup
Optional: Updates per Klick im Webinterface (Einstellungen › "OPNexus aktualisieren"):
in .env COMPOSE_PROFILES=updater und UPDATE_CHECK_URL setzen, dann
docker compose up -d --no-build (Hinweise zur Sicherheit: Handbuch)
Vollständige Anleitung: README.md im Repository, Abschnitt "Release-Images & CI".
TXT
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env bash
# opnexus — Update, Rollback und Backup für eine OPNexus-Installation aus
# fertigen Release-Images (siehe README, "Release-Images & CI").
#
# opnexus status Version, Container, Datenbank-Revision
# opnexus update [VERSION] Neue Images ziehen, wechseln, auf "gesund" warten;
# bei Fehler automatisch zurückrollen
# opnexus rollback Letztes Update rückgängig machen
# opnexus backup Sofort-Backup der OPNexus-Datenbank
#
# Läuft im Verzeichnis mit docker-compose.yml und .env (oder OPNEXUS_DIR=...).
# Braucht: docker (compose v2), bash. OPNEXUS_IMAGE_REPO muss in .env stehen.
set -euo pipefail
here="$(dirname "$(readlink -f "$0")")"
# Im Release-Bundle liegt das Skript neben docker-compose.yml, im Repo in scripts/.
if [ -f "$here/docker-compose.yml" ]; then default_dir="$here"; else default_dir="$here/.."; fi
cd "${OPNEXUS_DIR:-$default_dir}"
ENV_FILE=".env"
STATE_FILE=".opnexus-update-state"
SERVICES=(backend frontend proxy)
TIMEOUT="${OPNEXUS_UPDATE_TIMEOUT:-240}"
c() { docker compose "$@" 2> >(grep -v 'level=warning' >&2); }
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
die() { printf '\033[31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" "$ENV_FILE" 2>/dev/null | tail -n1; }
DB_USER="$(env_get POSTGRES_USER)"; DB_USER="${DB_USER:-opnexus}"
DB_NAME="$(env_get POSTGRES_DB)"; DB_NAME="${DB_NAME:-opnexus}"
env_set() {
if grep -q "^$1=" "$ENV_FILE" 2>/dev/null; then
sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"
else
printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"
fi
}
# Postgres-Client-Tools aus dem Backend-Image (Client 17): Die Pre-Upgrade-
# Dumps schreibt das Backend mit pg_dump 17 — das kann pg_restore 16 (Sidecar)
# NICHT lesen. Also Dump UND Restore immer mit diesen Tools.
pgtool() {
local tool="$1"; shift
# Passwort über die Umgebung durchreichen (`-e PGPASSWORD` ohne Wert), nicht als
# Argument — sonst stünde es in der Prozessliste.
PGPASSWORD="$(env_get POSTGRES_PASSWORD)" c run --rm --no-deps -T -e PGPASSWORD --entrypoint "$tool" backend "$@"
}
psql_db() { c exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -Atq "$@"; }
db_revision() {
psql_db -c "SELECT version_num FROM alembic_version" 2>/dev/null || true
}
running_version() {
c exec -T backend python -c 'from app.version import APP_VERSION; print(APP_VERSION)' 2>/dev/null || true
}
service_state() { # healthy | unhealthy | starting | running | (leer)
local h
h=$(c ps "$1" --format '{{.Health}}' 2>/dev/null || true)
if [ -n "$h" ]; then echo "$h"; else c ps "$1" --format '{{.State}}' 2>/dev/null || true; fi
}
wait_healthy() {
local deadline=$((SECONDS + TIMEOUT)) all svc st
while [ "$SECONDS" -lt "$deadline" ]; do
all=1
for svc in "${SERVICES[@]}"; do
st=$(service_state "$svc")
case "$st" in healthy|running) ;; *) all=0 ;; esac
done
[ "$all" = 1 ] && return 0
sleep 3
done
return 1
}
require_release_mode() {
[ -f docker-compose.yml ] || die "docker-compose.yml nicht gefunden (OPNEXUS_DIR setzen?)"
[ -f "$ENV_FILE" ] || die "$ENV_FILE nicht gefunden"
[ -n "$(env_get OPNEXUS_IMAGE_REPO)" ] || die "OPNEXUS_IMAGE_REPO ist in $ENV_FILE nicht gesetzt — dieses Kommando arbeitet mit fertigen Release-Images (lokale Builds: git pull && docker compose up -d --build)."
}
cmd_status() {
require_release_mode
echo "Image-Repo: $(env_get OPNEXUS_IMAGE_REPO)"
echo "OPNEXUS_VERSION: $(env_get OPNEXUS_VERSION)"
echo "Laufende Version: $(running_version)"
echo "DB-Revision: $(db_revision)"
for svc in "${SERVICES[@]}"; do echo "$svc: $(service_state "$svc")"; done
[ -f "$STATE_FILE" ] && { echo; echo "Letztes Update (für 'rollback'):"; sed 's/^/ /' "$STATE_FILE"; }
return 0
}
cmd_backup() {
local name="manual-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$name"
say "Backup geschrieben: /backups/$name (Volume postgres_backups)"
}
cmd_update() {
require_release_mode
local target="${1:-$(env_get OPNEXUS_VERSION)}"
target="${target:-latest}"
# Landet in .env und in Image-Tags: nur Zeichen, die ein Tag enthalten darf.
[[ "$target" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die "Ungültige Version '$target' (erlaubt: Buchstaben, Ziffern, . _ -)."
local prev_setting prev_version prev_rev started
prev_setting=$(env_get OPNEXUS_VERSION)
prev_version=$(running_version)
[ -n "$prev_version" ] || die "Backend läuft nicht oder antwortet nicht — erst reparieren, dann updaten."
prev_rev=$(db_revision)
say "Aktuell: $prev_version (DB-Revision ${prev_rev:-keine}) — Ziel: $target"
say "Ziehe neue Images (am laufenden System ändert sich dabei nichts) …"
OPNEXUS_VERSION="$target" c pull "${SERVICES[@]}" || die "Pull fehlgeschlagen — nichts wurde verändert."
started=$(date -u +%Y%m%dT%H%M%SZ)
cat > "$STATE_FILE" <<STATE
started=$started
prev_version=$prev_version
prev_setting=$prev_setting
prev_db_revision=$prev_rev
target=$target
STATE
env_set OPNEXUS_VERSION "$target"
say "Wechsle auf $target (das Backend sichert die Datenbank selbst vor einer Migration) …"
# `up` selbst scheitert, sobald das Backend nicht gesund wird (depends_on) —
# das ist der Fehlerfall, nicht das Ende des Skripts.
if c up -d --no-build && wait_healthy; then
say "Fertig: läuft jetzt auf $(running_version) (DB-Revision $(db_revision))."
return 0
fi
printf '\033[31mUpdate nicht gesund geworden.\033[0m Backend-Log:\n' >&2
c logs --tail 25 backend >&2 || true
if [ "${OPNEXUS_NO_AUTO_ROLLBACK:-}" = 1 ]; then
die "Automatisches Zurückrollen ist aus. Manuell: opnexus rollback"
fi
say "Rolle automatisch zurück …"
cmd_rollback
die "Update auf $target fehlgeschlagen und zurückgerollt auf $prev_version."
}
latest_pre_upgrade_dump() {
c exec -T postgres-backup sh -c 'ls -1t /backups/pre-upgrade-*.dump 2>/dev/null | head -n1' | tr -d '\r'
}
cmd_rollback() {
require_release_mode
[ -f "$STATE_FILE" ] || die "Kein Update-Zustand ($STATE_FILE) — es gibt nichts zurückzurollen."
# shellcheck disable=SC1090
. "./$STATE_FILE"
[ -n "${prev_version:-}" ] || die "$STATE_FILE ist unvollständig."
# Rollback target = the exact image tag that was configured before the update.
# The version the backend REPORTS only matches the tag for final releases: an
# rc image reports its final version (rc1 images say 1.11.0), so rolling back
# to that would land on a different, newer image. Floating tags ("latest",
# "1.11") cannot be restored by name, so those fall back to the reported version.
if [[ "${prev_setting:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
prev_version="$prev_setting"
fi
say "Rolle zurück auf $prev_version …"
c stop "${SERVICES[@]}"
local now_rev dump
now_rev=$(db_revision)
if [ "$now_rev" != "$prev_db_revision" ]; then
dump=$(latest_pre_upgrade_dump)
[ -n "$dump" ] || die "Datenbank wurde migriert ($prev_db_revision → $now_rev), aber es gibt kein pre-upgrade-Backup. Backend bleibt gestoppt."
# Nur ein Backup, das nach dem Start DIESES Updates entstanden ist, darf
# zurückgespielt werden — ein älteres würde stillschweigend Daten verwerfen.
dump_ts=$(echo "$dump" | sed -E 's/.*-([0-9]{8})-([0-9]{6})\.dump$/\1\2/')
[ "$dump_ts" -ge "${started//[-:TZ]/}" ] 2>/dev/null \
|| die "Neuestes pre-upgrade-Backup ($dump) ist älter als dieses Update (SKIP_MIGRATION_BACKUP gesetzt?). Backend bleibt gestoppt — manuell eingreifen."
say "Datenbank wurde migriert ($prev_db_revision → $now_rev): spiele $dump zurück."
say "Hinweis: Log-Historie und Ressourcen-Messwerte sind im Backup nicht enthalten und danach leer; alles andere steht auf dem Stand vor dem Update."
# Erst prüfen, dass das Backup lesbar ist — BEVOR irgendetwas gelöscht wird.
pgtool pg_restore --list "$dump" > /dev/null \
|| die "Backup $dump ist nicht lesbar. Datenbank unverändert, Backend bleibt gestoppt."
local safety="pre-rollback-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$safety"
say "Sicherheitskopie des jetzigen Zustands: /backups/$safety"
reset_schema() {
pgtool psql -h postgres -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
-c 'DROP SCHEMA public CASCADE' -c 'CREATE SCHEMA public' 2>&1 | grep -v '^NOTICE\|^DETAIL\|^drop cascades' || true
}
reset_schema
if ! pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "$dump"; then
say "Zurückspielen fehlgeschlagen — stelle den Zustand vor dem Rollback aus /backups/$safety wieder her …"
reset_schema
pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "/backups/$safety" \
|| die "Auch die Wiederherstellung schlug fehl. Backups liegen in /backups ($dump, $safety). Backend bleibt gestoppt."
die "Rollback der Datenbank fehlgeschlagen; Zustand vor dem Rollback wiederhergestellt. Backend bleibt gestoppt."
fi
else
say "Datenbank-Revision unverändert ($now_rev) — kein Zurückspielen nötig."
fi
env_set OPNEXUS_VERSION "$prev_version"
c up -d --no-build || true
wait_healthy || die "Nach dem Rollback sind nicht alle Dienste gesund — 'docker compose logs' prüfen."
say "Fertig: läuft wieder auf $(running_version) (DB-Revision $(db_revision)). OPNEXUS_VERSION in $ENV_FILE ist jetzt fest auf $prev_version gesetzt."
rm -f "$STATE_FILE"
}
case "${1:-}" in
status) cmd_status ;;
update) shift; cmd_update "${1:-}" ;;
rollback) cmd_rollback ;;
backup) cmd_backup ;;
*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
+1
View File
@@ -0,0 +1 @@
../updater/opnexus
+2
View File
@@ -0,0 +1,2 @@
__pycache__
*.pyc
+18
View File
@@ -0,0 +1,18 @@
# Optionaler Updater-Dienst (Compose-Profil "updater"). Er enthält Docker-CLI
# und Compose, damit das Webinterface ein Update auslösen kann, ohne dass das
# Backend je den Docker-Socket sieht (siehe server.py und Handbuch).
FROM python:3.12-alpine
RUN apk add --no-cache bash docker-cli docker-cli-compose
COPY opnexus /usr/local/bin/opnexus
COPY server.py /app/server.py
RUN chmod 0755 /usr/local/bin/opnexus
WORKDIR /app
ENV PYTHONUNBUFFERED=1 OPNEXUS_DIR=/project UPDATER_STATE_DIR=/state
HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=5s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"
CMD ["python", "/app/server.py"]
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env bash
# opnexus — Update, Rollback und Backup für eine OPNexus-Installation aus
# fertigen Release-Images (siehe README, "Release-Images & CI").
#
# opnexus status Version, Container, Datenbank-Revision
# opnexus update [VERSION] Neue Images ziehen, wechseln, auf "gesund" warten;
# bei Fehler automatisch zurückrollen
# opnexus rollback Letztes Update rückgängig machen
# opnexus backup Sofort-Backup der OPNexus-Datenbank
# opnexus password [NAME] Neues Zufallspasswort für den Admin setzen und
# anzeigen (--yes: ohne Rückfrage)
# opnexus status --porcelain Zustand als key=value-Zeilen (für den Updater)
#
# Läuft im Verzeichnis mit docker-compose.yml und .env (oder OPNEXUS_DIR=...).
# Braucht: docker (compose v2), bash. OPNEXUS_IMAGE_REPO muss in .env stehen.
#
# Exit-Codes von `update`: 0 = läuft auf der neuen Version, 3 = Update ist
# fehlgeschlagen und wurde sauber zurückgerollt, 1 = sonstiger Fehler.
#
# Umgebung (nur für den Updater-Container, siehe updater/server.py):
# OPNEXUS_UPDATER=1 `up` fasst nur backend/frontend/proxy an — der
# Updater-Container selbst wird nie aus seinem
# eigenen Lauf heraus neu gestartet.
# OPNEXUS_PHASE_FILE=PATH hängt bei jedem Schritt eine Zeile "phase" an.
set -euo pipefail
here="$(dirname "$(readlink -f "$0")")"
# Im Release-Bundle liegt das Skript neben docker-compose.yml, im Repo in scripts/.
if [ -f "$here/docker-compose.yml" ]; then default_dir="$here"; else default_dir="$here/.."; fi
cd "${OPNEXUS_DIR:-$default_dir}"
ENV_FILE=".env"
STATE_FILE=".opnexus-update-state"
SERVICES=(backend frontend proxy)
TIMEOUT="${OPNEXUS_UPDATE_TIMEOUT:-240}"
c() { docker compose "$@" 2> >(grep -v 'level=warning' >&2); }
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
# Schritt für den Updater-Container (Fortschrittsanzeige im Webinterface).
phase() { [ -z "${OPNEXUS_PHASE_FILE:-}" ] || echo "$1" >> "$OPNEXUS_PHASE_FILE"; }
die() { printf '\033[31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" "$ENV_FILE" 2>/dev/null | tail -n1; }
DB_USER="$(env_get POSTGRES_USER)"; DB_USER="${DB_USER:-opnexus}"
DB_NAME="$(env_get POSTGRES_DB)"; DB_NAME="${DB_NAME:-opnexus}"
env_set() {
if grep -q "^$1=" "$ENV_FILE" 2>/dev/null; then
sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"
else
printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"
fi
}
# Postgres-Client-Tools aus dem Backend-Image (Client 17): Die Pre-Upgrade-
# Dumps schreibt das Backend mit pg_dump 17 — das kann pg_restore 16 (Sidecar)
# NICHT lesen. Also Dump UND Restore immer mit diesen Tools.
pgtool() {
local tool="$1"; shift
# Passwort über die Umgebung durchreichen (`-e PGPASSWORD` ohne Wert), nicht als
# Argument — sonst stünde es in der Prozessliste.
PGPASSWORD="$(env_get POSTGRES_PASSWORD)" c run --rm --no-deps -T -e PGPASSWORD --entrypoint "$tool" backend "$@"
}
# Der Updater-Container ist ein optionaler Dienst (Compose-Profil "updater"):
# nur wenn er aktiv ist, wird sein Image mitgezogen — sonst würde ein Update
# daran scheitern, dass es für eine ältere Version noch kein Updater-Image gibt.
updater_enabled() {
local profiles
profiles="${COMPOSE_PROFILES:-$(env_get COMPOSE_PROFILES)}"
[[ ",$profiles," == *",updater,"* ]]
}
# Welche Dienste `up` anfasst: im Updater-Container nur die Anwendung, auf dem
# Host (CLI) alles — dort darf sich auch der Updater selbst erneuern.
up_targets() {
if [ -n "${OPNEXUS_UPDATER:-}" ]; then printf '%s\n' "${SERVICES[@]}"; fi
}
psql_db() { c exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -Atq "$@"; }
db_revision() {
psql_db -c "SELECT version_num FROM alembic_version" 2>/dev/null || true
}
running_version() {
c exec -T backend python -c 'from app.version import APP_VERSION; print(APP_VERSION)' 2>/dev/null || true
}
service_state() { # healthy | unhealthy | starting | running | (leer)
local h
h=$(c ps "$1" --format '{{.Health}}' 2>/dev/null || true)
if [ -n "$h" ]; then echo "$h"; else c ps "$1" --format '{{.State}}' 2>/dev/null || true; fi
}
wait_healthy() {
local deadline=$((SECONDS + TIMEOUT)) all svc st
while [ "$SECONDS" -lt "$deadline" ]; do
all=1
for svc in "${SERVICES[@]}"; do
st=$(service_state "$svc")
case "$st" in healthy|running) ;; *) all=0 ;; esac
done
[ "$all" = 1 ] && return 0
sleep 3
done
return 1
}
require_release_mode() {
[ -f docker-compose.yml ] || die "docker-compose.yml nicht gefunden (OPNEXUS_DIR setzen?)"
[ -f "$ENV_FILE" ] || die "$ENV_FILE nicht gefunden"
[ -n "$(env_get OPNEXUS_IMAGE_REPO)" ] || die "OPNEXUS_IMAGE_REPO ist in $ENV_FILE nicht gesetzt — dieses Kommando arbeitet mit fertigen Release-Images (lokale Builds: git pull && docker compose up -d --build)."
}
cmd_status_porcelain() {
require_release_mode
echo "repo=$(env_get OPNEXUS_IMAGE_REPO)"
echo "version_setting=$(env_get OPNEXUS_VERSION)"
echo "running_version=$(running_version)"
echo "db_revision=$(db_revision)"
for svc in "${SERVICES[@]}"; do echo "service.$svc=$(service_state "$svc")"; done
if [ -f "$STATE_FILE" ]; then
# Nur bekannte Schlüssel durchlassen — die Datei wird vom Skript selbst geschrieben.
grep -E '^(started|prev_version|prev_setting|prev_db_revision|target)=' "$STATE_FILE" | sed 's/^/last_update./'
fi
}
cmd_status() {
require_release_mode
echo "Image-Repo: $(env_get OPNEXUS_IMAGE_REPO)"
echo "OPNEXUS_VERSION: $(env_get OPNEXUS_VERSION)"
echo "Laufende Version: $(running_version)"
echo "DB-Revision: $(db_revision)"
for svc in "${SERVICES[@]}"; do echo "$svc: $(service_state "$svc")"; done
[ -f "$STATE_FILE" ] && { echo; echo "Letztes Update (für 'rollback'):"; sed 's/^/ /' "$STATE_FILE"; }
return 0
}
cmd_backup() {
local name="manual-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$name"
say "Backup geschrieben: /backups/$name (Volume postgres_backups)"
}
cmd_update() {
require_release_mode
local target="${1:-$(env_get OPNEXUS_VERSION)}"
target="${target:-latest}"
# Landet in .env und in Image-Tags: nur Zeichen, die ein Tag enthalten darf.
[[ "$target" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die "Ungültige Version '$target' (erlaubt: Buchstaben, Ziffern, . _ -)."
local prev_setting prev_version prev_rev started
prev_setting=$(env_get OPNEXUS_VERSION)
prev_version=$(running_version)
[ -n "$prev_version" ] || die "Backend läuft nicht oder antwortet nicht — erst reparieren, dann updaten."
prev_rev=$(db_revision)
say "Aktuell: $prev_version (DB-Revision ${prev_rev:-keine}) — Ziel: $target"
say "Ziehe neue Images (am laufenden System ändert sich dabei nichts) …"
phase pulling
local pull_services=("${SERVICES[@]}")
if updater_enabled; then pull_services+=(updater); fi
OPNEXUS_VERSION="$target" c pull "${pull_services[@]}" || die "Pull fehlgeschlagen — nichts wurde verändert."
started=$(date -u +%Y%m%dT%H%M%SZ)
cat > "$STATE_FILE" <<STATE
started=$started
prev_version=$prev_version
prev_setting=$prev_setting
prev_db_revision=$prev_rev
target=$target
STATE
env_set OPNEXUS_VERSION "$target"
say "Wechsle auf $target (das Backend sichert die Datenbank selbst vor einer Migration) …"
phase switching
local targets=()
mapfile -t targets < <(up_targets)
# `up` selbst scheitert, sobald das Backend nicht gesund wird (depends_on) —
# das ist der Fehlerfall, nicht das Ende des Skripts.
if c up -d --no-build "${targets[@]}" && { phase waiting; wait_healthy; }; then
say "Fertig: läuft jetzt auf $(running_version) (DB-Revision $(db_revision))."
phase done
return 0
fi
printf '\033[31mUpdate nicht gesund geworden.\033[0m Backend-Log:\n' >&2
c logs --tail 25 backend >&2 || true
if [ "${OPNEXUS_NO_AUTO_ROLLBACK:-}" = 1 ]; then
die "Automatisches Zurückrollen ist aus. Manuell: opnexus rollback"
fi
say "Rolle automatisch zurück …"
cmd_rollback
phase rolled_back
printf '\033[31mFehler:\033[0m Update auf %s fehlgeschlagen und zurückgerollt auf %s.\n' "$target" "$prev_version" >&2
exit 3
}
latest_pre_upgrade_dump() {
c exec -T postgres-backup sh -c 'ls -1t /backups/pre-upgrade-*.dump 2>/dev/null | head -n1' | tr -d '\r'
}
cmd_rollback() {
require_release_mode
[ -f "$STATE_FILE" ] || die "Kein Update-Zustand ($STATE_FILE) — es gibt nichts zurückzurollen."
# shellcheck disable=SC1090
. "./$STATE_FILE"
[ -n "${prev_version:-}" ] || die "$STATE_FILE ist unvollständig."
# Rollback target = the exact image tag that was configured before the update.
# The version the backend REPORTS only matches the tag for final releases: an
# rc image reports its final version (rc1 images say 1.11.0), so rolling back
# to that would land on a different, newer image. Floating tags ("latest",
# "1.11") cannot be restored by name, so those fall back to the reported version.
if [[ "${prev_setting:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
prev_version="$prev_setting"
fi
say "Rolle zurück auf $prev_version …"
phase stopping
c stop "${SERVICES[@]}"
local now_rev dump
now_rev=$(db_revision)
if [ "$now_rev" != "$prev_db_revision" ]; then
dump=$(latest_pre_upgrade_dump)
[ -n "$dump" ] || die "Datenbank wurde migriert ($prev_db_revision → $now_rev), aber es gibt kein pre-upgrade-Backup. Backend bleibt gestoppt."
# Nur ein Backup, das nach dem Start DIESES Updates entstanden ist, darf
# zurückgespielt werden — ein älteres würde stillschweigend Daten verwerfen.
dump_ts=$(echo "$dump" | sed -E 's/.*-([0-9]{8})-([0-9]{6})\.dump$/\1\2/')
[ "$dump_ts" -ge "${started//[-:TZ]/}" ] 2>/dev/null \
|| die "Neuestes pre-upgrade-Backup ($dump) ist älter als dieses Update (SKIP_MIGRATION_BACKUP gesetzt?). Backend bleibt gestoppt — manuell eingreifen."
say "Datenbank wurde migriert ($prev_db_revision → $now_rev): spiele $dump zurück."
phase restoring
say "Hinweis: Log-Historie und Ressourcen-Messwerte sind im Backup nicht enthalten und danach leer; alles andere steht auf dem Stand vor dem Update."
# Erst prüfen, dass das Backup lesbar ist — BEVOR irgendetwas gelöscht wird.
pgtool pg_restore --list "$dump" > /dev/null \
|| die "Backup $dump ist nicht lesbar. Datenbank unverändert, Backend bleibt gestoppt."
local safety="pre-rollback-$(date -u +%Y%m%d-%H%M%S).dump"
pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
--exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
-f "/backups/$safety"
say "Sicherheitskopie des jetzigen Zustands: /backups/$safety"
reset_schema() {
pgtool psql -h postgres -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
-c 'DROP SCHEMA public CASCADE' -c 'CREATE SCHEMA public' 2>&1 | grep -v '^NOTICE\|^DETAIL\|^drop cascades' || true
}
reset_schema
if ! pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "$dump"; then
say "Zurückspielen fehlgeschlagen — stelle den Zustand vor dem Rollback aus /backups/$safety wieder her …"
reset_schema
pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "/backups/$safety" \
|| die "Auch die Wiederherstellung schlug fehl. Backups liegen in /backups ($dump, $safety). Backend bleibt gestoppt."
die "Rollback der Datenbank fehlgeschlagen; Zustand vor dem Rollback wiederhergestellt. Backend bleibt gestoppt."
fi
else
say "Datenbank-Revision unverändert ($now_rev) — kein Zurückspielen nötig."
fi
env_set OPNEXUS_VERSION "$prev_version"
phase switching
local rb_targets=()
mapfile -t rb_targets < <(up_targets)
c up -d --no-build "${rb_targets[@]}" || true
phase waiting
wait_healthy || die "Nach dem Rollback sind nicht alle Dienste gesund — 'docker compose logs' prüfen."
say "Fertig: läuft wieder auf $(running_version) (DB-Revision $(db_revision)). OPNEXUS_VERSION in $ENV_FILE ist jetzt fest auf $prev_version gesetzt."
rm -f "$STATE_FILE"
phase done
}
# Setzt ein neues Zufallspasswort und gibt es einmalig aus. Gedacht für die
# frische Installation (das beim ersten Start erzeugte Passwort steht nur
# einmal im Log) und für ein vergessenes Admin-Passwort. Alle Sitzungen des
# Kontos enden. Rückfrage nur, wenn sich schon jemand angemeldet hat.
cmd_password() {
local user="" yes=0 arg
for arg in "$@"; do
case "$arg" in
--yes|-y) yes=1 ;;
-*) die "Unbekannte Option: $arg" ;;
*) [ -z "$user" ] || die "Nur ein Benutzername erlaubt."; user="$arg" ;;
esac
done
c ps --status running --services 2>/dev/null | grep -qx backend \
|| die "Das Backend läuft nicht. Erst starten: docker compose up -d"
if [ "$yes" != 1 ]; then
local logins; logins="$(psql_db -c 'SELECT count(*) FROM admin_users WHERE last_login_at IS NOT NULL' 2>/dev/null || echo 1)"
if [ "$logins" != 0 ]; then
[ -t 0 ] || die "Ohne Terminal nur mit --yes."
read -r -p "Passwort von ${user:-dem Admin} neu setzen? Alle Sitzungen enden. [j/N] " answer
case "$answer" in j|J|y|Y) ;; *) die "Abgebrochen." ;; esac
fi
fi
c exec -T backend python -m app.reset_password ${user:+"$user"}
}
case "${1:-}" in
password) shift; cmd_password "$@" ;;
status) if [ "${2:-}" = "--porcelain" ]; then cmd_status_porcelain; else cmd_status; fi ;;
update) shift; cmd_update "${1:-}" ;;
rollback) cmd_rollback ;;
backup) cmd_backup ;;
*) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""OPNexus-Updater: kleine interne HTTP-API vor dem `opnexus`-Skript.
Läuft als eigener, optionaler Container (Compose-Profil "updater") und ist der
EINZIGE Teil von OPNexus, der den Docker-Socket sieht — das Backend bekommt ihn
nie. Er kann bewusst nur drei Dinge:
GET /status Zustand der Installation und des letzten/laufenden Auftrags
POST /update {"version": "X.Y.Z"} auf eine neuere stabile Version wechseln
POST /rollback das letzte Update rückgängig machen
und es gibt keinen Weg, einen eigenen Befehl auszuführen. Erreichbar ist er nur
aus dem Compose-Netz (kein veröffentlichter Port) und nur mit dem Token, den er
beim ersten Start selbst erzeugt und in sein Zustands-Volume schreibt (das
Backend hängt dasselbe Volume schreibgeschützt ein).
Die eigentliche Arbeit — pullen, wechseln, auf "gesund" warten, bei Fehler
zurückrollen, Datenbank sichern/zurückspielen — macht dasselbe Skript wie auf
der Kommandozeile (`updater/opnexus`), damit es nur eine Update-Logik gibt.
"""
import hmac
import json
import os
import re
import secrets
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
STATE_DIR = Path(os.environ.get("UPDATER_STATE_DIR", "/state"))
PROJECT_DIR = Path(os.environ.get("OPNEXUS_DIR", "/project"))
SCRIPT = os.environ.get("OPNEXUS_SCRIPT", "/usr/local/bin/opnexus")
PORT = int(os.environ.get("UPDATER_PORT", "8080"))
# Wie in einem Docker-Tag, aber ohne Vorabversionen: nur X.Y.Z wird akzeptiert.
VERSION_RE = re.compile(r"^(\d{1,4})\.(\d{1,5})\.(\d{1,6})$")
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
MAX_BODY = 1024
LOG_TAIL_LINES = 150
LOG_TAIL_BYTES = 16_000
STATUS_CACHE_SECONDS = 3.0
TOKEN_FILE = STATE_DIR / "token"
JOB_FILE = STATE_DIR / "job.json"
LOG_FILE = STATE_DIR / "job.log"
PHASE_FILE = STATE_DIR / "job.phases"
_lock = threading.Lock() # genau ein Auftrag zur Zeit
_status_cache: dict = {"at": 0.0, "value": None}
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def parse_version(value: str | None):
match = VERSION_RE.match((value or "").strip())
return tuple(int(part) for part in match.groups()) if match else None
def read_env_file() -> dict[str, str]:
values: dict[str, str] = {}
try:
for line in (PROJECT_DIR / ".env").read_text(encoding="utf-8").splitlines():
if "=" in line and not line.lstrip().startswith("#"):
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
except OSError:
pass
return values
def load_token() -> str:
"""Token einmalig erzeugen; danach immer aus der Datei lesen."""
STATE_DIR.mkdir(parents=True, exist_ok=True)
try:
existing = TOKEN_FILE.read_text(encoding="utf-8").strip()
if len(existing) >= 32:
return existing
except OSError:
pass
token = secrets.token_urlsafe(32)
tmp = TOKEN_FILE.with_suffix(".tmp")
tmp.write_text(token + "\n", encoding="utf-8")
os.chmod(tmp, 0o644) # Zugriff regelt, welche Container das Volume sehen
tmp.replace(TOKEN_FILE)
return token
def read_job() -> dict:
try:
return json.loads(JOB_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def write_job(job: dict) -> None:
tmp = JOB_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(job), encoding="utf-8")
tmp.replace(JOB_FILE)
def log_tail() -> list[str]:
try:
raw = LOG_FILE.read_bytes()[-LOG_TAIL_BYTES:].decode("utf-8", errors="replace")
except OSError:
return []
lines = [ANSI_RE.sub("", line).rstrip() for line in raw.splitlines()]
return [line for line in lines if line][-LOG_TAIL_LINES:]
def phases() -> list[str]:
try:
return [line.strip() for line in PHASE_FILE.read_text(encoding="utf-8").splitlines() if line.strip()]
except OSError:
return []
def script_env(extra: dict[str, str] | None = None) -> dict[str, str]:
env = dict(os.environ)
env.update({"OPNEXUS_DIR": str(PROJECT_DIR), "OPNEXUS_UPDATER": "1"})
env.update(extra or {})
return env
def live_status() -> dict:
"""`opnexus status --porcelain`, kurz zwischengespeichert (docker exec ist nicht gratis)."""
cached = _status_cache["value"]
if cached is not None and time.monotonic() - _status_cache["at"] < STATUS_CACHE_SECONDS:
return cached
env_file = read_env_file()
result: dict = {
"mode": "release" if env_file.get("OPNEXUS_IMAGE_REPO") else "local",
"version_setting": env_file.get("OPNEXUS_VERSION") or None,
"running_version": None,
"db_revision": None,
"services": {},
"last_update": None,
}
if result["mode"] == "release":
try:
proc = subprocess.run(
[SCRIPT, "status", "--porcelain"], cwd=PROJECT_DIR, env=script_env(),
capture_output=True, text=True, timeout=45,
)
except (OSError, subprocess.TimeoutExpired):
proc = None
if proc is not None and proc.returncode == 0:
last: dict[str, str] = {}
for line in proc.stdout.splitlines():
key, _, value = line.partition("=")
if key == "running_version":
result["running_version"] = value or None
elif key == "db_revision":
result["db_revision"] = value or None
elif key.startswith("service."):
result["services"][key.split(".", 1)[1]] = value
elif key.startswith("last_update."):
last[key.split(".", 1)[1]] = value
if last:
result["last_update"] = {
"prev_version": last.get("prev_setting") if parse_version(last.get("prev_setting")) else last.get("prev_version"),
"prev_db_revision": last.get("prev_db_revision") or None,
"target": last.get("target"),
"started": last.get("started"),
}
_status_cache.update(at=time.monotonic(), value=result)
return result
def status_payload() -> dict:
job = read_job()
running = job.get("state") == "running"
payload = {"job": job or None, "phases": phases() if job else [], "log": log_tail() if job else []}
if running:
# Während eines Auftrags kann das Backend gerade fehlen — nicht anfragen.
payload.update(mode="release", running_version=None, db_revision=None, services={}, last_update=None,
rollback_available=False, rollback_loses_data=False)
return payload
live = live_status()
last = live["last_update"]
payload.update(live)
payload["rollback_available"] = bool(last and last.get("prev_version"))
payload["rollback_loses_data"] = bool(last and last.get("prev_db_revision") != live["db_revision"])
return payload
class Reject(Exception):
def __init__(self, code: str, status: int = 409):
super().__init__(code)
self.code, self.status = code, status
def start_job(action: str, target: str | None) -> dict:
if not _lock.acquire(blocking=False):
raise Reject("busy")
try:
if read_job().get("state") == "running":
raise Reject("busy")
_status_cache["value"] = None
live = live_status()
if live["mode"] != "release":
raise Reject("not_release_mode")
if action == "update":
current = parse_version(live["running_version"])
wanted = parse_version(target)
if wanted is None:
raise Reject("invalid_version", 400)
if current is None:
raise Reject("backend_unreachable")
if wanted <= current:
raise Reject("not_newer")
if wanted[0] != current[0]:
raise Reject("major_change")
elif action == "rollback":
last = live["last_update"]
if not (last and last.get("prev_version")):
raise Reject("no_rollback")
job = {
"id": secrets.token_hex(8), "action": action, "target": target, "state": "running",
"started_at": now_iso(), "finished_at": None, "exit_code": None,
"from_version": live["running_version"],
}
for path in (LOG_FILE, PHASE_FILE):
path.write_text("", encoding="utf-8")
write_job(job)
except BaseException:
_lock.release()
raise
# Ab hier gehört das Lock dem Worker-Thread.
threading.Thread(target=_run_job, args=(job,), daemon=True).start()
return job
def _run_job(job: dict) -> None:
args = [SCRIPT, "update", job["target"]] if job["action"] == "update" else [SCRIPT, "rollback"]
env = script_env({"OPNEXUS_PHASE_FILE": str(PHASE_FILE), "TERM": "dumb"})
try:
with LOG_FILE.open("ab") as log:
proc = subprocess.Popen(args, cwd=PROJECT_DIR, env=env, stdout=log, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, start_new_session=True)
code = proc.wait()
except OSError as exc:
code = 127
with LOG_FILE.open("a", encoding="utf-8") as log:
log.write(f"Updater: Skript konnte nicht gestartet werden: {exc}\n")
if code == 0:
state = "succeeded"
elif code == 3 and job["action"] == "update":
state = "rolled_back"
else:
state = "failed"
job.update(state=state, exit_code=code, finished_at=now_iso())
write_job(job)
_status_cache["value"] = None
try:
if state == "succeeded" and job["action"] == "update":
self_update()
finally:
_lock.release()
def _docker(*args: str, timeout: int = 120) -> subprocess.CompletedProcess:
return subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout,
cwd=PROJECT_DIR, env=script_env())
def self_update() -> None:
"""Den Updater-Container selbst auf das neue Image bringen.
Läuft in einem eigenen, kurzlebigen Container: Würde der Updater sich aus
sich selbst heraus neu erstellen, würde `docker compose` mitten im Vorgang
mit ihm beendet. Das Ergebnis des Auftrags ist zu diesem Zeitpunkt längst
gespeichert; schlägt dieser Schritt fehl, läuft der Updater einfach in der
alten Version weiter und wird beim nächsten `docker compose up -d` erneuert.
"""
def note(text: str) -> None:
with LOG_FILE.open("a", encoding="utf-8") as log:
log.write(text + "\n")
try:
me = os.environ.get("HOSTNAME", "")
inspect = _docker("inspect", me, "--format",
'{{.Config.Image}}|{{range .Mounts}}{{if eq .Destination "' + str(PROJECT_DIR) + '"}}{{.Source}}{{end}}{{end}}')
current_image, _, host_dir = inspect.stdout.strip().partition("|")
if inspect.returncode != 0 or not host_dir.startswith("/"):
note("Updater: eigenes Verzeichnis auf dem Host nicht ermittelbar — Updater-Container nicht erneuert.")
return
images = _docker("compose", "config", "--images").stdout.split()
new_image = next((i for i in images if "/opnexus-updater:" in i or i.startswith("opnexus-updater:")), "")
if not new_image or new_image == current_image:
return
_docker("rm", "-f", "opnexus-updater-self-update")
started = _docker(
"run", "-d", "--rm", "--name", "opnexus-updater-self-update",
"-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", f"{host_dir}:{host_dir}", "-w", host_dir,
"--entrypoint", "docker", new_image, "compose", "up", "-d", "--no-build", "--no-deps", "updater",
)
note("Updater: erneuert sich selbst auf " + new_image if started.returncode == 0
else "Updater: Selbst-Update fehlgeschlagen: " + started.stderr.strip()[:300])
except (OSError, subprocess.TimeoutExpired) as exc:
note(f"Updater: Selbst-Update fehlgeschlagen: {exc}")
class Handler(BaseHTTPRequestHandler):
server_version = "opnexus-updater"
token = ""
def log_message(self, fmt, *args): # Zugriffsprotokoll auf stderr, ohne Query/Header
sys.stderr.write("updater: %s %s\n" % (self.command, self.path.split("?", 1)[0]))
def _send(self, status: int, body: dict) -> None:
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def _authorized(self) -> bool:
header = self.headers.get("Authorization", "")
supplied = header[7:] if header.startswith("Bearer ") else ""
# Ein leeres Token darf nie "passen" (leerer Header == leeres Token).
return bool(self.token) and hmac.compare_digest(supplied.encode(), self.token.encode())
def _body(self) -> dict:
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
raise Reject("bad_request", 400)
if length > MAX_BODY:
raise Reject("bad_request", 413)
if length == 0:
return {}
try:
data = json.loads(self.rfile.read(length))
except ValueError:
raise Reject("bad_request", 400)
if not isinstance(data, dict):
raise Reject("bad_request", 400)
return data
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/health":
return self._send(200, {"ok": True})
if not self._authorized():
return self._send(401, {"error": "unauthorized"})
if path == "/status":
return self._send(200, status_payload())
self._send(404, {"error": "not_found"})
def do_POST(self):
path = self.path.split("?", 1)[0]
if not self._authorized():
return self._send(401, {"error": "unauthorized"})
try:
body = self._body()
if path == "/update":
version = body.get("version")
job = start_job("update", version if isinstance(version, str) else None)
elif path == "/rollback":
job = start_job("rollback", None)
else:
return self._send(404, {"error": "not_found"})
except Reject as rejected:
return self._send(rejected.status, {"error": rejected.code})
self._send(202, {"job": job})
def mark_interrupted() -> None:
"""Ein Auftrag, der beim Start noch als "running" steht, hat nie geendet."""
job = read_job()
if job.get("state") == "running":
job.update(state="failed", finished_at=now_iso())
write_job(job)
with LOG_FILE.open("a", encoding="utf-8") as log:
log.write("Updater: wurde während des Auftrags neu gestartet — Zustand bitte prüfen (opnexus status).\n")
def main() -> None:
Handler.token = load_token()
mark_interrupted()
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.daemon_threads = True
print(f"updater: listening on :{PORT}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+215
View File
@@ -0,0 +1,215 @@
"""Tests für updater/server.py — nur Standardbibliothek:
python3 -m unittest discover -s updater -p 'test_*.py'
Das echte `opnexus`-Skript und Docker werden durch ein Fake-Skript ersetzt, das
`status --porcelain` beantwortet und `update`/`rollback` mit einem vorgegebenen
Exit-Code beendet; so sind Zustandsübergänge, Ablehnungen und die Authentifizierung
prüfbar, ohne einen Docker-Daemon.
"""
import json
import os
import stat
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
import server
FAKE = """#!/bin/sh
case "$1" in
status)
echo "running_version=${FAKE_RUNNING:-1.14.0}"
echo "db_revision=0004"
echo "service.backend=healthy"
if [ -n "$FAKE_LAST" ]; then
echo "last_update.prev_version=1.13.0"
echo "last_update.prev_setting=1.13.0"
echo "last_update.prev_db_revision=${FAKE_PREV_REV:-0004}"
fi
;;
update|rollback)
[ -z "$OPNEXUS_PHASE_FILE" ] || echo pulling >> "$OPNEXUS_PHASE_FILE"
echo "arbeite $1 $2"
sleep "${FAKE_SLEEP:-0.2}"
exit "${FAKE_EXIT:-0}"
;;
esac
"""
class UpdaterTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
(root / "state").mkdir()
(root / "project").mkdir()
(root / "project" / ".env").write_text("OPNEXUS_IMAGE_REPO=reg.test/opnexus\nOPNEXUS_VERSION=1.14.0\n")
script = root / "opnexus"
script.write_text(FAKE)
script.chmod(script.stat().st_mode | stat.S_IEXEC)
state = root / "state"
server.STATE_DIR = state
server.TOKEN_FILE = state / "token"
server.JOB_FILE = state / "job.json"
server.LOG_FILE = state / "job.log"
server.PHASE_FILE = state / "job.phases"
server.PROJECT_DIR = root / "project"
server.SCRIPT = str(script)
server._status_cache.update(at=0.0, value=None)
for key in ("FAKE_EXIT", "FAKE_RUNNING", "FAKE_LAST", "FAKE_PREV_REV", "FAKE_SLEEP"):
os.environ.pop(key, None)
self.token = server.load_token()
server.Handler.token = self.token
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
self.httpd.daemon_threads = True
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
deadline = time.time() + 5
while server._lock.locked() and time.time() < deadline:
time.sleep(0.05)
self.tmp.cleanup()
def call(self, method, path, body=None, token="__default__"):
token = self.token if token == "__default__" else token
headers = {"Content-Type": "application/json"}
if token is not None:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(self.base + path, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as err:
return err.code, json.loads(err.read())
def wait_done(self):
deadline = time.time() + 10
while time.time() < deadline:
_, body = self.call("GET", "/status")
if body["job"] and body["job"]["state"] != "running":
return body
time.sleep(0.1)
self.fail("job did not finish")
# --- Authentifizierung -------------------------------------------------
def test_health_is_public_everything_else_needs_the_token(self):
self.assertEqual(self.call("GET", "/health", token=None)[0], 200)
for token in (None, "", "wrong", self.token[:-1]):
self.assertEqual(self.call("GET", "/status", token=token)[0], 401, token)
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"}, token=token)[0], 401)
self.assertEqual(self.call("POST", "/rollback", {}, token=token)[0], 401)
def test_token_is_generated_once_and_reused(self):
self.assertGreaterEqual(len(self.token), 32)
self.assertEqual(server.load_token(), self.token)
def test_unknown_paths_are_404(self):
self.assertEqual(self.call("GET", "/etc/passwd")[0], 404)
self.assertEqual(self.call("POST", "/exec", {"cmd": "id"})[0], 404)
# --- Status ------------------------------------------------------------
def test_status_reports_version_and_mode(self):
status, body = self.call("GET", "/status")
self.assertEqual(status, 200)
self.assertEqual(body["mode"], "release")
self.assertEqual(body["running_version"], "1.14.0")
self.assertFalse(body["rollback_available"])
def test_local_build_install_is_reported_as_local(self):
(server.PROJECT_DIR / ".env").write_text("POSTGRES_PASSWORD=x\n")
_, body = self.call("GET", "/status")
self.assertEqual(body["mode"], "local")
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"})[1], {"error": "not_release_mode"})
# --- Update ------------------------------------------------------------
def test_update_runs_and_succeeds(self):
status, body = self.call("POST", "/update", {"version": "1.14.1"})
self.assertEqual(status, 202)
self.assertEqual(body["job"]["target"], "1.14.1")
done = self.wait_done()
self.assertEqual(done["job"]["state"], "succeeded")
self.assertIn("pulling", done["phases"])
self.assertTrue(any("arbeite update 1.14.1" in line for line in done["log"]))
def test_failed_update_exit_codes(self):
os.environ["FAKE_EXIT"] = "3"
self.call("POST", "/update", {"version": "1.14.1"})
self.assertEqual(self.wait_done()["job"]["state"], "rolled_back")
server._status_cache.update(at=0.0, value=None)
os.environ["FAKE_EXIT"] = "1"
self.call("POST", "/update", {"version": "1.14.1"})
self.assertEqual(self.wait_done()["job"]["state"], "failed")
def test_invalid_and_disallowed_versions_never_start_a_job(self):
cases = {
"1.14.0": "not_newer", "1.13.9": "not_newer", "2.0.0": "major_change",
"latest": "invalid_version", "1.14.1-rc1": "invalid_version", "1.14": "invalid_version",
"1.14.1; id": "invalid_version", "../1.14.1": "invalid_version", "": "invalid_version",
}
for version, error in cases.items():
status, body = self.call("POST", "/update", {"version": version})
self.assertEqual(body, {"error": error}, version)
self.assertIn(status, (400, 409))
self.assertEqual(self.call("POST", "/update", {})[1], {"error": "invalid_version"})
self.assertEqual(self.call("POST", "/update", {"version": 5})[1], {"error": "invalid_version"})
self.assertIsNone(self.call("GET", "/status")[1]["job"])
def test_second_request_while_running_is_busy(self):
os.environ["FAKE_SLEEP"] = "1.5"
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"})[0], 202)
self.assertEqual(self.call("POST", "/update", {"version": "1.14.2"}), (409, {"error": "busy"}))
self.assertEqual(self.call("POST", "/rollback", {}), (409, {"error": "busy"}))
_, running = self.call("GET", "/status")
self.assertEqual(running["job"]["state"], "running")
self.assertEqual(self.wait_done()["job"]["state"], "succeeded")
def test_oversized_or_malformed_body_is_rejected(self):
req = urllib.request.Request(self.base + "/update", method="POST", data=b"x" * 5000,
headers={"Authorization": "Bearer " + self.token, "Content-Length": "5000"})
with self.assertRaises(urllib.error.HTTPError) as ctx:
urllib.request.urlopen(req, timeout=5)
self.assertEqual(ctx.exception.code, 413)
req = urllib.request.Request(self.base + "/update", method="POST", data=b"{not json",
headers={"Authorization": "Bearer " + self.token})
with self.assertRaises(urllib.error.HTTPError) as ctx:
urllib.request.urlopen(req, timeout=5)
self.assertEqual(ctx.exception.code, 400)
# --- Rollback ----------------------------------------------------------
def test_rollback_needs_a_previous_update(self):
self.assertEqual(self.call("POST", "/rollback", {}), (409, {"error": "no_rollback"}))
def test_rollback_and_data_loss_flag(self):
os.environ["FAKE_LAST"] = "1"
_, body = self.call("GET", "/status")
self.assertTrue(body["rollback_available"])
self.assertFalse(body["rollback_loses_data"]) # Revision unverändert
server._status_cache.update(at=0.0, value=None)
os.environ["FAKE_PREV_REV"] = "0003"
_, body = self.call("GET", "/status")
self.assertTrue(body["rollback_loses_data"])
server._status_cache.update(at=0.0, value=None)
self.assertEqual(self.call("POST", "/rollback", {})[0], 202)
self.assertEqual(self.wait_done()["job"]["state"], "succeeded")
# --- Neustart ----------------------------------------------------------
def test_job_left_running_after_a_restart_is_marked_failed(self):
server.write_job({"id": "x", "action": "update", "target": "1.14.1", "state": "running"})
server.mark_interrupted()
job = server.read_job()
self.assertEqual(job["state"], "failed")
self.assertIn("neu gestartet", server.LOG_FILE.read_text())
if __name__ == "__main__":
unittest.main()