Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die OPNsense-Core-Quellen geprüft und live auf drei Test-Firewalls (26.7.1 und 26.7.6) getestet, siehe Handbuch „Netzwerk, DNS und DHCP“: - **Neuer Bereich „Netzwerk“:** Inventar je Firewall (Schnittstellen mit Adressen und CARP-Zustand, VLANs, virtuelle IPs, Gateways, Routen, DNS-Einträge, DHCP) und Änderungen daran: anlegen, bearbeiten, löschen für VLANs, virtuelle IPs, Gateways, statische Routen, Schnittstellen-Zuweisung samt IP-Konfiguration, Unbound-DNS-Einträge, dnsmasq-Hosts sowie Kea-Subnetze und -Reservierungen. - **Abgesichert:** Jede Änderung läuft über eine Vorschau mit Diff und einem Token, der den Firewall-Stand erfasst. Vor dem Schreiben wird ein Konfig-Backup gespeichert (ohne Backup keine Änderung), danach angewendet und zurückgelesen. OPNexus erkennt, über welche Schnittstelle es die Firewall erreicht; Änderungen, die diese Verbindung kappen können (Verwaltungs-Schnittstelle, Standard-Gateway, Standardroute, Route über die Firewall-Adresse), brauchen eine ausdrückliche Bestätigung mit Eingabe des Firewall-Namens. Es gibt bewusst **keinen** automatischen Rollback: OPNexus kann eine Änderung nicht zurücknehmen, wenn sie seine eigene Verbindung trennt. Das Backup vor der Änderung ist der Rückweg. - **Schnittstellen-Löschen:** OPNsense entfernt dabei selbst alle Regeln, die die Schnittstelle nutzen. Die Vorschau listet sie, die Bestätigung ist Pflicht. Bereits auf der Firewall vorgemerkte Änderungen blockieren eine neue Änderung. - **IP-Konfiguration per API gibt es erst ab OPNsense 26.7.6.** Auf 26.7.1 ist die Zuweisungsliste nur lesbar; OPNexus erkennt das an den Daten und sperrt die Änderung mit Begründung. - **Von der Firewall übernehmen:** Regeln, die direkt in OPNsense angelegt wurden, lassen sich in OPNexus übernehmen, bearbeiten und ausrollen. Die Übernahme schreibt nichts auf die Firewall. Regeln, die das Modell verfälschen würde (invertierte Quelle/Ziel, mehrere Schnittstellen, keine Quick-Regel, …), werden mit Grund abgelehnt. Neu: Regeln haben ein Protokollieren-Flag (`log`, Migration 0003), damit es beim Bearbeiten nicht still verloren geht; nicht modellierte Felder (Gateway, Zustandsoptionen, …) bleiben auf der Firewall unverändert. - **Geheimnisse:** Das CARP-Passwort (liefert OPNsense im Klartext) und der Kea-DDNS-Schlüssel werden nie zurückgegeben, gespeichert oder auditiert. - **Host-Adresse änderbar:** Hosts › Bearbeiten hat jetzt das Feld „Adresse“. Nach einer Änderung der Verwaltungs-IP muss der Host nicht mehr gelöscht und neu angelegt werden; Verlauf und Zuordnungen bleiben erhalten. Meldet eine Änderung „angewendet, aber nicht prüfbar“, weist die Oberfläche darauf hin. - **Fehler aus den Live-Tests, die Mocks nicht gezeigt haben:** Jede Änderung einer virtuellen IP muss die Adresse (`network`) wiederholen, sonst leert OPNsense sie bzw. lehnt ab; die Prüfung nach dem Anwenden vergleicht jetzt den vollständigen Soll-Zustand statt nur der gesendeten Felder. Dazu: Gateway-Priorität als Zahl, Routen mit `enabled` statt `disabled`, Beschreibungsmuster der Schnittstellen, gesperrte Schnittstellen, Pflicht-Backup vor jeder Änderung. - **Hinweis zu den Versionen:** Die IP-Konfiguration von Schnittstellen per API gibt es erst ab OPNsense 26.7.6. Mit 26.7.5 (die Prod-Firewalls) sind VLANs, virtuelle IPs, Gateways, Routen, DNS und DHCP verwaltbar, Schnittstellen nur lesbar.
210 KiB
Handbuch. Ausführliche Feature- und Betriebsdokumentation (Deutsch), chronologisch gewachsen. Kurzüberblick und Schnellstart: README. Verweise auf
docs/verification*unddocs/HANDOFF-*meinen interne Verifikationsnachweise, die nicht veröffentlicht werden.
OPNexus
Zentrale Verwaltung mehrerer OPNsense-Firewalls: Fleet-/HA-Status (CARP MASTER/BACKUP), zentral gepflegte Firewall-Regeln (Push per API statt OPNsense-eigenem HA-Config-Sync), Firewall-Logs.
Eigenständiges Projekt unter /opt/opnexus/ — komplett
getrennt vom bestehenden InfraPanel (eigenes Docker-Compose-Projekt
opnexus, eigenes Netzwerk, eigene Ports, kein Traefik).
Auf einen Blick: Fleet-Dashboard mit offenen Aktionen, Sicherheits- Warnungen und Aktivitäts-Feed · zentrale Firewall-Regeln, Aliase und NAT mit Preview→Deploy→Confirm-Workflow und automatischem Rollback-Fenster · hierarchische Pre-/Post-Policies mit Vererbung · CARP/HA inkl. Ereignis-Historie, Split-Brain-Alarm und optionalem Auto-Revert-Timer · IPsec-, OpenVPN- und WireGuard-VPN-Management · Zertifikats-Trust-Store und ACME/Let's-Encrypt-Ausstellung · zentrale Log-Suche mit Korrelation und Dashboards · lückenloser Deploy-Audit-Verlauf · Fleet-Update-Center inkl. Major-Upgrades und CARP-Wartungsmodus · Rollen (Admin/Nur-Lese), Dark Mode und ein eigenes Versionsschema mit Codenamen (siehe CHANGELOG.md).
Alle Screenshots unten sind mit fiktiven, in der Anwendung selbst erzeugten Beispieldaten aufgenommen (kein Bezug zur echten Produktivflotte) — Details und Verifikationsnachweise zu jedem Feature stehen in den chronologischen Abschnitten weiter unten in diesem Dokument.
Lizenz
OPNexus steht unter der PolyForm Shield License 1.0.0 (siehe LICENSE). Das ist eine source-available-Lizenz, keine Open-Source-Lizenz im Sinne der OSI:
- Erlaubt: kostenlos herunterladen, Quellcode ansehen, für eigene Zwecke anpassen und einsetzen — auch geschäftlich, z. B. in einer Firma für die eigenen Firewalls.
- Nicht erlaubt: OPNexus oder Ableitungen als Produkt oder Dienst anzubieten oder weiterzuverkaufen, das mit OPNexus konkurriert (auch nicht kostenlos).
- Der Lizenzgeber (Julian Boldt) ist an diese Einschränkung nicht gebunden und darf OPNexus selbst vertreiben und verkaufen.
Frühere Stände, die bereits unter der AGPL-3.0 veröffentlicht wurden, bleiben für alle, die sie erhalten haben, unter dieser Lizenz. Diese Zusammenfassung ersetzt nicht den Lizenztext.
Required Notice: Copyright 2026 Julian Boldt (https://opnexus.dev)
Features
Dashboard
Fleet-Startseite (/dashboard, seit 2026-09-14 das Ziel von / und des
Post-Login-Redirects): offene Aktionen (ausstehende Bestätigungen/Fehler
über Regeln, NAT, Policy-Hierarchie und laufende CARP-Wartungsfenster),
Fleet-/CARP-Gesundheit, Sicherheits- und Ablauf-Warnungen (Zertifikate,
Regel-/NAT-Drift, verfügbare Updates) sowie ein Aktivitäts-Feed der letzten
Audit-Ereignisse.
Fleet-Übersicht & Hochverfügbarkeit
Zentrale Liste aller verwalteten OPNsense-Hosts mit Live-Status, CARP-Rolle, Cluster- und Policy-Hierarchie-Zugehörigkeit.
HA-Paare werden automatisch anhand des Cluster-Namens gruppiert; zeigt MASTER/BACKUP-Rollen nebeneinander, markiert Regel- und NAT-Drift zwischen den Knoten, führt eine einsehbare CARP-Ereignis-Historie pro Cluster und alarmiert bei Split-Brain oder fehlendem MASTER. Der CARP-Wartungsmodus kann optional mit einem Auto-Revert-Timer (15/30/60 Minuten oder dauerhaft) versehen werden.
Firewall-Regelwerk
Zentral gepflegte Firewall-Regeln, gezielt auf Hosts oder Gerätegruppen ausgerollt — mit Preview→Deploy→Confirm-Flow, Änderungs-/Deploy-Status pro Ziel-Host und automatischem Rollback-Fenster bei ausbleibender Bestätigung.
Pre-/Post-Rulebases mit Vererbung über Gruppenbäume: eine auf einer übergeordneten Gruppe gepflegte Regel rollt automatisch, in korrekter Reihenfolge, auf alle zugehörigen Hosts aus.
Gerätegruppen bündeln Hosts für gezieltes Deployment von Regeln, Aliasen und NAT — ein Ziel, viele Hosts.
Netzwerk-Objekte (Host/Netz/Port/Gruppe/MAC/ASN) zentral gepflegt, gleicher Deploy-Workflow und gleiche Rollback-Absicherung wie Firewall-Regeln.
Port Forward, 1:1 NAT und Outbound NAT zentral verwaltet, inklusive Live-Ansicht der tatsächlich auf der Firewall aktiven Regeln.
VPN-Management
Site-to-Site-Tunnel mit Pre-Shared-Key-Authentifizierung, zentral gepflegt und auf Ziel-Hosts oder Gerätegruppen ausgerollt.
Server- und Client-Instanzen inklusive optionalem TLS-Auth/TLS-Crypt- Hardening, mit host-lokalem Zertifikat-/CA-Picker pro Ziel-Host.
Server+Peer-Tunnel inklusive optionalem Peer-Pre-Shared-Key; OPNexus bildet OPNsense's denormalisierte Server↔Peer-Verknüpfung korrekt nach.
Zertifikatsverwaltung
Live-Fleet-Überblick sowie zentrale Verwaltung von Trust-Store-Zertifikaten und ACME/Let's-Encrypt-Ausstellung, mit Warngate vor jedem Kontakt zu einer echten Zertifizierungsstelle.
Monitoring
OPNexus ingestiert Firewall-Logs selbst statt sie nur live anzuzeigen: durchsuchbar, filterbar, mit Dashboards für Top-Quellen und -Zielports.
Lückenloser, dauerhaft gespeicherter Audit-Verlauf jeder Anfrage und jedes Deployment-Ergebnisses — bleibt auch nach dem Löschen einer Regel oder eines Hosts erhalten, mit JSON-Export.
Update Center
Fleet-weite Ansicht über den abgesicherten Host-Wartungspfad: automatische Update-Suche, stabile Updates und Major-Upgrades mit eigenem Warngate, Neustart, sowie ein geführter CARP-Ablauf für HA-Updates ohne Verkehrsunterbrechung auf dem falschen Knoten.
Barrierefreiheit (2026-10-04)
Version 1.8.0 „Harris“. Ziel: OPNexus per Tastatur und mit Screenreader vollständig bedienbar, Texte in beiden Themes gut lesbar (WCAG 2.2 AA). Grundlage war ein automatischer axe-core-Lauf über alle 18 Seiten in Hell, Dunkel und Mobil (54 Läufe), der zuerst 192 Kontrast- und 20 Struktur-Treffer fand.
- Kontrast. Die Nebentext-Grautöne
slate-400/slate-500sind im Hellmodus dunkler (jetzt ≥ 4,5:1 auf Weiß, Slate-50 und Slate-100), im Dunkelmodus heller. Links nutzensky-700. Der aktive Hauptmenüpunkt im Dunkelmodus (bg-brand-50, hell auf hell, Kontrast 1,99) hat jetzt einen dunklen Hintergrund. - Tastatur. Globaler
:focus-visible-Ring (nur bei Tastaturbedienung, in beiden Themes ausreichend kontrastreich); „Zum Inhalt springen“ als erstes Tab-Ziel;<main>ist Sprungziel; scrollbare Tabellenkarten sind per Tab erreichbar. Dialoge (Fokusfalle, Esc, Fokus zurück an den Auslöser) und Tabs (Pfeiltasten) waren bereits korrekt und wurden mitgeprüft. - Screenreader. Seitentitel wechseln pro Seite („Regeln · OPNexus“, per
gerendertem
<title>inAuthGate, weil der statische Metadata-Titel sonst zurückspringt). Jede Seite hat genau eineh1(Login und Über-Seite fehlte sie); Karten in Regeln/Aliase/Logs nutzenh2statth3direkt unterh1. Menü:aria-current, Icon-Links mitaria-label, Iconsaria-hidden, Unternavigation als eigene Landmarke. Leere Tabellenköpfe tragen einen nur für Screenreader sichtbaren Text („Aktionen“). Der Host-Status (StatusDot) war fest deutsch und folgt jetzt der Sprache. - Prüfen.
scripts/verify_a11y.mjswiederholt den axe-Lauf (nur lesend: schreibende Anfragen werden im Browser lokal beantwortet, das Theme wird nur in der Antwort überschrieben). Aufruf siehe Skriptkopf; Exit-Code ≠ 0 bei Verstößen.
Verifikation: axe-core (WCAG 2.0/2.1/2.2 A + AA, Best Practices) auf allen
18 Seiten, Hell/Dunkel bei 1440 px und Hell bei 375 px: 0 Verstöße. Zusätzlich
von Hand im Browser: erster Tab = Sprunglink, Enter springt zum Inhalt,
Seitentitel wechseln, Dialog hält den Fokus nach 30 Tab-Drücken, Esc schließt
und gibt den Fokus zurück, Command-Palette ohne Verstöße; keine
Konsolenfehler. tsc --noEmit und check_i18n.py sauber. Nicht abgedeckt:
Test mit einem echten Screenreader (NVDA/VoiceOver) und Zustände, die erst nach
Schreibaktionen sichtbar werden (Review-Dialoge nach Push).
Sichere Übertragung: HTTPS-Proxy, Zertifikats-Pinning, Zwei-Faktor (2026-10-04)
Version 1.7.0 „Goshawk“. Ziel: Passwort, Session und Firewall-Zugangsdaten laufen nie unverschlüsselt oder unauthentisiert über das Netz — auch in einem Netz ohne DNS, in dem OPNexus und die Firewalls nur per IP angesprochen werden.
Architektur. Ein mitgelieferter Caddy-Proxy (proxy/Caddyfile, Service
proxy) ist der einzige Dienst, der im Netz lauscht (Ports 80/443). Backend
und Frontend sind nur noch an 127.0.0.1 gebunden. Der Browser spricht die API
unter demselben Host an (/api), daher funktionieren IP und Namen gleich und es
gibt kein CORS zwischen Ursprüngen.
Zertifikate ohne DNS. Caddy stellt Zertifikate aus einer eigenen internen
CA aus — auch für reine IP-Adressen. Die Root-CA lädst du einmal unter
https://<ip>/opnexus-ca.crt herunter und importierst sie in Browser/System
(Firefox: Einstellungen → Zertifikate → Zertifizierungsstellen → Importieren;
Windows/macOS: Zertifikat als vertrauenswürdige Stammzertifizierungsstelle
installieren). Bis dahin zeigt der Browser eine Warnung, die Verbindung ist
trotzdem verschlüsselt.
Root-CA vor dem Import prüfen. Die Verschlüsselung allein beweist nicht, dass die zuerst heruntergeladene Datei echt ist — wer auf dem Weg dazwischen sitzt, könnte eine eigene CA unterschieben. Vergleiche vor dem Import den Fingerabdruck, den du auf dem OPNexus-Server selbst ermittelst, mit dem der heruntergeladenen Datei:
# auf dem Server
docker compose exec -T proxy cat /data/caddy/pki/authorities/local/root.crt \
| openssl x509 -noout -fingerprint -sha256
# auf dem Client, für die heruntergeladene Datei
openssl x509 -in opnexus-root-ca.crt -noout -fingerprint -sha256
Nur bei identischem SHA-256-Fingerabdruck importieren (oder die CA stattdessen über eure Unternehmens-/MDM-Verteilung ausrollen).
Weitere Adressen (IP und Name). In .env:
OPNEXUS_SITES=10.0.0.5, opnexus.lan # alle Adressen, unter denen OPNexus erreichbar sein soll
OPNEXUS_DEFAULT_SNI=10.0.0.5 # die primäre (Browser senden bei reiner IP keinen Namen)
FRONTEND_ORIGIN=https://10.0.0.5,https://opnexus.lan
COOKIE_SECURE=true
TRUST_PROXY_HEADERS=true
BIND_ADDRESS=127.0.0.1
Danach docker compose up -d. Ein Name ohne DNS braucht auf dem Client einen
Hosts-Datei-Eintrag. Backup-Hinweis: Der Schlüssel der CA liegt im Volume
caddy_data. Geht es verloren, entsteht eine neue CA und alle Clients müssen sie
erneut importieren.
Firewall-Verbindungen: Zertifikat festsetzen (Pinning). Firewalls haben meist
ein selbstsigniertes Zertifikat, dessen Name nicht zur genutzten Adresse passt.
Statt die Prüfung abzuschalten, setzt OPNexus pro Host das Zertifikat fest: Hosts
→ Bearbeiten → „Zertifikat abrufen“ zeigt den SHA-256-Fingerprint; nach dem
Vergleich mit OPNsense (System → Vertrauen → Zertifikate) bestätigen. Danach
vertraut OPNexus ausschließlich diesem Zertifikat, bevor Zugangsdaten gesendet
werden — unabhängig davon, ob der Host per IP oder Name angesprochen wird
(tls_pin_service.py). Erneuert die Firewall ihr Zertifikat, meldet der Host
„Zertifikat passt nicht zum festgesetzten“; dann erneut abrufen und bestätigen.
Beide produktiven Firewalls wurden am 2026-10-04 festgesetzt; sie präsentieren
dasselbe Zertifikat (HA-Sync), gültig bis 2027-07-18. Das Festsetzen ist
„Trust on first use“ — der Fingerprint-Vergleich ist der Schritt, der es absichert.
Zwei-Faktor-Anmeldung (optional, pro Benutzer). Einstellungen →
Zwei-Faktor-Anmeldung: QR-Code scannen, Code bestätigen, 10 einmalige
Wiederherstellungscodes sichern. TOTP nach RFC 6238 ohne Zusatzbibliothek; das
Geheimnis liegt als Datei (0600) im Volume totp_secrets, nicht in der
Datenbank; ein einmal akzeptierter Code kann nicht wiederverwendet werden; falsche
Codes zählen für die Login-Sperre. Das Passwort wird beim Einrichten erneut
verlangt. Ein Admin kann das 2FA anderer Benutzer zurücksetzen (Benutzerliste);
für den letzten Admin gibt es auf dem Server:
docker compose exec backend python -m app.reset_2fa <benutzername>.
Weitere Härtung.
Secure-/HttpOnly-/SameSite=Lax-Cookie; zusätzlich lehnt das Backend jede schreibende Anfrage ab, derenOrigin/Refererweder derselbe Host noch inFRONTEND_ORIGINgelistet ist (security_middleware.py).- Header über den Proxy: HSTS,
Content-Security-Policy(nur eigener Ursprung;'unsafe-inline'für Next.js-Hydration und React-Styles ist die bekannte Einschränkung),X-Frame-Options: DENY,nosniff,Referrer-Policy,Permissions-Policy, COOP/CORP;Server/X-Powered-Byentfernt. API-Antworten sindCache-Control: no-store. - Login-Sperre nutzt über den Proxy die echte Client-IP (
TRUST_PROXY_HEADERS). - Abhängigkeiten:
pip-auditmeldet keine bekannten Lücken.npm auditmeldet (Stand 2026-10-07) noch Funde nur in Build-Werkzeugen (braces,postcss-selector-parserüber Tailwind 3/ESLint; fürbracesexistiert keine gepatchte Version), die nicht im Laufzeit-Image liegen. Der Stand ändert sich täglich — vor einem Release neu prüfen (npm audit,pip-audit). - Sicherheitsüberprüfung 2026-10-07: umgesetzte Korrekturen siehe Changelog (1.11.0).
Bekannte Grenzen. Kein IP-Allowlisting, keine Passkeys/WebAuthn; die Session-Lebensdauer beträgt weiter 30 Tage. Das Zertifikat der Firewall wird beim Festsetzen einmalig ohne Vorab-Vertrauen abgerufen.
Verifikation: 402 Backend-Tests (inkl. echtem lokalem TLS-Server für Pinning
per IP und per Name, Origin-Prüfung, 2FA-Ablauf); Browser-Ende-zu-Ende über
https://<ip> und über einen Namen ohne DNS (--host-resolver-rules): Login,
2FA-Einrichtung, falscher Code, Recovery-Code, Cookie Secure/HttpOnly;
mit der CA verifizierte TLS-Verbindung per IP; Ports 3100/8100 von außen
geschlossen; beide Firewalls nach dem Festsetzen online.
Bedienbarkeit: Hinweise als Tooltips, ruhigere Listen, Log-Speicherlimit (2026-10-04)
Version 1.6.0 „Peregrine“. Ziel: weniger Text auf dem Bildschirm, klarere Aktionen, dieselben Sicherheitsschranken.
- Tooltips statt Textblöcken. Neue Komponente
components/ui/Tooltip.tsx(Tooltip,InfoTip): erscheint bei Hover, Tastaturfokus und Tippen, schließt mit Esc, wird nie von Karten oder Dialogen abgeschnitten und funktioniert auch auf deaktivierten Buttons. Seitenintros sind ein Info-Icon am Titel;FieldGrouphat einhint-Feld. - Update Center. Die Sperrgrund-Kästen sind weg: Warum „Update vorbereiten“ gesperrt ist, steht im Tooltip des Buttons (mit Schloss-Symbol, per Tab erreichbar). Erfolgsmeldungen erscheinen als Toast. Die Bestätigung (Hostname eintippen + Häkchen) bleibt unverändert, ist aber kürzer.
- Dialoge. CARP-Umschaltung zeigt die Rollen als
MASTER → BACKUPstatt eines Textblocks; Host-Wartung, Restore-Wizard (jetzt ein Schritt, Warnung bleibt sichtbar) und Review-Dialoge folgen demselben Muster. Dialoge halten den Tastaturfokus und geben ihn beim Schließen zurück. - Listen. Pro Zeile eine Hauptaktion („Prüfen & Push“), Bearbeiten als Outline-Button, Löschen dezent in Rot, Verlauf/Duplizieren als Icon mit Tooltip; Deploy-Ergebnisse stehen in einer Zeile.
- Host-Detail. Live-Regeln und Live-Aliase sind einklappbar (mit Anzahl); die Seite ist von ca. 10.900 px auf ca. 2.300 px geschrumpft.
- Log-Speicherlimit. Einstellungen → Log-Erfassung: „Max. Speicher (GB)“
(Standard 10, 0 = unbegrenzt). Ist es erreicht, löscht OPNexus die ältesten
Einträge (
log_ingest_service.enforce_size_limit, im bestehenden Prune-Zyklus alle 20 Polls). Die Größe ist eine Schätzung aus der Tabellengröße, skaliert mit dem Anteil lebender Zeilen, weil Postgres Platz nach DELETE nicht ans Dateisystem zurückgibt, sondern wiederverwendet. Neue Spaltelog_settings.max_size_gb(viaschema_patches.py);GET /api/logs/settingsliefert zusätzlichused_bytes,entries,oldest_at. Die Zahl in den Einstellungen („gespeichert“) zählt die ganze Tabelle, die Zahl auf der Logs-Seite („Treffern“) nur den aktuellen Filter. - UI-Korrekturen. Karten in Listen haben wieder Abstand (
space-ywirkt nicht überfieldset.contents, daherflex flex-col gap-*);Input/Selectbehalten ihre Standardstyles auch mit eigenerclassName; alle Buttons sind gleich hoch; Zahlen in den Logs haben Tausendertrennung; das Dashboard zeigt lesbare Aktionsnamen.
Verifikation: Backend-Tests 383 bestanden (inkl. neuer Tests für
enforce_size_limit und die Einstellungen); Browserprüfung bei 1440 px und
375 px auf 16 Seiten ohne Konsolenfehler und ohne horizontalen Überlauf, mit
GET-only-Routing (jede schreibende Anfrage lokal abgefangen, keine
Firewall-Änderung).
Mehrsprachigkeit (Deutsch/Englisch)
Mehrsprachigkeit vollständig shipped 2026-09-19 (v1.5.0 „Osprey“).
Deutsch bleibt Standard und Fallback; Englisch kann pro Benutzer unter
/settings gewählt werden. Die Auswahl wird sofort in localStorage
(opnexus-language) angewendet, in user_preferences.language persistiert,
zwischen Tabs synchronisiert und vor der React-Hydration auf <html lang>
gesetzt. Ein Reload oder eine neue Anmeldung übernimmt anschließend den
DB-Wert. Die Lösung verwendet bewusst keine URL-Locale und keine zusätzliche
i18n-Abhängigkeit, sondern den kleinen LanguageProvider und die DE/EN-Kataloge
unter frontend/lib/i18n/ (de.json/en.json, ca. 1.400 Schlüssel).
- Oberfläche: alle Seiten und Komponenten laufen über
t("bereich.schlüssel")(Platzhalter als{name}). Datums- und Zeitangaben folgen der Sprache (de-DE/en-GB, überuseLanguage().locale). Gemeinsame Bausteine (Regel-/NAT-/Alias-Karten, Review-Dialoge, Wartungsdialoge) teilen sich ihre Schlüssel inrules.*,review.*,maint.*,fields.*. - API-Fehler bleiben sprachneutral:
detail: {code, params}, 118 Codes, beide Kataloge decken alle ab (errors.<CODE>). Neu seit dieser Version: die letzten rohenHTTPExceptions inservices/sowie die Middleware-Antworten (401NICHT_AUTHENTIFIZIERT, 403READ_ONLY_ACCESS) liefern ebenfalls Codes. - Deutsche Fließtexte in Erfolgsantworten (Vorschau-Warnungen, Wartungs- und
Deploy-Meldungen, Revisionsnotizen, Blocker-Listen, Pydantic-Validierungstexte)
erzeugt das Backend weiterhin auf Deutsch. Der Client übersetzt bekannte Texte
mit
translateServerText()anhand der Tabelle infrontend/lib/i18n/serverMessages.ts(ganze Sätze, Muster mit Platzhaltern, zusammengesetzte Strings). Unbekannte Texte erscheinen unverändert; wer im Backend einen neuen deutschen Satz einführt, ergänzt dort den englischen Text. - Bewusst nicht übersetzt: Server-Logs, ausgehende Benachrichtigungen (Webhook/E-Mail), Antworttexte der OPNsense-Firewalls selbst, Namen aus Firewall-Daten (Aliase, Regelbeschreibungen) und die deutschen Suchbegriffe der Befehlspalette (sie bleiben zusätzlich zu den englischen Seitentiteln nutzbar).
- Prüfwerkzeuge:
python3 scripts/check_i18n.py(Katalog-Parität, Platzhalter, Abdeckung allerErrorCodes, referenzierte Schlüssel) undscripts/verify_i18n.mjs(Browser: Umschalten über die Einstellungen, alle lesenden Seiten samt „Neu“-Dialogen auf deutsche Restwörter prüfen, 375 px auf horizontalen Überlauf; schreibende Requests werden abgefangen;/updateswird ausgelassen, weil das Öffnen dort Update-Suchen auf den Firewalls startet). Das Skript brauchtOPNEXUS_TEST_PASSWORDund alsOPNEXUS_BASE_URLden inFRONTEND_ORIGINkonfigurierten Origin (sonst blockiert die CORS-Freigabe die Anmeldung).
Verifiziert 2026-09-19: DE→EN→DE mit DB-Persistenz und Reload; 17 Seiten und
alle „Neu“-Dialoge im Englischen ohne deutsche Restwörter (Treffer nur aus
Firewall-Daten); englischer Fehlertext bei unbekanntem Host; Update Center inkl.
Blocker-Meldungen, Update-/Major-/CARP-Dialogen im Englischen über die
gemockten Fixtures; 375 px ohne horizontalen Überlauf (dabei einen bestehenden
Überlauf im Dashboard behoben: Grid-Karten ohne min-w-0); keine Browser-
Konsolenfehler. Backend-Suite 378 Tests grün, tsc --noEmit und
next build sauber. Die Spalte user_preferences.language entsteht über den
append-only Schema-Patch.
Zweite Härtungsrunde: Fehlerinfo-Leckage, Healthchecks, DB-Backup, Log-Retention (2026-09-17)
Fortsetzung der Härtung unten, ausgelöst durch das Live-Rollout dieser Härtung selbst — mehrere Punkte wurden erst beim tatsächlichen Deployment gegen die echte Produktivinstanz sichtbar, nicht am Schreibtisch:
- Keine rohen OPNsense-Fehlertexte mehr in 502-Antworten:
str(exc)enthielt die komplette Roh-Antwort der Ziel-Firewall (inkl. interner Details) und ging 1:1 an jeden angemeldeten Nutzer (auchread_only) raus. Neuer_upstream_error()-Helper loggt das volle Detail serverseitig (jetzt dank der Logging-Härtung unten auch sichtbar) und gibt nur noch eine generische Meldung an den Client. - Healthchecks für Backend/Frontend ergänzt (bisher nur Postgres) —
dabei einen echten Bug gefunden: Docker setzt automatisch
HOSTNAME=<container-id>, und Next.js' Standalone-Server bindet sich standardmäßig genau daran, nicht an0.0.0.0— der Frontend-Container lauschte dadurch nur auf seiner eigenen Container-IP, nie auf127.0.0.1. Harmlos für den veröffentlichten Port (Docker-NAT geht direkt auf die Container-IP), aber jeder containerinterne Check auflocalhostschlug fehl. Behoben per explizitemHOSTNAME=0.0.0.0. - Nächtliches Backup der eigenen OPNexus-Datenbank (neuer
postgres-backup-Service) plus Fix für Postgres' zu kleines/dev/shm— siehe eigener Abschnitt "Backup & Restore" unten für Details, Restore-Prozedur und warumfirewall_log_entriesbewusst ausgeschlossen ist. - Log-Erfassungs-Einstellungen (Intervall/Aufbewahrung) jetzt sicht-
und editierbar über eine neue Einstellungen-Sektion +
GET/PUT /api/logs/settings— vorher gab es dafür überhaupt keine UI/API, nur direktes Editieren der DB-Zeile. Default-Aufbewahrung von 30 auf 7 Tage gesenkt. - Realer Vorfall, der den letzten Punkt motiviert hat: beim Testen
des neuen Backup-Service fiel auf, dass
firewall_log_entriesbei den zwei echten, mäßig ausgelasteten Firewalls in nur 9 Tagen auf ~8,7 Mio. Zeilen / ~9,7GB angewachsen war (30-Tage-Default, keine Möglichkeit, das vorher zu sehen oder zu ändern) — zusammen mit den Rebuilds dieser Session zweimal kurz zu einem vollen Datenträger und dadurch zu einer kurzen Postgres-Crash-Loop geführt. Mit demselben sicherendocker image/builder prunewie beim 2026-09-12-Audit behoben, dazu Retention auf dieser Instanz sofort über die App-eigene Logik auf 7 Tage reduziert und 2,1 Mio. alte Zeilen bereinigt. Keine Datenverluste, beide echten Hosts während des gesamten Vorgangs erreichbar (außer den kurzen Postgres-Unterbrechungen selbst). - Diese VM ist für die reale Log-Last knapp bemessen (42GB Disk, reihenweise Docker-Image-Rebuilds plus jetzt auch DB-Wachstum) — auch nach allen Fixes hier bewegt sich der freie Speicher im niedrigen einstelligen GB-Bereich. Für den öffentlichen Release kein Code-Thema mehr, aber für den laufenden Betrieb dieser Instanz eine offene Kapazitätsfrage (mehr Disk zuteilen, oder Retention/Poll-Intervall noch weiter senken).
375 Backend-Tests grün (4 neu für die Log-Einstellungen), tsc --noEmit
und next build sauber.
Sicherheits- und Stabilitäts-Härtung vor dem öffentlichen Release (2026-09-17)
Vollständiges Security-/Stabilitäts-Audit (zwei parallele Reviews) mit Blick auf den geplanten öffentlichen Release — der Kern war bereits solide (Argon2id-Passwort-Hashing, saubere httpOnly/SameSite-Session-Cookies, keine SQL-/Shell-Injection, keine XSS-Lücken, robuste Background-Loops), aber mehrere Standardeinstellungen waren nur für die eigene, vertrauenswürdige Dev-VM sinnvoll und wären für fremde Selfhoster riskant:
- CORS gehärtet: Default akzeptierte zuvor jede Origin auf Port 3100
(
^https?://[^/]+:3100$), kombiniert mitallow_credentials=True— in Kombination ein potenzielles Session-Hijacking-Einfallstor. Default matcht jetzt nur nochlocalhost/127.0.0.1; jedes andere Deployment mussFRONTEND_ORIGINexakt setzen (neue Env-Variable). POSTGRES_PASSWORDohne unsicheren Fallback: der bisherige Default (identisch zum Benutzernamen) stand als Klartext im öffentlichen Repo —docker composeverweigert jetzt den Start ohne explizit gesetztes Passwort.- Ports standardmäßig nur auf
127.0.0.1statt0.0.0.0— verhindert, dass ein frischesdocker compose upauf einer Maschine mit Netzwerk-/Internet-Interface eine unauthentifizierte Firewall-Management-Oberfläche exponiert. Steuerbar über die neueBIND_ADDRESS-Variable in.env(z.B.0.0.0.0für LAN-weiten Zugriff ohne Reverse-Proxy), ohnedocker-compose.ymlanfassen zu müssen. - TLS/Reverse-Proxy-Weg dokumentiert (siehe "Zugriff von außerhalb
dieser VM / Produktivbetrieb mit TLS" im Setup-Abschnitt) — empfohlener
Produktivpfad ist ein TLS-terminierender Proxy (Caddy-Beispiel) plus
FRONTEND_ORIGIN+COOKIE_SECURE=true, statt die Ports direkt zu öffnen. - Backend-Logging aktiviert:
logging.basicConfig()fehlte komplett, wodurch sämtlichelogger.info()-Aufrufe der 5 Background-Loops (CARP- Rollenwechsel, Log-Ingest-Zähler, Sweep-Ergebnisse) spurlos verworfen wurden —docker compose logs backendzeigte nach Stunden Laufzeit nur uvicorns eigene Zugriffslogs. Zusätzlich Log-Rotation (max-size: 10m,max-file: 3) für alle drei Container, gegen unbegrenztes Log-Wachstum. - Automatischer Schema-Patch-Mechanismus (
app/schema_patches.py): löst das strukturelle Problem hinter dem "Schema-Änderungen ohne Migrationstool"-Abschnitt unten dauerhaft — jede historische manuelleALTER TABLE ... ADD COLUMN IF NOT EXISTS-Anweisung läuft jetzt bei jedem Start automatisch (idempotent), statt eine manuelle DBA-Aktion zu verlangen. Verifiziert durch simulierten Alt-Stand (Spalte künstlich gelöscht, Neustart stellt sie wieder her). Schließt insbesondere die zuvor offene Migration auf der zweiten echten Instanz (10.0.0.34). - Reproduzierbare Builds:
frontend/package-lock.jsoncommittet (Dockerfile nutzt jetztnpm cistattnpm install), Backend- Abhängigkeiten inrequirements.txtexakt statt mit>=-Untergrenzen gepinnt.
Alle Änderungen einzeln committet, 371 Backend-Tests grün, gegen die
laufende Produktiv-Instanz (dieser Host, 10.0.0.34) live ausgerollt
und verifiziert (Erreichbarkeit, CORS-Preflight mit der konfigurierten
Origin, Schema-Spalten vorhanden, beide echten Hosts fw-primary/
fw-secondary weiterhin korrekt in der DB). Während der Arbeit lief die
Root-Disk durch die Rebuilds kurz auf 100% voll, was den Postgres-
Container in eine kurze Crash-Loop schickte — mit demselben sicheren
docker image prune/docker builder prune-Cleanup wie beim Audit vom
2026-09-12 behoben, keine Datenverluste (siehe dortigen Handbuch-Abschnitt).
Bewusst nicht in diesem Batch: unauthentifizierte /docs//redoc,
in-memory-only Login-Throttle (kein Problem für den Normalfall, aber nicht
Multi-Replica-fähig), rohe Fehlermeldungen in einigen 502-Antworten an
read_only-Nutzer, fehlende Resource-Limits/Healthchecks für
Backend/Frontend in docker-compose.yml, kein automatisches Backup der
eigenen OPNexus-Postgres-Datenbank — alles als "nice-to-have" eingestuft,
nicht release-blockierend.
Direct-View-Link, Dienst-Steuerung, Resource-Monitoring, geplante Wartung (2026-09-17)
Vier Lücken gegenüber DynFi Manager (dem einzigen echten Software- Wettbewerber in der Nische "zentrales OPNsense-Fleet-Management", deren öffentliche Feature-Seiten für diesen Vergleich geprüft wurden) geschlossen. Multi-Tenancy/MSP-Betrieb ist dabei bewusst kein Ziel — Zielgruppe sind Privatleute/kleine Unternehmen mit eigener Firewall, nicht Dienstleister mit mehreren Kunden.
Direct-View-Link. Ein-Klick-Button "In OPNsense öffnen" (neue Spalte in
der Host-Liste, neuer Button in der Host-Detailansicht) öffnet host.base_url
in einem neuen Tab — reiner Navigationslink, kein neuer Endpunkt, kein API-Call.
Generische Dienst-Steuerung. Neue Sektion "Dienste" auf der Host-
Detailseite: Liste aller von OPNsense gemeldeten Dienste (Core\Api\ ServiceController::searchAction) mit Running-Badge, Start-/Stopp-/Neustart-
Buttons je Zeile hinter einem generischen Warnhinweis (keine kuratierte
Allowlist — alle Dienste sind grundsätzlich steuerbar). Neuer Router
routers/services.py, nutzt denselben ensure_no_maintenance()-Lock wie
jeder andere Schreibpfad. Nur das Auflisten (search) wurde live gegen
fw-primary/fw-secondary verifiziert (Read-only GET) — Start/Stopp/Neustart
bleiben mock-only, bis explizit freigegeben.
Resource-Monitoring. Neue Sektion "Systemauslastung (24h)" mit drei
kompakten Zeitverlaufs-Charts (CPU-Last, Speicher-%, Platten-%) je Host,
als eigene, minimale Inline-SVG-Komponente (components/ui/Sparkline.tsx,
keine neue Chart-Bibliothek) mit Hover-Crosshair+Tooltip. Neue Tabelle
host_resource_samples, vierter Hintergrund-Poll-Loop
(_resource_poll_loop, fester 60s-Intervall, keine eigene Einstellungs-UI
für v1) sampelt systemResources/systemTime/systemDisk/
getInterfaceStatistics je aktivem Host, mit Retention-Pruning nach 30
Tagen. Live gegen fw-primary/fw-secondary verifiziert — der Loop läuft
bereits produktiv und hat reale Messwerte aufgezeichnet.
Geplante Wartung (einmalig). Neuer Abschnitt in der Host-Wartung:
Neustart/Update/Major-Upgrade auf einen zukünftigen Zeitpunkt statt sofort
legen, mit Pflicht-Bestätigung ("läuft unbeaufsichtigt"). Bewusst kein
Recurring/Cron-Schema — ein Zeitpunkt pro Eintrag. Neue Tabelle
scheduled_maintenance, fünfter Poll-Loop
(_scheduled_maintenance_poll_loop, 60s) prüft beim Zünden erneut
ensure_no_maintenance() und dieselben Blockierungsgründe wie ein manuell
ausgeführtes Update (z. B. fehlendes frisches Backup) — eine blockierte
Aktion wird als failed markiert, nicht automatisch wiederholt. Feuert über
dieselbe HostOperation/Action-Dispatch-Logik wie maintenance.pys
execute(), keine doppelte Implementierung. v1-Scope-Entscheidung: ein
blockierter/fehlgeschlagener Zündversuch wird geloggt und im Status der
geplanten Wartung sichtbar, aber (noch) nicht an das bestehende Webhook-/
E-Mail-Alerting angebunden — das ist auf persistente NotificationProblem-
Zeilen mit eigenen Einstellungen zugeschnitten, eine neue Problem-Art dafür
wäre ein größerer Schnitt als dieses Feature selbst. Das End-zu-Ende-
Verhalten (Anlegen → Liste → Abbrechen) wurde live gegen fw-primary
verifiziert (Zeitpunkt 1 Jahr in der Zukunft, danach abgebrochen — zu keinem
Zeitpunkt ein echter OPNsense-Call, da eine geplante Wartung vor dem
Zünden nur eine OPNexus-eigene Datenbankzeile ist).
371 Backend-Tests grün (14 neu für diese vier Features), Browser-verifiziert bei 1440px/375px in Dunkelmodus gegen die echten Produktions-Hosts, keine Konsolenfehler.
Versionierung: Semantic Versioning mit Codenamen (2026-09-17)
OPNexus hatte bisher nur eine manuell gepflegte, datumsbasierte
Versionsstring (2026.09.09, nur auf der Login-Seite sichtbar) ohne
erkennbare Bedeutung zwischen den Zahlen. Ersetzt durch ein richtiges
Schema:
- Semantic Versioning (
MAJOR.MINOR.PATCH) plus Codename pro MINOR-/MAJOR-Version — Raubvögel, alphabetisch aufsteigend über die Releases hinweg (Vorbild: Ubuntus alliterierende Tier-Codenamen, ohne den Buchstaben-Zwang). Ein PATCH-Release behält den Codenamen seiner MINOR-Version. Vollständig dokumentiert in der neuenCHANGELOG.md, die ab jetzt parallel zu den Feature-Abschnitten hier im Handbuch die kurze Versions-/Namens-Übersicht führt (Das Handbuch bleibt die ausführliche Feature-Doku mit Verifikationsnachweisen). - Erstes versioniertes Release:
1.0.0"Falcon" — markiert den aktuellen, bereits sehr ausgereiften Stand (siehe CHANGELOG.md für die Begründung, warum1.0.0und nicht0.x). - Versionsanzeige erweitert. Bisher nur auf der Login-Seite; jetzt
zusätzlich im Footer jeder eingeloggten Seite (
AuthGate.tsx, verlinkt zu/about), auf/aboutselbst (bisher nur Fleet-Status/OPNsense- Versionen der verwalteten Hosts, nie die eigene App-Version) und in den Einstellungen unter einem neuen "Über OPNexus"-Abschnitt. Quelle bleibt weiterhinfrontend/lib/version.ts(APP_VERSION/APP_CODENAME, jetzt mit Codenamen-Konstante); neu istbackend/app/version.pymit denselben Werten, das FastAPI's eigenesversion=-Feld setzt (sichtbar in/docs//openapi.json). Beide Konstanten sind unabhängig voneinander von Hand gepflegt (kein Build-Schritt verbindet sie, wie auch sonst nirgends im Projekt) — künftig bei jedem Release-Bump beide gemeinsam ändern. - Nebenfund beim Anfassen der Login-Seite behoben:
router.pushnach erfolgreichem Login zeigte noch auf/hosts, obwohl/(und damit die eigentliche Startseiten-Erwartung) seit dem Dashboard-Release (2026-09-14, siehe unten) auf/dashboardzeigt — frisch eingeloggte Nutzer landeten also nicht auf der neuen Startseite. Jetzt konsistent/dashboard.
Keine Backend-Schema-Änderung. Verifiziert: tsc --noEmit/next build
sauber, volle Backend-Suite weiterhin grün (357), Backend-/openapi.json
zeigt version: "1.0.0". Browser-verifiziert (Playwright, Session direkt
per auth_service.create_session gemäß der Projekt-Konvention für
passwortlose Testsessions, danach wieder gelöscht — löscht dabei auch
jede andere gerade offene Sitzung, ein erwarteter Seiteneffekt, kein
Datenverlust): /about, /settings und /dashboard zeigen
„Falcon“ im Footer bzw. den jeweiligen Abschnitten, null Konsolenfehler.
CARP/HA vertiefen: Ereignis-Historie, Split-Brain-Alarm, NAT-Drift, Auto-Revert (2026-09-15)
Auf Wunsch nach einem offenen Feature-Brainstorm ("was können wir noch hinzufügen") aus drei angebotenen Kategorien zwei gewählt: CARP/HA vertiefen und bekannte UX-Lücken schließen (eine dritte, komplett neue Feature-Kategorie wurde für diese Runde bewusst zurückgestellt).
- CARP-Ereignis-Historie. Neue Tabelle
carp_events(komplett neu, kein manuellesALTER TABLEnötig) pluscarp_event_service. detect_and_record, eingehängt in den bestehenden_notification_poll_loop(main.py) — ein zusätzlicherget_vip_status-Aufruf pro Zyklus, bewusst getrennt vom bestehendennotification_checks.check_host_offline-Aufruf, um dessen bereits getesteten Code nicht anzufassen. Kein "letzter bekannter Zustand"-Feld nötig: neue Events werden gegen die jeweils letztecarp_events-Zeile pro Host verglichen. Neuer EndpunktGET /api/cluster/carp-events?cluster_name=(routers/cluster.py, der erste Router dieses Namens im Projekt). Frontend: einklappbarer Verlaufs-Abschnitt pro Cluster auf/cluster. - Split-Brain-/Kein-Master-Alarm, vollständig über die bestehende
Benachrichtigungs-Infrastruktur (kein neuer Zustell-/Dedup-Code): neue
notification_checks.check_carp_health, neuerProblemTypecarp_split_brain, neue SpalteNotificationSettings. carp_health_enabled— brauchte den manuellen-ALTER TABLE-Workaround (siehe "Schema-Änderungen" oben; auf dieser Dev-VM bereits ausgeführt, auf10.0.0.34noch ausstehend). - NAT-Drift-Erkennung, Erweiterung des bestehenden Regel-/Alias-Drift-
Musters auf alle drei NAT-Typen (Port Forward, 1:1, Outbound). Neuer
nat_drift_service.py, neue KomponenteLiveNatDrift.tsx(4. Tab "Live-Drift" auf/nat), neuerProblemTypenat_drift+ SpalteNotificationSettings.nat_drift_enabled(zweite manuelle-ALTER TABLE-Spalte). Feldnamen vorab gegen den OPNsense-Core-Quellcode verifiziert (DNatController.php/OneToOneController.php/SourceNatController.php), nicht geraten: Port Forwards Such-Zeilen-Felder sinddescr+ gepunktetesource.network/destination.network-Keys — anders als OneToOne/Outbound-NATs flachedescription/source_net/destination_net, und wieder anders als Port Forwards eigenes Schreib-Payload-Format (dort echtesource/destination-Objekte statt gepunkteter Keys). - CARP-Wartungsmodus-Auto-Revert-Timer — der riskanteste Teil, da er
einen echten Produktions-Schreibpfad erweitert
(
opnsense_client.toggle_carp_maintenance). Neue Tabellecarp_maintenance_windows(komplett neu) +carp_maintenance_window_ service.py: optionale Dauer (15/30/60 Minuten oder dauerhaft) am bestehenden CARP-Umschalt-UI (CarpSwitchControl.tsx), ein Sweep im bestehenden 10-Sekunden-_deploy_revert_sweep_loop(main.py). Anders als die bewusste Kein-Auto-Revert-Haltung der VPN-Trilogie (ein VPN-reconfigurekann den für die Reparatur nötigen Pfad selbst kappen) ist ein CARP-Rollenwechsel gefahrlos automatisch rückgängig zu machen — der Sweep überspringt das menschliche Bestätigen-Gate bewusst, weil der Mensch mit der Dauer-Auswahl bereits zugestimmt hat. NeuerProblemTypecarp_auto_revert_failed+ SpalteNotificationSettings. carp_auto_revert_failed_enabled(dritte manuelle-ALTER TABLE-Spalte dieser Runde). Das Dashboards "Offene Aktionen" zeigt jetzt auch aktive Wartungsfenster (informativ, mit Auto-Revert-Zeitpunkt) und fehlgeschlagene an. Von der Testsuite gefundener echter Bug, nicht durch Inspektion: die erste Fassung schrieb ein rohes Python-datetime-Objekt inHostOperation.snapshot(eine reine JSON-Spalte mit Stdlib-json.dumps, anders als FastAPIs eigene Response- Serialisierung) — behoben durch.isoformat()aufauto_revert_atvor dem Einfügen. - Toast-Abdeckungs-Lücke geschlossen (schließt eine offen dokumentierte
Lücke aus dem UI/UX-Umbau vom 2026-09-14 unten): alle 9 Deploy-Review-
Overlays (
RuleReview,PortForwardReview,OneToOneReview,OutboundNatReview,AliasReview,PolicyHierarchyRuleReview,IpsecTunnelReview,OpenVpnInstanceReview,WireguardTunnelReview) zeigen jetzt einen Erfolgs-Toast beim Push (zusätzlich zu, nicht statt ihrem bisherigen Host-Status-Text). Alle 4 VPN-/Zertifikats-Seiten (ipsec,openvpn,wireguard,certificates) haben jetzt vollständige Erstellen/Bearbeiten/Löschen/Push/Registrieren/ Signieren-Toast-Abdeckung — vorher null.
Bewusst nicht in dieser Runde gebaut (im Plan so festgehalten, kein
Versehen): Aliase-/NAT-Hierarchie in der Policy-Hierarchie, Restore Stufe 2
(revertBackup) — beide als eigene, mit früheren Features vergleichbar
große Features zurückgestellt.
Verifikation: 351→357 Backend-Tests (19 neu, alle respx-gemockt), volle
Suite durchgehend grün. tsc --noEmit/next build bei jedem Schritt
sauber. Echte Browser-Verifikation gegen die reale Flotte (fw-primary/
fw-secondary, nicht gemockt) nach Neubau beider Container und Anwendung
der drei manuellen ALTER TABLE-Befehle auf dieser Dev-VM-Datenbank —
bestätigt per Screenshot bei 1440px (Dark Mode) und 375px (kein
horizontales Overflow auf allen drei neuen/geänderten Seiten): der
CARP-Poll-Loop erfasste sofort und korrekt die erste carp_events-Zeile
beider echten Hosts, NAT-Live-Drift zeigte korrekte reale Zahlen (1
Port-Forward-Regel, 0 1:1, 0 Outbound, keine falsch-positiven Treffer),
alle neuen Einstellungs-Schalter rendern und sind standardmäßig aktiviert.
Null Konsolenfehler. Kein Schreibzugriff auf 10.0.0.2/.3 für den
CARP-Auto-Revert-Pfad — A4s eigentlicher toggle_carp_maintenance-Sweep-
Aufruf ist nur über respx-/zustandsbehaftete Mock-Tests verifiziert; ein
erster echter Test des Auto-Revert-Sweeps gegen Produktion bräuchte ein
eigenes, explizites Go-ahead, dieselbe Disziplin wie bei jedem anderen
schreibfähigen Feature.
Noch offen: dieselben drei ALTER TABLE notification_settings ADD COLUMN-Befehle (carp_health_enabled, nat_drift_enabled,
carp_auto_revert_failed_enabled, alle BOOLEAN DEFAULT true NOT NULL)
müssen noch auf der zweiten echten Instanz (10.0.0.34) ausgeführt
werden, bevor deren Benachrichtigungs-Einstellungsseite lädt — dieselbe
ausstehende Migration wie die bereits bekannte admin_users.role-Lücke.
Fleet-Dashboard, neue UI-Bausteine und Konsistenz-Pass (2026-09-14)
Auf Wunsch, das Frontend auf "perfektes UI/UX" zu analysieren und zu verbessern. Die App war funktional bereits sehr ausgereift, die Oberfläche aber als "solide Admin-Tabellen" statt poliert eingeschätzt worden — kein echtes Dashboard, Dark Mode als brüchige ~100-zeilige Klassennamen-Override-Tabelle, nur 7 UI-Primitives für ~20 Seiten, nicht-semantische Button-Varianten, kein Toast-/Feedback-System, ad-hoc Empty-States. In vier Phasen umgesetzt:
- Phase 0 — Design-Fundament.
slateist jetzt CSS-Variablen- gestützt statt eine statische Palette:tailwind.config.tsdefiniertcolors.slate.*überrgb(var(--slate-N) / <alpha-value>),globals.csssetzt die 11--slate-N-Variablen unter:root(hell) und spiegelt 10 davon unter[data-theme="dark"](dieselben Schatten-Umkehr-Paare wie die alte Tabelle). Das ersetzt ~60 Zeilen der alten[data-theme="dark"] .bg-slate-100 {...}-Tabelle und behebt den Opacity-Modifier-Dark-Mode-Bug dauerhaft für slate (bg-slate-100/50komponiert jetzt korrekt) — ohne eine einzige JSX-Änderung über alle ~20 Seiten hinweg. Die Akzentfarben (sky/blue/ emerald/…) bleiben bewusst bei der alten kontextabhängigen Override-Tabelle, da deren Dark-Mode-Zielfarbe je nach CSS-Eigenschaft variiert (kein reiner Paletten-Ersatz möglich). Neue semantische--tone-*-Variablen (neutral/info/success/warning/danger/accent) für die neuenBadge/Toast-Komponenten. Neue Primitives incomponents/ui/:Badge.tsx,Tabs.tsx(mit echterrole="tab"/ Pfeiltasten-Navigation),Table.tsx,EmptyState.tsx,Toast.tsx(+ToastProvider/useToast(), inapp/layout.tsxeingehängt).Buttonsedit-Variante zusecondaryumbenannt (war ein fest codiertesbg-blue-800ohne semantischen Namen). Ein Dark-Mode-Regressions-Fund vom Nutzer selbst korrigiert:.bg-white/select optionnutzten versehentlich die (bewusst invertierende)--slate-900-Variable statt eines festen Werts — für eine Karte, die immer hell/dunkel bleiben soll (nicht themenabhängig invertiert), jetzt hart aufrgb(15 23 42)gesetzt. - Phase 1 — Dashboard. Neue Seite
/dashboard, jetzt Ziel von/und des Post-Login-Redirects (ersetzt/hosts), erster Eintrag in Navs "Übersicht"-Gruppe. Deckt alle vier gewünschten Aspekte ab: Fleet-/CARP-Gesundheit (Host-Liste mit Live-Status/CARP-Rolle/Cluster), offene Aktionen (neuerGET /api/dashboard,routers/dashboard.py, reiner DB-Read ohne Live-OPNsense-Aufrufe — aggregiert Pending/Error/ Revert-Failed über alle 5 deploybaren Entitätstypen plus feststeckendeHostOperation-Zeilen), Sicherheits-/Ablauf-Warnungen (bewusst keine Logik dupliziert — nutzt das bereits bestehendeGET /api/notifications/ problemsdirekt statt eine zweite Erkennung zu bauen) sowie Aktivität (letzte 15 Audit-Events). Ein echter Fund beim ersten Laden gegen die reale Flotte: 28 einzeln gerenderte, fast identische Drift-Zeilen flurteten das Panel — client-seitig zu einer Zeile pro Cluster gruppiert, die Rohzahl bleibt trotzdem in der Stat-Kachel sichtbar. 3 neue Backend-Tests, volle Suite (338) grün. - Phase 2 — Konsistenz-Pass. Alle handgerollten Badges (
WorkflowBadge,CertStatusBadge, mehrere Inline-Pills inrules/logs/certificates/updates) auf<Badge tone=...>migriert; alle handgerolltenTabButton-Implementierungen (6 Dateien) auf<Tabs>mit echter Pfeiltasten-Navigation; alle ad-hoc "Noch keine X angelegt"-Meldungen auf<EmptyState>(mit bewusst dokumentierten Ausnahmen, wo eine Icon-Box das Layout gesprengt hätte, z.B. Tabellen-Leerzeilen und die kompakte Policy-Hierarchie-Sidebar). - Toast-Feedback. Nur bei erfolgreichen Mutationen (
toast.show(..., "success")) — Fehler bleiben bewusst als persistenter<p role="alert">neben der Aktion, da das für etwas, das der Nutzer wirklich lesen und beheben muss, das bessere Muster ist als ein nach 5s verschwindender Toast. Vollständige Erstellen/Bearbeiten/Löschen- Abdeckung aufhosts/users; nur-Löschen aufgroups(bewusst kein Toast für dessen Speichern-Pfad, der bereits einen besseren bespoken Erfolgs-Banner mit Deep-Link hat); Bestätigen+Löschen aufrules/aliases/den drei NAT-Panels. Explizit noch nicht abgedeckt in dieser Phase, als bekannte Lücke dokumentiert: die eigentliche "Prüfen & Push"-Deploy-Aktion in den*Review.tsx-Overlays sowie alle VPN-/Zertifikats-Seiten — geschlossen am 2026-09-15 (siehe "CARP/HA vertiefen" oben).
Verifiziert bei jedem Schritt: tsc --noEmit/next build sauber,
Playwright bei 1440px/375px in Hell und Dunkel gegen den echten Container
mit echten fw-primary/fw-secondary-Daten, null Konsolenfehler. Kein README-
Eintrag/Screenshot wurde ursprünglich ergänzt (bewusst zurückgestellt, um
im Scope der UI/UX-Arbeit selbst zu bleiben) — dieser Abschnitt holt das
jetzt nach.
Login-Infos: Version, Sicherheits-Historie, Dark-Mode-Audit (2026-09-09)
Drei kleine, zusammenhängende Ergänzungen auf Nutzerwunsch ("beim Login Infos und sowas"):
- Versions-/Herstellerzeile auf der Login-Seite (
app/login/page.tsx):OPNexus {APP_VERSION} · Eigenentwicklung von {APP_MAINTAINER}auslib/version.ts— eine manuell gepflegte, datumsbasierte Version (kein Build-Tooling/Git-Hash-Pipeline, bewusst einfach gehalten, analog zu den bereits datierten README-Abschnitten); wird bei künftigen nennenswerten Änderungen mit hochgezogen. - Sicherheits-Banner nach dem Login (
components/LoginInfoBanner.tsx): zeigt einmalig nach einer erfolgreichen Anmeldung Zeitpunkt+IP der vorherigen Anmeldung, die Anzahl fehlgeschlagener Versuche seit dieser letzten Anmeldung (amber-Warnfarbe statt neutral, wenn > 0) und bis wann die neue Sitzung gültig ist. Backend:AdminUserbekommt eine neue Spaltelast_login_ip(String, manuelleALTER TABLEgegen die echte DB nötig und bereits ausgeführt — siehe Schema-Änderungs-Konvention weiter unten);auth_service.authenticate()merkt sich den alten Wert vonlast_login_at/last_login_ipals transientes (nicht persistiertes) Attribut auf dem zurückgegebenen User-Objekt, bevor beide auf den aktuellen Login überschrieben werden. Fehlversuche seit dem letzten erfolgreichen Login zählt ein neuer In-Memory-Zähler in_LoginThrottle(_failures_since_success, pro Prozess, wie der Rest des Throttles — überlebt also keinen Backend-Neustart, ist aber ein reiner Info-Hinweis und keine Sicherheitskontrolle).POST /api/auth/loginliefert die neuen Felder (previous_login_at/previous_login_ip/failed_attempts_since_last_login/session_expires_at) nur einmal in der Login-Response; das Frontend legt sie kurz insessionStorageab (Key auslib/loginInfo.ts) und liest+löscht sie einmalig, damit das Banner nicht bei jedem Seitenaufruf erneut erscheint. 278 Backend-Tests weiterhin grün (respx-gemockt wie üblich,admin_usersbleibt vom Tabellen-Reset zwischen Tests ausgenommen). Browser-verifiziert: Banner erscheint nach Login, bleibt nach Reload weg, zeigt beim zweiten Login IP+Zeitpunkt des ersten, Warnvariante nach zwei Fehlversuchen korrekt amber, X schließt es. - Dark-Mode-Audit (Anlass: die Ecken/Kanten-Rückmeldung zur Nav/Command-Palette
vom selben Tag): gezielt nach dem gefundenen Bug-Muster gesucht (Tailwind-
Opacity-Modifier-Klassen wie
bg-x/NN, die vom klassennamen-basierten Dark-Mode-Override inglobals.cssnicht erfasst werden). Zwei weitere Fundstellen behoben:LiveRulesDrift.tsxs Drift-Zeilen-Highlight (bg-amber-50/60→bg-amber-50) undcomponents/ui/Modal.tsx— dessen Karte hatte wie die Command Palette vorher gar keinen Rand, nurshadow-xl, was im Dark Mode (Karte fast gleich dunkel wie der Backdrop) nicht mehr als Abgrenzung reicht; jetztborder border-slate-200ergänzt (jeder Confirm-Dialog im Projekt nutztModal.tsx, Fix wirkt also projektweit). Vollständiger Grep nach(bg|text|border|ring)-<farbe>/<zahl>überapp/+components/bestätigt: keine weiteren Fundstellen offen.
OpenVPN: Zertifikat-/CA-Picker pro Ziel-Host (2026-09-09)
Schließt eine der beiden verbliebenen Lücken der Funktions-Roadmap. Bisher musste
cert_ref/ca_ref als eine einzelne, von Hand eingetippte UUID gepflegt
werden — dabei ist eine Zertifikat-UUID host-lokal, und OpenVPN-Instanzen
unterstützen echtes Multi-Host-Targeting: der alte Code schickte dieselbe
UUID an jeden Ziel-Host, was für jeden Host außer bestenfalls einem defekt
gewesen wäre (ein realer, nicht nur hypothetischer Bug, durch Code-Lesen
bestätigt, bevor mit dem Picker begonnen wurde).
- Datenmodell:
cert_ref/ca_ref(einzelner String) →cert_refs/ca_refs({host_id: uuid}-Maps, JSON-Spalten).openvpn_instanceshatte null echte Zeilen, daher direktesALTER TABLE ... DROP COLUMN cert_ref, DROP COLUMN ca_ref, ADD COLUMN cert_refs JSON ..., ADD COLUMN ca_refs JSON ...gegen die echte DB, kein Datenverlust. - Neuer Lesepfad:
GET /api/hosts/{id}/cas(Pendant zu/certificates), gegen OPNsense-Kernquelle verifiziert vor dem Bauen (Trust/Api/ CaController.php→trust/ca/search,Trust/Ca.xmlfür die Feldnamen) statt einer Vermutung — beide direkt von github.com/opnsense/core master abgerufen. - Deploy-Pfad wird wirklich pro Host:
build_payloads(instance, host_id)löst Zertifikat/CA jetzt pro Host auf; fehlt einem Ziel-Host die Auswahl, wird fail closed abgebrochen (MissingCertError, kein Config-Write, kein Reconfigure-Aufruf) statt eine leere/falsche UUID zu senden.preview()bekommt dafür einen eigenen Aktions-Wertmissing_cert. - Frontend:
openvpn/page.tsxs Formular löst die tatsächlichen Ziel-Hosts (direkte Auswahl + Gerätegruppen-Mitglieder) live auf und zeigt je Host ein eigenes Zertifikat-/CA-Dropdown aus dessen echtem Trust-Store (GET .../certificates+.../cas), mit Leerzustand, falls ein Host (noch) keins hat. - Nebenbei gefundener und behobener Bug (unabhängig vom Picker): das
Anlegen einer neuen Instanz über die UI schickte immer
tls_key_material: ""statt es wegzulassen, was das Backend-Schema (min_length=1) ablehnte — der Bearbeiten-Pfad hatte dafür schon einen Workaround, der Anlegen-Pfad nicht. Jetzt beide gleich behandelt. - 4 neue Backend-Tests (Multi-Host-Deploy schickt pro Host ein eigenes
Zertifikat/CA — vorher nicht einmal ausdrückbar; fehlende Auswahl bricht
ohne jeden Aufruf ab; Vorschau zeigt den neuen Aktionswert;
/cas-Endpunkt), volle Suite (294) grün. Browser-verifiziert direkt gegen beide echten Hosts (sicher, weil rein lesend + lokaler Formularstand bis zu einem expliziten Push, der während der Verifikation nie ausgelöst wurde) bei 1440px/375px/ Dark Mode — dabei ein reales Ergebnis sichtbar gemacht: beide echten Hosts haben aktuell keine CA im Trust-Store registriert (nur das Standard-WebGUI- Zertifikat), korrekt als Leerzustand statt als Fehler angezeigt. Test-Daten nach der Verifikation wieder gelöscht.
Restore, Stufe 1: Vorschau + manuelle Übergabe (2026-09-09)
Setzt docs/restore-plan.md (8. September) in seiner ersten Ausbaustufe um:
eine lokal gespeicherte OPNsense-Konfigurationssicherung (backup_store.py,
bereits vorhandenes "Backups"-Panel) prüfen, sicher als Vorschau anzeigen und
den Wechsel auf das OPNsense-Webinterface dokumentiert übergeben — OPNexus
führt an keiner Stelle einen Schreibzugriff auf die Firewall aus. Stufe 2
(revertBackup als echter API-Aufruf) ist bewusst nicht Teil dieser Änderung.
- Backend: neue Tabelle
restore_operations(models/restore_operation.py, nach dem Vorbild vonHostOperation, keine FK aufopnsense_hosts, brandneu also ohne manuellesALTER TABLE). Neuer Servicerestore_service.pyparst die XML mitdefusedxml.ElementTree(neue Abhängigkeit, kein XML-Parser war vorher im Projekt vorhanden) — Root-Element mussopnsensesein, 32-MiB- Obergrenze vor dem Parsen, jedes Blattelement mit einem passwort-/schlüssel-artigen Tag-Namen wird persha256:<hex>ersetzt statt je roh gespeichert oder zurückgegeben zu werden (gleiches Muster wie die Secret-Redaktion inipsec_deploy_service.pynach dem Fund vom Vortag). Neuer Routerrouters/restore.pyunter/hosts/{id}/restoremitpreview→handoff→confirm/cancel, 1:1 nach dem Vorschau/Ausführen/ Bestätigen-Muster vonmaintenance.py(Fingerprint-Prüfung, Typ-den- Hostnamen-Bestätigung, Ablaufzeit).previewlöst zusätzlich best-effort eine frische Sicherheitssicherung aus (derselbe Aufruf, den der bestehende „Jetzt sichern“-Button schon macht) — schlägt das fehl, wird nur gewarnt, nicht abgebrochen. Cross-Feature-Sperre:ensure_no_maintenance()undinspect_host()s Blocked-Liste inhost_maintenance_service.py(die bereits von allen 9 Regel-/Alias-/NAT-/VPN-/Zertifikats-Deploy-Pfaden sowie Reboot/Update genutzten Prüfpunkte) wurden um eine Prüfung auf offene Restore-Vorgänge (status=="handed_off") erweitert — eine einzige Änderung an einer Datei sperrt automatisch alle bestehenden Schreibpfade, ohne die 9 einzelnen Deploy-Services anzufassen. - Frontend:
RestoreWizard.tsx(gleiches Modal/„armed“-Zwei-Schritt-Muster wieIpsecTunnelReview.tsx, deutliche Warnung vor der Übergabe), neuer „Wiederherstellen“-Button je Backup-Zeile in der Host-Detailseite, neue „Restore-Historie“-Tabelle darunter. - Bewusst nicht gebaut: Stufe 2 (
revertBackup), Durchsuchen/Herunterladen geräteseitiger Revisionen (backups/this) — Stufe 1 sieht nur bereits lokal gespeicherte Sicherungen vor —, automatisches Markieren von Regel-/ Alias-/NAT-Zeilen als "abzugleichen" nach einem Restore (nur ein statischer Hinweistext bei der Bestätigung). - 12 neue Backend-Tests (Happy Path, überlange/falsches-Wurzelelement/XXE-
Datei abgelehnt, Fingerprint-Mismatch blockiert Übergabe, Sperre blockiert
echten Regel-Deploy und Reboot-Vorschau während
handed_offund gibt nachconfirmwieder frei), volle Suite (290) grün. Browser-verifiziert bei 1440px/375px/Dark Mode direkt gegen den echten Hostfw-primary— sicher, weil Stufe 1 außer dem bereits etablierten, lesendendownload_backup-Aufruf (identisch zum „Jetzt sichern“-Button) keinerlei OPNsense-Aufruf macht; Vorschau zeigt reale Backup-Metadaten (u.a. einen echten, informativen Fund: der Hostname in der Sicherung ist noch der OPNsense-Standardwert, nicht „fw-primary“ — als Warnung sichtbar gemacht, nicht verschluckt), 17 geheime Felder redaktiert, Übergabe/Bestätigen/Abbrechen durchgeklickt, danach bewusst wieder auf „Abgebrochen“ gesetzt statt bestätigt, damit kein echter Host in gesperrtem Zustand zurückbleibt.
Command Palette: Schnellsuche per Strg/⌘+K (2026-09-09)
Fast-Follow zur Nav-Umstrukturierung (siehe unten): eine globale
Command-Palette (components/CommandPalette.tsx), erreichbar per
Strg/⌘+K oder über den Such-Button in der Kopfzeile. Durchsucht zwei
Quellen — alle 14 Nav-Ziele (aus navGroups, jetzt aus Nav.tsx
exportiert statt privat, um Doppelpflege zu vermeiden) mit Gruppen-Label
als Kontext-Hinweis, sowie alle Hosts (GET /api/hosts, live bei jedem
Öffnen geladen) mit Adresse als Hinweis, Ziel /hosts/{id}. Volltext-
Filter per Substring (Label + Hinweis, case-insensitive), Pfeiltasten
zum Navigieren, Enter zum Sprung, Escape zum Schließen, Klick auf
Backdrop schließt ebenfalls. Reine Navigations-Suche v1 — keine Suche
innerhalb von Entitäten (z. B. "welche Regel matcht Port 443"); das
wäre ein separates, deutlich größeres Vorhaben mit eigenen Backend-
Suchendpunkten pro Entität, bewusst nicht Teil dieser Iteration.
Browser-verifiziert bei 1440px/375px (eingeloggt): Öffnen per Button und
per Tastenkürzel, Filtern auf Nav-Ziel und auf Host, Sprung per Enter zu
beiden Zieltypen bestätigt (URL-Wechsel geprüft), Leerzustand bei
Treffer-losem Suchbegriff, Escape schließt, kein Overflow auf Mobile.
npx tsc --noEmit sauber.
Dark-Mode-Nachbesserung (gleicher Tag): Nutzer-Feedback anhand eines
Screenshots zeigte zwei Stellen ohne saubere Abgrenzung im Dark Mode.
Ursache beide Male dieselbe: das Dark-Mode-Override-Schema in
globals.css matcht exakte Tailwind-Klassennamen (z. B. .bg-slate-50)
— eine Opacity-Modifier-Klasse wie bg-slate-50/60 erzeugt aber intern
einen anderen Klassennamen (.bg-slate-50\/60) und wird vom Override
nicht erfasst, bleibt also im Dark Mode bei der hellen Originalfarbe.
Betraf die Subnav-Leiste in Nav.tsx (bg-slate-50/60 → auf reines
bg-slate-50 ohne Opacity gewechselt, dadurch vom bestehenden
.bg-slate-50-Override erfasst) und die Command-Palette-Karte in
CommandPalette.tsx (hatte gar keinen Rand, nur shadow-xl — im Dark
Mode ist die Karte fast gleich dunkel wie der Backdrop, wodurch der
Schatten allein nicht mehr als Abgrenzung ausreicht; border border-slate-200 ergänzt, per bestehendem Override im Dark Mode
border-slate-700). Browser-verifiziert im Dark Mode bei 1440px:
Subnav-Leiste jetzt durchgängig dunkel ohne hellen Streifen,
Command-Palette mit sichtbarem Rand klar vom Hintergrund abgegrenzt.
Navigation: gruppierte Top-Tabs + Subnav (2026-09-09)
Die Hauptnavigation (components/Nav.tsx) war mit 14 flachen Links (Hosts,
Cluster, Gruppen, Regeln, Policy-Hierarchie, Aliase, NAT, IPsec-VPN, OpenVPN,
WireGuard, Zertifikate, Audit, Logs, Einstellungen) in einer Zeile überladen
— Folge des schnellen Feature-Wachstums (VPN-Trilogie, Zertifikate,
Policy-Hierarchie, Log-Suche kamen alle in wenigen Tagen dazu). Umgebaut zu
einer zweistufigen Struktur nach dem Vorbild zentraler
Firewall-Management-Konsolen: eine schlanke Kopfzeile mit 6 Gruppen-Tabs
(Übersicht, Regelwerk, VPN, Zertifikate, Monitoring, System), darunter eine
zweite, schmale Subnav-Leiste mit den Unterpunkten der jeweils aktiven
Gruppe. Ein-Punkt-Gruppen (Zertifikate → nur /certificates, System → nur
/settings) navigieren direkt und zeigen keine Subnav-Zeile. Aktive Gruppe
wird über Pfad-Präfix-Matching der Kind-Routen ermittelt (kein eigener
Router-State). Reine Frontend-/Struktur-Änderung, keine neuen Routen, keine
Backend-Änderung. Browser-verifiziert bei 1440px und 375px (eingeloggt,
echte Session): alle 6 Gruppen-Tabs und deren Subnav-Items einzeln
angeklickt, Active-State-Highlighting auf beiden Ebenen korrekt, Ein-Punkt-Gruppen
ohne Subnav-Zeile bestätigt, keine Konsolenfehler durch die Nav selbst,
kein horizontaler Overflow. npx tsc --noEmit sauber.
Zertifikatsverwaltung: Trust-Store + ACME/Let's Encrypt (2026-09-09)
Baut die bisher rein lesende Zertifikatsansicht (GET /api/hosts/{id}/certificates,
Seite /certificates) zu echter zentraler Verwaltung aus — Trust-Store-CRUD
(OPNsense-Core, kein Plugin) plus ACME/Let's-Encrypt-Ausstellung (Plugin
os-acme-client), in einem Durchgang. Beide neuen Entitäten
(ManagedCertificate, AcmeAccount/AcmeCertificateRequest) sind
bewusst pro Host modelliert (ein einfaches host_id-Feld, keine
DeviceGroup/effective_hosts-Mehrfachziel-Auswahl wie bei
Regeln/IPsec/OpenVPN/WireGuard) — Trust-Store-caref und ACME-account-/
validationMethod-UUIDs sind hostlokal und über mehrere Hosts nicht
portabel, ein Mehrfach-Host-Push würde eine ganze Klasse von
UUID-Mismatch-Bugs erzeugen.
Wichtiger Unterschied zu den VPN-Features: kein Lockout-Risiko für den
Trust-Store-Teil. Bestätigt über GitHub-Issue opnsense/core#9399: es
existiert nachweislich keine API, um das WebGUI-Zertifikat der
Firewall umzubiegen — OPNexus kann diesen Kanal also auch nicht
versehentlich kappen. Trust-Store-Schreibaktionen lösen laut
Modell-Definition keinen Service-Restart aus (reissue z. B.
explizit "does not restart services"), ein Push ist ein einzelner,
sofort wirksamer add/set-API-Call. Deshalb: kein zweistufiges
Warn-Gate im Frontend für ManagedCertificate, anders als bei den
VPN-Features — dieses Risikoprofil existiert hier schlicht nicht.
Das neue Risiko liegt stattdessen bei ACME: die separaten Aktionen
register (Account) und sign (Zertifikat) sind echte Aufrufe gegen
die externe Zertifizierungsstelle (Let's Encrypt o. ä.) über das echte
Internet — mit deren bekannten Rate-Limits (z. B. Ausstellungen pro
Domain und Woche), nicht mit Verbindungsabbruch-Risiko. Beide sind daher
hinter demselben zweistufigen Warn-Gate versteckt wie IPsec/OpenVPN/
WireGuards Push (AcmeActionConfirm.tsx, mechanisch identisches
"warnung verstanden → explizit bestätigen"-Muster), aber mit anderem
Warntext: Kontaktiert die echte Zertifizierungsstelle über das
Internet, zählt gegen deren Rate-Limits, Empfehlung letsencrypt_test/
Staging-CA für Tests zu verwenden. Anlegen/Ändern/Push von Account und
Zertifikatsanfrage selbst sind reine lokale Konfigurationsänderungen ohne
Außenkontakt und daher NICHT gewarnt — nur register/sign.
register ist blockierend (acme.sh --registeraccount, idempotent
bei bereits registriertem Account, aber jedes Mal ein echter
Außenkontakt). sign läuft asynchron (configd forkt einen Daemon und
kehrt sofort zurück) — der Trigger-Aufruf ist keine Erfolgsmeldung,
nur eine Bestätigung, dass die Ausstellung angestoßen wurde
(sign_status wird "triggered", nie "success"). Das tatsächliche
Ergebnis ist nur über einen separaten Poll (GET /acmeclient/certificates/get/{uuid} → statusCode/statusLastUpdate/
lastUpdate) sichtbar — Button "Status" in der UI, POST /api/acme/certificates/{id}/poll im Backend.
Response-Form-Falle, dritte eigene Variante nach IPsec/OpenVPN:
register/sign antworten weder mit IPsecs {"status":"ok"}
noch mit OpenVPN/WireGuards {"result":"ok"}, sondern mit
{"result":"failed"} bei Vorprüfungsfehler oder {"response": "<roher configd-Text>"} bei akzeptiertem Trigger — ein eigener Parser
(opnsense_client._acme_action_result()) wertet das aus, _mutation_result
passt hier nicht (durch einen eigenen Regressionstest abgesichert, siehe
unten).
renewInterval-Semantik-Falle (explizit dokumentiert, da real
verwirrend): das Feld zählt Tage seit der letzten Ausstellung, NICHT
Tage vor Ablauf — sowohl im Formular-Label (AcmeCertificateForm.tsx) als
auch im Modell-Docstring (AcmeCertificateRequest) explizit so benannt.
UUID-Injektion für AcmeCertificateRequest.account — beim Push wird
die pro-Host-UUID des verlinkten AcmeAccount frisch eingefügt, exakt das
Prinzip, das ipsec_deploy_service.py für sein eigenes connection-Feld
dokumentiert (inkl. derselben Stolperfalle: die injizierte UUID darf nie
in das für No-Op-Redeploy-Vergleich gespeicherte last_payload
einfließen). Ein eigener Regressionstest (test_push_certificate_request_ repushes_after_account_switch) sperrt das exakte Szenario ein: Wechsel
des verlinkten Accounts allein (alle anderen Felder unverändert) muss
trotzdem einen echten Re-Push mit der neuen Account-UUID auslösen.
Geheimnisse nie in Postgres — weder der private Schlüssel
(ManagedCertificate, nur bei action=import) noch der External-
Account-Binding-HMAC (AcmeAccount.eab_hmac) landen jemals in der
Datenbank, beide nur auf Platte, eine Datei pro Entity-ID, chmod 600
(cert_key_store.py/acme_secret_store.py, neue Docker-Volumes
cert_key_secrets/acme_secrets). Anders als beim bereits bekannten
Muster von IPsec/OpenVPN/WireGuard (deren PSK/Static-Key/Private-Key
bewusst auch im last_payload-JSON landen, für den No-Op-Vergleich) —
hier landen die Geheimnisse nirgends in Postgres, auch nicht im
last_payload: ein Boolean-Platzhalter (private_key_set/
eab_hmac_set) steht im Vergleichs-Payload stellvertretend dafür, damit
eine reine Schlüsseländerung trotzdem als echte Änderung erkannt wird.
Löschen eines verwendeten Zertifikats wird von OPNsense selbst
verhindert (checkAndThrowValueInUse() in CertController::delAction())
— OPNexus prüft das nicht selbst vorab, sondern reicht OPNsenses eigene
Fehlermeldung 1:1 durch.
Verifizierte Fakten (gegen opnsense/core und opnsense/plugins
master, 2026-09-09, direkt per raw.githubusercontent.com abgerufen —
volle Zitate in opnsense_client.pys "Trust-Store"/"ACME"-Abschnitten und
den Modul-Docstrings von managed_certificate_service.py/acme_service.py):
Trust/Cert.xml: Beschreibungsfeld heißt auf der Leitungdescr, NICHTdescription. Klartext-Eingabefelder sindcrt_payload/csr_payload/prv_payload—CertController::setBaseHook()base64-kodiert diese serverseitig incrt/csr/prv.private_key_location(Default"firewall") wird von OPNexus nie gesendet — dadurch generiert und speichert OPNsense den privaten Schlüssel fürinternal/reissuekomplett selbst; OPNexus muss nur füraction=importüberhaupt Schlüsselmaterial verwalten.CertController:addAction/setAction/delAction/getAction(Body-Keycert), Standard-{"result":"saved","uuid":...}-Form über_mutation_result— Trust-Store hat keine asynchrone/externe Antwortform, anders als ACME.AcmeClient.xml:ca-Enum letsencrypt/letsencrypt_test/zerossl/ buypass/buypass_test/google/google_test/sslcom/custom.keyLength-Werte sind präfixiert (key_4096, nicht4096) — eine reale Feld-Form-Falle, die ein ungeprüftes"4096"still verworfen hätte.altNamesist einCSVListField(kommagetrennt, keine Unterliste).AccountsController/CertificatesController: Bearbeiten heißtupdateAction, nichtsetAction(explizit anders als Trust-Store und alle VPN-Features geprüft, nicht durch Analogie angenommen).registerAction/signActionmit der oben beschriebenen{"result":"failed"}/{"response":"..."}-Form.
Manueller Schema-Schritt: keiner nötig. Alle 3 neuen Tabellen
(managed_certificates, acme_accounts, acme_certificate_requests)
sind brandneu — Base.metadata.create_all() legt sie beim ersten Start
automatisch an.
Neue Endpunkte:
| Methode | Pfad | Zweck |
|---|---|---|
| GET / POST | /api/certificates/managed |
Trust-Store-Zertifikate auflisten / anlegen |
| PUT | /api/certificates/managed/{id} |
Entwurf speichern; private_key optional (nur action=import) |
| POST | /api/certificates/managed/{id}/push |
Einzelner add/set-Call, sofort wirksam, kein Warn-Gate |
| DELETE | /api/certificates/managed/{id} |
Löschen; OPNsenses "in use"-Fehler wird 1:1 durchgereicht |
| GET / POST | /api/acme/accounts |
ACME-Accounts auflisten / anlegen |
| PUT | /api/acme/accounts/{id} |
Entwurf speichern; eab_hmac optional |
| POST | /api/acme/accounts/{id}/push |
Lokaler Config-Write, kein Warn-Gate |
| POST | /api/acme/accounts/{id}/register |
Echter Außenkontakt — im Frontend zweistufig gewarnt |
| DELETE | /api/acme/accounts/{id} |
Löschen; blockiert, solange eine Zertifikatsanfrage noch referenziert |
| GET / POST | /api/acme/certificates |
ACME-Zertifikatsanfragen auflisten / anlegen |
| PUT | /api/acme/certificates/{id} |
Entwurf speichern |
| POST | /api/acme/certificates/{id}/push |
Lokaler Config-Write inkl. Account-UUID-Injektion, kein Warn-Gate |
| POST | /api/acme/certificates/{id}/sign |
Echter, asynchroner Außenkontakt — zweistufig gewarnt, Ergebnis nur per Poll |
| POST | /api/acme/certificates/{id}/poll |
statusCode/statusLastUpdate/lastUpdate von OPNsense nachladen |
| DELETE | /api/acme/certificates/{id} |
Löschen |
Bewusst nicht in v1 enthalten (aus dem Plan übernommen): kein
Mehrfach-Host-Push (hostlokale UUIDs, siehe oben); keine
DNS-Provider-/Validierungsverwaltung (validations.validation) —
Betreiber konfiguriert das selbst in OPNsense, OPNexus referenziert nur
per manuell eingetragener UUID (validation_ref); kein eigener
Renewal-Scheduler — OPNsenses eigenes Cron/autoRenewal bleibt die
Quelle der Wahrheit, OPNexus zeigt nur an und bietet den manuellen
Sofort-Trigger; keine restartActions-Verwaltung (Post-Issuance-Hooks
wie HAProxy/Synology).
Validierung: 22 neue Backend-Tests (Trust-Store: Anlegen, Pflichtfeld-
Validierung je Aktion, privater Schlüssel nur bei action=import
erlaubt, Push, No-Op-Redeploy, Löschen vor erstem Push ohne Firewall-
Aufruf, Löschen eines in Verwendung befindlichen Zertifikats reicht
OPNsenses Fehlermeldung durch; ACME: Account anlegen, custom-CA
verlangt URL, Push inkl. leerem eab_hmac-Default, Regressionstest für
die {"result":"failed"}/{"response":"..."}-Antwortform (sowohl
Erfolgs- als auch Fehlerfall), Registrieren verlangt vorherigen Push,
Löschen eines noch referenzierten Accounts blockiert, Zertifikatsanfrage
verlangt zum Host passenden Account, UUID-Injektions-Regressionstest
inkl. No-Op- und Account-Wechsel-Szenario, sign markiert nie
fälschlich "success", Poll aktualisiert die Remote-Statusfelder), plus
die vollständige bestehende Suite weiterhin grün (277 Tests insgesamt,
vorher 255). Browser-Verifikation (Chromium/Playwright) bei 1440px und
375px auf /certificates — vollständig im Browser gemockter
/api/**-Schreibpfad (auch push/register/sign, die einzigen
Aktionen in diesem Projekt, die potenziell eine echte externe CA
erreichen könnten): keine Konsolenfehler, kein horizontaler
Seitenüberlauf, das zweistufige Warn-Gate für register/sign wurde
exakt wie spezifiziert durchlaufen (Warnung sichtbar → "Warnung
verstanden" löst noch keinen API-Call aus → erst der explizite
Bestätigen-Klick tut das), sign_status bleibt nach dem Trigger explizit
"triggered", nie "success" — siehe docs/verification/certificates-*.
Kein Schreib-, Registrier- oder Ausstellungs-Aufruf gegen
10.0.0.2/.3, irgendeinen anderen Host oder eine echte externe
Zertifizierungsstelle fand im Rahmen dieser Implementierung statt.
IPsec-VPN-Management: Site-to-Site, Pre-Shared-Key (2026-09-09)
Erste Schreib-Feature-Kategorie mit einem strukturell neuen Risiko gegenüber Firewall-Regeln/NAT/Aliasen (siehe Sicherheitsabschnitt unten) — deshalb bewusst zuletzt auf der Funktions-Roadmap umgesetzt, nach dem rein lesenden Log-Feature. v1-Scope: nur PSK-Authentifizierung (kein Zertifikat/EAP), ein Phase-2 (Child) pro Tunnel, einfache 1:1-Netz-zu-Netz-Tunnel (keine Pools, keine Wildcard-Traffic-Selektoren). Kein OpenVPN, kein WireGuard (beide technisch machbar, siehe Recherche — späterer Fast-Follow mit demselben konservativen Apply-Muster).
Sicherheitsrisiko strukturell anders als bei Firewall-Regeln (bewusste,
mit dem Nutzer abgestimmte Entscheidung, konservativ ohne Auto-Rollback):
Ein reconfigure des IPsec-Dienstes kann genau die Verbindung kappen,
über die OPNexus den Host erreicht — und der übliche Auto-Revert-
Mechanismus (siehe "Deploy-Sperrschutz" unten) kann dann selbst nicht mehr
greifen, weil der Host nicht mehr erreichbar ist. Anders als ein
Regel-Fehler (Grenzfall) ist das bei VPN ein Kernszenario (Fleet-
Management über Site-to-Site-Tunnel). Bei den beiden echten Hosts
(10.0.0.2/.3) ist das laut Topologie-Doku aktuell kein akutes
Risiko (direkt über Management-IPs erreichbar, nicht über einen von
OPNexus verwalteten Tunnel) — die konservative Lösung gilt trotzdem
generell, nicht nur für den aktuellen Fleet-Zustand:
- Deliberat einfacheres Status-Modell als bei Regeln: nur
draft(noch nie gepusht — abgeleitet aus dem Fehlen eines Deployment-Datensatzes, nicht persistiert),success,error. Keinpending, keinrevert_deadline, kein Bestätigen-Endpunkt, kein Hintergrund-Sweep/Auto-Revert —services/ipsec_deploy_service.pydokumentiert diese Abweichung ausführlich in seinem Modul-Docstring. - Ein Push schreibt nacheinander Connection → Local → Remote → Child →
Pre-Shared-Key; schlägt ein Schritt fehl, bleibt der bereits
geschriebene Teil-Stand unverändert stehen (kein Rollback-Mechanismus
existiert dafür), und das Deployment landet direkt auf
errormit Klartext-Fehlermeldung. Danach folgt separat derreconfigure-Call; scheitert nur dieser, lautet die Meldung exakt „Konfiguration wurde geschrieben, Dienst-Reload aber nicht bestätigt — Erreichbarkeit manuell prüfen" statt einer generischen Exception-Meldung, weil beide Fehlerarten unterschiedliche manuelle Reaktionen erfordern. - UI-Warnung vor jedem Apply: Vor jedem Push zeigt die
Prüfen-&-Push-Ansicht (
IpsecTunnelReview.tsx) einen auffälligen, nicht-beiläufigen Warnhinweis („Kein automatischer Rollback: falls dieser Host nur über den VPN-Tunnel erreichbar ist, den du gerade änderst, kann OPNexus die Verbindung nach einem Fehler NICHT automatisch wiederherstellen.") und verlangt ein explizites zweistufiges Bestätigen ("Warnung verstanden — Push vorbereiten" → "Ja, jetzt ausrollen") statt eines einfachen Toasts oder direkten Buttons. - Komposit-Entität
IpsecTunnelverbirgt 5 verlinkte OPNsense-Objekte (SwanctlConnection/Local/Remote/Child+ separates ModellOPNsense\IPsec\IPsecspreSharedKeys.preSharedKey) hinter einem Formular, das ein Admin tatsächlich als "eine Site-to-Site-VPN- Verbindung" denkt. - Targeting exakt wie bei Regeln (
DeviceGroup+ direkte Host-Ziele,policy_service.effective_hostsdirekt wiederverwendet, keine Neuerfindung) — der Hauptanwendungsfall ist ein identischer Tunnel auf beiden HA-Cluster-Mitgliedern. - Der Pre-Shared-Key selbst landet nie in der Datenbank — wie das
OPNsense-API-Secret pro Host (
opnsense_secret_store.py) liegt er nur auf Platte, eine Datei pro Tunnel-ID,chmod 600(services/ipsec_psk_store.py, neues Docker-Volumeipsec_psk_secrets). Die DB hält nurpre_shared_key_set. Beim Bearbeiten kann das Feld leer gelassen werden, um den bestehenden Key zu behalten (Muster wie beiNotificationSettings' SMTP-Passwort/ Webhook-Signing-Secret). - Löschen/Undeploy: normale explizite Aktion wie beim Regel-Löschen,
nicht durch irgendeinen Lockout gesperrt (es gibt hier keinen). Löscht
in Abhängigkeitsreihenfolge Child → Local → Remote → Connection (die
ModelRelationField-Beziehung zurück zur Connection-UUID), danach den Pre-Shared-Key (eigenständiges Modell, nur über String-Gleichheitident/remote_ident↔ Local/Remote-idverknüpft, keine UUID-Referenz), danachreconfigure. - Keine Interface-/Alias-Konsistenzchecks über mehrere Hosts (bei VPN-Endpunkten nicht einschlägig, anders als bei Regeln).
Verifizierte Fakten (gegen OPNsense-Core-Quellcode + Modell-XML
geprüft, 2026-09-08/09, siehe ausführliche Zitate im Modul-Docstring von
ipsec_deploy_service.py und im Kommentarblock in opnsense_client.py):
Swanctl.xml:Connections.Connection(Felder u. a.enabled,proposals,version,local_addrs/remote_addrs,description),locals.local/remotes.remote(separate Top-Level-Arrays mitconnection-ModelRelationFieldzurück zur Connection-UUID, nicht unter der Connection verschachtelt — Feldnamenenabled,connection,auth,id,description),children.child(ebenfalls eigenes Array mitconnection-Relationsfeld —esp_proposals,local_ts/remote_ts,description).IPsecProposalField(proposals/esp_proposals) direkt im Feldtyp-Quellcode verifiziert: eineBaseListField(kommagetrennt bei mehreren Werten) mit Cipher-Suite-Strings wieaes256-sha256-modp2048oder dem Literaldefault— Letzteres ist OPNsenses eigener Modell-Default für beide Felder und der Wert, den diese App verwendet, sofern nicht explizit geändert. Nicht gegen eine echtegetConnection-API-Antwort verifiziert, nur gegen Modell- und Feldtyp-Quellcode — als konservativste, quellcode-belegte Wahl dokumentiert.IPsec.xml:preSharedKeys.preSharedKey(separates ModellOPNsense\IPsec\IPsec, nicht Teil von Swanctl) — Felderident,remote_ident,keyType(DefaultPSK),Key(großes K),description. Verknüpfung zur Connection läuft nicht über eine UUID, sondern über String-Gleichheitident/remote_ident↔ der jeweiligen Local-/Remote-Identität (id-Feld) — bestätigt inConnectionsController::delConnectionAction()/connectionFilter().- Endpunkt-Pfad-Konvention (
/api/{modul}/{controller}/{aktion}) direkt gegenRouter.php::parsePath()verifiziert, nicht nur per Analogie angenommen: jedes URL-Segment durchläuftstr_replace('_', '', ucwords($segment, '_')), wodurch sowohl camelCase- als auch snake_case-Segmente ohne internen Unterstrich auf denselben PHP-Klassen-/Methodennamen abbilden (Erklärung, warum die bestehendeDNatController-Zuordnungd_natfunktioniert). Genaue Methodennamen (addConnectionAction,addLocalAction,addChildAction,PreSharedKeysController::addItemAction,ServiceController::reconfigureAction, …) direkt aus den PHP-Dateien gelesen, nicht geraten.
Manueller Schema-Schritt: keiner nötig. Alle 5 neuen Tabellen
(ipsec_tunnels, ipsec_tunnel_targets, ipsec_tunnel_groups,
ipsec_tunnel_deployments, ipsec_tunnel_revisions) sind brandneu —
Base.metadata.create_all() legt sie beim ersten Start mit diesem Code
automatisch an, keine Spaltenänderung an einer bestehenden Tabelle nötig
(anders als beim cluster_name-/Policy-Hierarchie-Präzedenzfall).
Neue Endpunkte:
| Methode | Pfad | Zweck |
|---|---|---|
| GET / POST | /api/ipsec/tunnels |
Tunnel auflisten / anlegen |
| PUT | /api/ipsec/tunnels/{id} |
Entwurf mit expected_updated_at speichern; pre_shared_key optional (leer = unverändert) |
| GET | /api/ipsec/tunnels/{id}/preview |
Vorschau/Vorschau-Token, inkl. Rollback-Warnung |
| POST | /api/ipsec/tunnels/{id}/deploy |
Push (Config-Schreiben + reconfigure in einem Aufruf — die Warn-Gate liegt im Frontend, nicht als zweiter Backend-Call) |
| DELETE | /api/ipsec/tunnels/{id} |
Tunnel von allen Ziel-Hosts entfernen und löschen |
| GET | /api/ipsec/tunnels/{id}/revisions |
Versionshistorie (Pre-Shared-Key selbst nie enthalten, nur pre_shared_key_set) |
| POST | /api/ipsec/tunnels/{id}/deployments/{host_id}/remove |
Frühere Zielkopie entfernen |
Bewusst nicht in v1 enthalten: Zertifikats-/EAP-Authentifizierung (nur PSK), Multi-Child-Support, Pools/Wildcard-Traffic-Selektoren, OpenVPN/WireGuard, Pending-/Bestätigen-/Auto-Revert-Fenster (siehe oben).
Validierung: 11 neue Backend-Tests (Anlegen, Pflicht-PSK beim
Anlegen, PSK beim Bearbeiten weglassbar, Vorschau inkl. Rollback-Warnung,
Deploy ohne Ziele abgelehnt, Deploy-Erfolg inkl. aller 5 UUIDs, fehlender
Config-Schritt landet als error ohne Rollback der bereits geschriebenen
Teile, fehlgeschlagenes reconfigure nach erfolgreichem Config-Schreiben
mit der exakten spezifizierten Meldung, No-Op-Redeploy überspringt
OPNsense komplett, Löschen räumt Child/Local/Remote/Connection/PSK in der
richtigen Reihenfolge ab), plus die vollständige bestehende Suite
weiterhin grün (222 Tests insgesamt). Browser-Verifikation (Chromium/
Playwright) bei 1440px und 375px auf /ipsec — anders als bei bisherigen
Features mit vollständig im Browser gemocktem Backend für den
gesamten /api/ipsec/tunnels-Schreibpfad (nicht nur host-lose Testdaten),
da dieses Feature laut eigener Risikoanalyse gefährlicher live zu testen
ist als Firewall-Regeln: keine Konsolenfehler, kein horizontaler
Seitenüberlauf, das zweistufige Warn-Gate wurde exakt wie spezifiziert
durchlaufen (Warnung sichtbar → "Push vorbereiten" löst noch keinen
API-Call aus → erst "Ja, jetzt ausrollen" tut das) — siehe
docs/verification/ipsec-*. Kein Schreib- oder Reconfigure-Aufruf
gegen 10.0.0.2/.3 oder irgendeinen anderen Host fand im Rahmen
dieser Implementierung statt.
OpenVPN-Instanz-Verwaltung (2026-09-09)
Fast-Follow zu IPsec, gleiches konservatives Apply-Muster (Config
schreiben → separates reconfigure, kein Auto-Rollback). Anders als
IPsec (5 verlinkte OPNsense-Objekte) ist OPNsenses moderne
"Instance"-Model (24.1+, OpenVPN.xml) größtenteils ein flaches Objekt —
außer dem optionalen TLS-Hardening-Key (tls-auth/tls-crypt), der
ein eigenständiges Objekt (StaticKeys.StaticKey) ist, per UUID vom
Instance referenziert. Diese Entität ist daher näher an einem
2-Objekt-Komposit (StaticKey optional zuerst, Instance referenziert
danach) als am perfekt flachen FirewallRule-Vorbild — dokumentiert
ausführlich im Modul-Docstring von openvpn_deploy_service.py.
Wichtiger Scope-Fund beim Quellcode-Abgleich, der den ursprünglich
angenommenen Fallback verändert hat: Die Recherche vor Feature-Beginn
ging davon aus, dass — falls Zertifikats-Integration zu aufwendig wäre —
ein reiner "Static-Key"-Auth-Modus (ohne Zertifikate) als v1-Fallback
diente, analog zu IPsecs PSK-only-Entscheidung. Der direkte Abgleich
gegen OpenVPN.xml zeigt: dieser Fallback existiert im modernen
Instance-Modell nicht — role hat nur die Werte client/server,
das alte (vor-24.1) zertifikatsfreie p2p_shared_key-Modus wurde bei der
Modell-Vereinheitlichung nicht übernommen. Jede Instance braucht also
unabhängig von der Rolle ein funktionierendes TLS-Handshake (cert +
ca, beides ModelRelationFields in OPNsenses eigenen Zertifikatsspeicher)
um tatsächlich Traffic zu transportieren; der tls_key (Static-Key) ist
nur eine zusätzliche Härtung des Kontrollkanals oben drauf, kein
Ersatz für Zertifikate.
Tatsächliche v1-Entscheidung: cert_ref/ca_ref sind reine, von
OPNexus nicht validierte Strings — die OPNsense-seitige UUID eines
bereits auf dem Ziel-Host vorhandenen Zertifikats/CA, die der Betreiber
selbst nachschlägt (z. B. über die bestehende, rein lesende
GET /api/hosts/{id}/certificates-Ansicht) und ins Formular einträgt —
exakt dasselbe Vertrauensmodell, das IPsec schon für
local_address/remote_address verwendet (OPNexus validiert nichts
gegen den Host, OPNsenses eigene API ist die Quelle der Wahrheit und
lehnt einen falschen Wert mit einer konkreten Fehlermeldung ab). Das
vermeidet die wirklich "tiefe Integration" dieses Problems — ein
host-übergreifender Zertifikats-Picker, der für einen Multi-Host-Push
passende UUIDs je Zielhost bräuchte (plausibel lösbar für ein HA-Paar,
dessen Konfigurations-XML per CARP-Sync identisch gehalten wird, aber
unverifiziert und ein eigenständiger Scope-Posten) — während trotzdem
eine tatsächlich funktionsfähige Instance entsteht, sofern der
Betreiber gültige, auf dem Host existierende UUIDs einträgt. Das
TLS-Hardening (Static-Key) wird dagegen vollständig End-to-End verwaltet
(dieselbe Fähigkeit, die OPNexus für IPsecs PSK schon hat) — die nächste
verfügbare Analogie zu "nur PSK", aber kein vollwertiger Ersatz dafür.
Zertifikats-Picker/-Verwaltung bleibt explizit einem späteren Schritt
vorbehalten, genau wie IPsec Zertifikate/EAP verschoben hat.
Gleiches deliberat einfaches Status-Modell wie IPsec (nur draft/
success/error, kein pending/Auto-Revert — dieselbe Begründung: ein
OpenVPN-reconfigure kann genau den Verwaltungsweg kappen, den OPNexus
für einen Auto-Revert bräuchte) und dasselbe zweistufige UI-Warn-Gate
(OpenVpnInstanceReview.tsx, wortgleiche Warnung mit "OpenVPN-Tunnel"
statt "VPN-Tunnel").
Deploy-Reihenfolge: StaticKey (nur falls tls_key_enabled) zuerst, dann
Instance (referenziert die StaticKey-UUID über tls_key). Wird
tls_key_enabled nachträglich deaktiviert, wird der verwaiste StaticKey
beim nächsten Push explizit gelöscht und tls_key explizit auf ""
gesetzt (nur das Weglassen eines Felds reicht bei
ApiMutableModelControllerBase::setBase() nicht, um eine bereits
gesetzte Relation zu löschen — dieselbe Erkenntnis, die IPsec schon für
sein local_wire/remote_wire/child_wire-Overlay-Muster dokumentiert).
Undeploy löscht in umgekehrter Reihenfolge (Instance vor StaticKey).
Verifizierte Fakten (direkt gegen raw.githubusercontent.com/opnsense/ core/master/... abgeglichen, 2026-09-09 — Rohdateien direkt
heruntergeladen statt nur eine KI-Zusammenfassung zu vertrauen, um
Detailfehler bei Feldnamen/Response-Shape auszuschließen):
OpenVPN.xml(Mount//OPNsense/OpenVPN) →Instances.Instance:vpnid(kein<Default>— wird nie gesendet, OPNsenses eigene UI fragt auch nie danach; nicht unabhängig verifiziert, ob/wieVPNIdFieldautomatisch einen vergibt, explizit als offener Punkt markiert statt geraten),enabled,dev_type/topology(Defaultstun/subnet, nie gesendet),proto(v1 nurudp/tcp),port,local,remote,role(nurclient/server— siehe Scope-Fund oben),server,cert/ca(ModelRelationField→Trust\Cert),tls_key(ModelRelationField→ dasselbe ModellsStaticKeys.StaticKey),description.OpenVPN.xml→StaticKeys.StaticKey(separates Top-Level-Array):mode(auth/crypt/crypt-v2, Defaultcrypt),key(Pflichtfeld, auf den Base64-artigen OpenVPN-Static-Key-Zeichensatz maskiert),description(Pflichtfeld).InstancesController.php(class InstancesController extends ApiMutableModelControllerBase,internalModelName = 'instance'):searchAction/getAction/addAction/setAction/delAction/toggleAction(Instance) sowiesearchStaticKeyAction/getStaticKeyAction/addStaticKeyAction/setStaticKeyAction/delStaticKeyAction(StaticKey,internalModelName 'statickey') — Methodennamen direkt aus dem Controller-Quellcode gelesen.genKeyAction(Server-seitige Schlüsselgenerierung) existiert, wird von OPNexus aber bewusst nicht aufgerufen — der Betreiber liefert das Schlüsselmaterial selbst, dieselbe Philosophie wie beim IPsec-PSK.ServiceController.php:reconfigureAction()— Methodenkörper direkt gelesen:(new Backend())->configdRun('openvpn configure'); $this->runInterfaceRegistration(); return ['result' => 'ok'];. Wichtig: Antwortschlüssel istresult, nichtstatuswie bei IPsecsreconfigureAction({"status": "ok"}) — explizit geprüft statt als symmetrisch angenommen (eigener Regressionstest:test_reconfigure_checks_result_key_explicitly). Auch in dieser Controller-Klasse vorhanden, aber ungenutzt:startServiceAction/stopServiceAction/restartServiceAction(Instanz-genaues Start/Stop/Restart) — diese App nutzt wie bei IPsec ausschließlich das globalereconfigureActionals einzigen Apply-Schritt.
Manueller Schema-Schritt: keiner nötig. Alle 5 neuen Tabellen
(openvpn_instances, openvpn_instance_targets,
openvpn_instance_groups, openvpn_instance_deployments,
openvpn_instance_revisions) sind brandneu —
Base.metadata.create_all() legt sie beim ersten Start automatisch an.
Neue Endpunkte:
| Methode | Pfad | Zweck |
|---|---|---|
| GET / POST | /api/openvpn/instances |
Instanzen auflisten / anlegen |
| PUT | /api/openvpn/instances/{id} |
Entwurf mit expected_updated_at speichern; tls_key_material optional (leer = unverändert) |
| GET | /api/openvpn/instances/{id}/preview |
Vorschau/Vorschau-Token, inkl. Rollback-Warnung |
| POST | /api/openvpn/instances/{id}/deploy |
Push (Config-Schreiben + reconfigure in einem Aufruf) |
| DELETE | /api/openvpn/instances/{id} |
Instanz von allen Ziel-Hosts entfernen und löschen |
| GET | /api/openvpn/instances/{id}/revisions |
Versionshistorie (Schlüsselmaterial nie enthalten, nur tls_key_material_set) |
| POST | /api/openvpn/instances/{id}/deployments/{host_id}/remove |
Frühere Zielkopie entfernen |
Bewusst nicht in v1 enthalten: Zertifikats-Picker/-Verwaltung (nur
manuell eingetragene UUIDs), zertifikatsfreie "Shared-Key"-Instanz (im
modernen OPNsense-Modell nicht vorgesehen, siehe oben), crypt-v2-Modus
(Server-/Client-Schlüsselhälfte-Unterscheidung, deferred), IPv4/IPv6-
gepinnte Protokoll-Varianten (udp4/tcp6/…), Authentifizierungsserver
(authmode/RADIUS/LDAP), Multi-Remote-Client-Konfiguration, Pending-/
Bestätigen-/Auto-Revert-Fenster (siehe oben).
Validierung: 17 neue Backend-Tests (Anlegen, Rollen-Validierung
Server↔server_network/Client↔remote, Vorschau inkl.
Rollback-Warnung, Deploy ohne Ziele abgelehnt, Deploy blockiert wenn
TLS-Hardening aktiviert aber kein Schlüsselmaterial hinterlegt,
Deploy-Erfolg inkl. Instance-UUID, StaticKey-vor-Instance-Reihenfolge,
fehlender Config-Schritt landet als error ohne Rollback,
fehlgeschlagenes reconfigure mit der exakten spezifizierten Meldung,
expliziter result-vs-status-Regressionstest, No-Op-Redeploy
überspringt OPNsense komplett, Deaktivieren von TLS-Hardening löscht den
verwaisten StaticKey, Löschen räumt Instance vor StaticKey ab, PUT kann
Schlüsselmaterial unverändert lassen), plus die vollständige bestehende
Suite weiterhin grün (239 Tests insgesamt, 222 Baseline + 17 neu).
Browser-Verifikation (Chromium/Playwright) bei 1440px und 375px auf
/openvpn — wie bei IPsec mit vollständig im Browser gemocktem
Backend für den gesamten /api/openvpn/instances-Schreibpfad: keine
Konsolenfehler, kein horizontaler Seitenüberlauf, das zweistufige
Warn-Gate wurde exakt wie spezifiziert durchlaufen (ein
Mutationszähler im Mock bestätigt: "Push vorbereiten" löst keinen
API-Call aus, erst "Ja, jetzt ausrollen" tut das) — siehe
docs/verification/openvpn-*. Kein Schreib- oder Reconfigure-Aufruf
gegen 10.0.0.2/.3 oder irgendeinen anderen Host fand im Rahmen
dieser Implementierung statt.
WireGuard-Tunnel-Verwaltung (2026-09-09)
Dritter und — vorerst — letzter Fast-Follow auf der VPN-Roadmap (nach
IPsec und OpenVPN), gleiches konservatives Apply-Muster (Config
schreiben → separates reconfigure, kein Auto-Rollback). WireGuard ist
seit OPNsense 24.1+ Core-Bestandteil (aus dem os-wireguard-Plugin nach
opnsense/core verschoben — die Flotte läuft auf 26.7.3_11, deutlich
danach) — kein Plugin-Check nötig.
Objektmodell abweichend von beiden Vorgänger-Features: WireGuards
reales Modell ist ein Server (das lokale wg-quick-Interface: Schlüssel,
Listen-Port, eigenes Tunnelnetz), der über ein kommagetrenntes peers-Feld
NULL oder mehrere Client-Objekte (Peers) referenziert — eine echte
1:n-Beziehung, weder IPsecs 5-Objekt-Komposit noch OpenVPNs nahezu flaches
Einzelobjekt. Die Verknüpfung selbst ist denormalisiert und
bidirektional gepflegt: Client.servers ist im Modell-XML
volatile="true" (nie persistiert, nur beim Laden aus jedem Server's
peers zurückgerechnet) — die einzige persistierte Liste ist
Server.peers, und OPNsenses eigener ClientController::setClientAction/
delClientAction pflegt sie als Nebeneffekt des Peer-Schreibens, wenn
diese App im Peer-Payload ein servers-Feld (Server-UUID) mitschickt.
Diese App liest oder schreibt Server.peers deshalb nie selbst —
direkt aus ClientController.php verifiziert, nicht angenommen.
Scope-Entscheidung Entität: Trotz der echten 1:n-Möglichkeit bildet
WireguardTunnel genau einen Server + genau einen Peer pro Zeile ab —
der realistische v1-Flotten-Anwendungsfall (eine Verbindung zwischen zwei
verwalteten Firewalls, oder einer verwalteten Firewall und einem externen
Peer) ist 1:1, exakt wie IPsecs Site-to-Site-Tunnel-Abstraktion. Ein
echter Multi-Peer-Hub (ein Server, viele Clients) ist ein bewusst
zurückgestellter Gap, kein Versehen — dokumentiert im Modul-Docstring von
wireguard_deploy_service.py. Damit ist die Entität näher an OpenVPNs
2-Objekt-Komposit-Form (Instance + optionaler StaticKey) als an IPsecs
5-Objekt-Form.
Verifizierte Fakten (direkt gegen raw.githubusercontent.com/opnsense/ core/master/... abgeglichen, 2026-09-09 — Rohdateien direkt
heruntergeladen, u. a. via api.github.com-Verzeichnislisting, um
sicherzustellen, wirklich alle relevanten Dateien gefunden zu haben):
- Wichtige Korrektur gegenüber der Vorab-Recherche dieser Session:
Die vor Implementierungsbeginn angenommene Endpunkt-Namenskonvention
(bare
addAction/setActionwie bei OpenVPNsInstancesController) stimmt nicht —ServerController.php/ClientController.phpbenennen jede CRUD-Aktion nach dem Zielmodell (addServerAction/setServerAction/…,addClientAction/setClientAction/…), analog zu IPsecsaddConnection/addLocal-Konvention. Direkt aus dem Controller-Quellcode gelesen, nicht aus der Vorab-Recherche übernommen. Server.xml(Mount//OPNsense/wireguard/server) →servers.server:enabled,name(Pflicht, Maske/^([0-9a-zA-Z._\-]){1,64}$/u),instance(AutoNumberField, wird nie gesendet — automatische Vergabe direkt im Feldtyp-Quellcode bestätigt, dieselbe "nie senden"-Präzedenz wie OpenVPNsvpnid),pubkey/privkey(Base64Field — kein<Required>beipubkey, und keine Ableitung vonprivkeyirgendwo im Modell-/Feldtyp-Code, direkt gegenServerField.phpverifiziert: beide müssen vom Betreiber geliefert werden),port,mtu,dns,tunneladdress(NetMaskRequired),disableroutes/gateway/carp_depend_on(v1 ungenutzt),peers(ModelRelationField →Client, s. o. — nie direkt geschrieben),debug(v1 immer0).Client.xml(Mount//OPNsense/wireguard/client) →clients.client:enabled,name(gleiche Maske),pubkey(Pflicht),psk(Base64Field, optional — WireGuards eigener Pre-Shared-Key, nicht zu verwechseln mit IPsecs PSK),tunneladdress(Pflicht — dieses Peers Allowed-IPs),serveraddress/serverport(optional — der Endpoint, den diese Box wählt, um den Peer zu erreichen; nicht unabhängig verifiziert über die Feldplatzierung/Kommentare im Quellcode hinaus, ob configd sie exakt so konsumiert — als offener Punkt markiert statt geraten),endpoint(volatile="true", Laufzeit-Status, nie geschrieben),keepalive(optional),servers(ModelRelationField, ebenfallsvolatile="true"— s. o.),privkey(nur vom Config-Builder-Assistenten genutzt, den diese App nicht baut — ein Peer braucht hier nur seinen eigenen Public Key).ServiceController.php:reconfigureAction()— Methodenkörper direkt gelesen:$this->runInterfaceRegistration(); $backend->configdRun('template reload ...'); $backend->configdpRun('wireguard configure'); return ['result' => 'ok'];. Antwortschlüsselresult, wie bei OpenVPN, nichtstatuswie bei IPsec — explizit geprüft, eigener Regressionstest (test_reconfigure_checks_result_key_explicitly).ServerController.php:keyPairAction(configdRun('wireguard gen_keypair')) existiert, wird von OPNexus bewusst nicht aufgerufen — der Betreiber liefert beide Schlüsselhälften selbst, dieselbe Philosophie wie beim IPsec-PSK/OpenVPN-Static-Key.ClientController.php:pskAction(configdRun('wireguard gen_psk')) ebenso ungenutzt.- Bewusst nicht angetastet:
GeneralController/General.xml(//OPNsense/wireguard/general) trägt einen separaten, dienstweitenenabled-Schalter (Default0), an denServiceControllers$internalServiceEnabledgekoppelt ist —reconfigureActionselbst liest ihn aber nie (läuft immer unbedingt). Obwireguard configurebeiGeneral.enabled = 0trotzdem ein funktionierendes Interface hochzieht, ist nicht verifiziert (liegt in configds eigenem Action-Script, außerhalb der Controller-/Modell-Ebene, die diese App als Prüfmaßstab ansetzt) — Betreiber sollten „VPN > WireGuard > Allgemein" einmalig in der OPNsense-UI aktivieren, bevor sie sich auf von OPNexus gepushte Tunnel verlassen.
Deploy-Reihenfolge: Server zuerst (UUID muss existieren, bevor der
Peer sie über servers referenzieren kann — falsche Reihenfolge würde
laut Quellcode keinen Fehler, sondern einen stillen, nicht verknüpften
Peer erzeugen), dann Peer (Payload trägt servers = Server-UUID; OPNsense
pflegt Server.peers server-seitig als Nebeneffekt). Undeploy löscht in
umgekehrter Reihenfolge (Peer vor Server — delClientAction entfernt den
Peer server-seitig automatisch aus jeder peers-Liste, kein manueller
Unlink-Schritt nötig), danach reconfigure.
Gleiches deliberat einfaches Status-Modell wie IPsec/OpenVPN (nur
draft/success/error) und dasselbe zweistufige UI-Warn-Gate
(WireguardTunnelReview.tsx, wortgleiche Warnung mit
"WireGuard-Tunnel").
Peer-Pre-Shared-Key: v1 unterstützt WireGuards eigenen optionalen
Pre-Shared-Key pro Peer (peer_psk_enabled/peer_psk_material), disk-only
gespeichert wie der Server-Privatschlüssel (wireguard_key_store.py, neues
Docker-Volume wireguard_key_secrets) — strukturell einfacher als
OpenVPNs Static-Key, weil psk ein einzelnes Feld direkt am Peer ist,
kein eigenständiges, separat zu löschendes OPNsense-Objekt.
Manueller Schema-Schritt: keiner nötig. Alle 5 neuen Tabellen
(wireguard_tunnels, wireguard_tunnel_targets, wireguard_tunnel_groups,
wireguard_tunnel_deployments, wireguard_tunnel_revisions) sind
brandneu — Base.metadata.create_all() legt sie beim ersten Start
automatisch an.
Neue Endpunkte:
| Methode | Pfad | Zweck |
|---|---|---|
| GET / POST | /api/wireguard/tunnels |
Tunnel auflisten / anlegen |
| PUT | /api/wireguard/tunnels/{id} |
Entwurf mit expected_updated_at speichern; server_private_key/peer_psk_material optional (leer = unverändert) |
| GET | /api/wireguard/tunnels/{id}/preview |
Vorschau/Vorschau-Token, inkl. Rollback-Warnung |
| POST | /api/wireguard/tunnels/{id}/deploy |
Push (Server + Peer schreiben + reconfigure in einem Aufruf) |
| DELETE | /api/wireguard/tunnels/{id} |
Tunnel von allen Ziel-Hosts entfernen und löschen |
| GET | /api/wireguard/tunnels/{id}/revisions |
Versionshistorie (Schlüsselmaterial nie enthalten) |
| POST | /api/wireguard/tunnels/{id}/deployments/{host_id}/remove |
Frühere Zielkopie entfernen |
Bewusst nicht in v1 enthalten: Schlüsselpaar-Generator (keyPairAction/
pskAction bewusst ungenutzt, siehe oben), Multi-Peer-Hub (ein Server,
viele Peers — siehe Scope-Entscheidung oben), Config-Builder/Peer-Export
(getClientBuilderAction/addClientBuilderAction), Live-Status
(wg show via ServiceController::showAction), CARP-Abhängigkeit
(carp_depend_on), disableroutes/gateway, Pending-/Bestätigen-/
Auto-Revert-Fenster (siehe oben).
Validierung: 16 neue Backend-Tests (Anlegen, Namens-Maskenvalidierung,
Endpoint-Adresse-erfordert-Port-Validierung, Vorschau inkl.
Rollback-Warnung, Deploy ohne Ziele abgelehnt, Deploy blockiert wenn
Peer-PSK aktiviert aber kein Material hinterlegt, Deploy-Erfolg inkl.
Server- und Peer-UUID, Nachweis dass die servers-Feld-Injektion in der
richtigen Reihenfolge passiert, fehlender Config-Schritt landet als
error ohne Rollback, fehlgeschlagenes reconfigure mit der exakten
spezifizierten Meldung, expliziter result-Regressionstest, No-Op-Redeploy
überspringt OPNsense komplett, Löschen räumt Peer vor Server ab,
PUT kann Schlüsselmaterial unverändert lassen), plus die vollständige
bestehende Suite weiterhin grün (255 Tests insgesamt, 239 Baseline + 16
neu). Browser-Verifikation (Chromium/Playwright) bei 1440px und 375px auf
/wireguard — wie bei IPsec/OpenVPN mit vollständig im Browser
gemocktem Backend für den gesamten /api/wireguard/tunnels-Schreibpfad:
keine Konsolenfehler, kein horizontaler Seitenüberlauf, das zweistufige
Warn-Gate wurde exakt wie spezifiziert durchlaufen (ein Mutationszähler im
Mock bestätigt: "Push vorbereiten" löst keinen API-Call aus, erst "Ja,
jetzt ausrollen" tut das) — siehe docs/verification/wireguard-*. Kein
Schreib- oder Reconfigure-Aufruf gegen 10.0.0.2/.3 oder irgendeinen
anderen Host fand im Rahmen dieser Implementierung statt.
Damit ist die auf der Funktions-Roadmap benannte VPN-Management-Kategorie (IPsec + OpenVPN + WireGuard) vollständig abgedeckt — keine weiteren VPN-Fast-Follows offen.
Hierarchische Policies: Pre-/Post-Rulebases mit Vererbung (2026-09-08)
Nächster Schritt auf der Funktions-Roadmap (siehe bisheriger "Abgrenzung"-Hinweis unter "Zentrale Policies und Gerätegruppen"): Regeln lassen sich jetzt auf einer übergeordneten Gruppe definieren und werden automatisch, in korrekter Pre-/Post-Reihenfolge, auf alle Hosts dieser Gruppe und ihrer Untergruppen ausgerollt — mit demselben Deploy-Sperrschutz/Rollback-Sicherheitsnetz wie bei normalen Regeln.
Sicherheitsrelevanter Kontext: Jede von OPNexus gepushte Regel setzt
"quick": "1" — OPNsense wertet also strikt first-match aus. Reihenfolge
ist damit kein kosmetisches Detail: eine permissive Regel vor einer
Deny-Pre-Regel hebelt diese komplett aus. Das Feature stellt die
Reihenfolge deshalb aktiv über moveRuleBefore-Aufrufe her, statt sie nur
implizit über die Push-Reihenfolge entstehen zu lassen.
- Neuer, separater Baum (
/policy-hierarchy):PolicyHierarchyGroupist bewusst nichtDeviceGroup—DeviceGrouperlaubt Mehrfachmitgliedschaft pro Host, was bei einem Baum zu mehrdeutigen Ancestor-Ketten führen würde. Ein Host gehört zu höchstens einerPolicyHierarchyGroup(neue Spaltepolicy_hierarchy_group_idaufopnsense_hosts, exakt nach demcluster_name-Präzedenzfall inkl.clear_policy_hierarchy_group_id-Flag im Host-Update). - Neue, separate Regel-Entität
PolicyHierarchyRule(eigene Tabelle, eigener Deploy-Servicepolicy_hierarchy_deploy_service.py) stattFirewallRulezu erweitern — null Regressionsrisiko für den bestehenden, sicherheitskritischen Rule-Deploy-Pfad. Felder wieFirewallRule, plushierarchy_group_id,rulebase(pre/post),sequence(Drag & Drop mit atomarem Backend-Write; Pfeil-Buttons bleiben als Tastatur-/Fallback-Bedienung erhalten). - Zwei Traversierungs-Richtungen:
- Abwärts (
effective_hosts): von der Autoren-Gruppe einer Regel zu allen Nachfahren-Gruppen — für Targeting/Vorschau/Deploy. - Aufwärts (
ordered_hierarchy_rules): über die gesamte Ancestor-Kette eines Hosts — für die tatsächliche Deploy-Reihenfolge über mehrere Hierarchie-Ebenen hinweg. Pre-Regeln: root-most Gruppe zuerst (eine organisationsweite Pre-Regel muss vor allem Spezifischeren gewinnen). Post-Regeln: leaf-most (spezifischste) Gruppe zuerst (das "letzte Wort" der spezifischsten Gruppe steht näher an einem eventuellen host-lokalen Default-Deny) — genau die übliche Semantik für Pre-/Post-Rulebases.
- Abwärts (
- Reassert/Deploy-Ordering-Algorithmus:
moveRuleBefore(selected, target)ist ein reines Pairwise-Primitiv ohne Sentinel für "an den Anfang/ans Ende". Eine Zielreihenfolge[r1..rN]wird deshalb rückwärts verkettet hergestellt: fürivonNrunter bis2,moveRuleBefore(r[i-2], r[i-1]). Vorwärts-Verketten ist nachweislich falsch (bereits fixierte Paare werden durch spätere Moves wieder auseinandergerissen). Läuft automatisch nach jedem erfolgreichen Deploy einer Hierarchie-Regel (für deren eigenes Interface, beide Rulebases), automatisch nach normalen Regel-, Alias- und NAT-Deploys sowie manuell über den Button "Reihenfolge neu herstellen" pro Gruppe (POST /api/policy-hierarchy/groups/{id}/reassert-ordering).searchRuleliefert dabei die tatsächliche OPNsense-Reihenfolge jeprio_group: fremde MVC-Regeln behalten ihre relative Reihenfolge, Hierarchie-Pre- Regeln werden davor und Post-Regeln danach einsortiert. Nicht verschiebbare Legacy-/Automatikregeln bleiben unangetastet. - Deploy-Fehler nach erfolgreichem Push: Schlägt
moveRuleBeforefehl, nachdemaddRule/setRule+applybereits erfolgreich waren (Regel ist live, aber falsch positioniert), geht das Deployment direkt auf Statuserror, nichtpending— eine live, aber falsch positioniertequick-Regel ist sicherheitsrelevant falsch, nicht bloß unbestätigt. - Zyklus-Schutz: Anlegen/Ändern einer Gruppe lehnt einen
parent_idmit 409 ab, falls die Gruppe im Ancestor-Pfad des vorgeschlagenen Parents vorkäme (direkt oder über mehrere Ebenen). - Multi-Host-Konsistenzchecks (
check_interface_consistency,check_alias_consistency) laufen beim Hierarchy-Rule-Deploy exakt wie beim normalen Regel-Deploy.
Manueller Schema-Schritt (Pflicht, wie schon bei cluster_name und den
Deploy-Sperrschutz-Spalten): Base.metadata.create_all() legt die vier
neuen Tabellen (policy_hierarchy_groups, policy_hierarchy_rules,
policy_hierarchy_rule_deployments, policy_hierarchy_rule_revisions)
beim Start automatisch an — aber nicht die neue Spalte
policy_hierarchy_group_id auf der bereits existierenden Tabelle
opnsense_hosts. Nach dem ersten Start mit diesem Code (der die vier
neuen Tabellen anlegt) manuell nachziehen:
docker compose exec postgres psql -U opnexus -d opnexus \
-c "ALTER TABLE opnsense_hosts ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL;"
Erweiterung 2026-09-10: Aliase sowie Port-Forward-, 1:1- und Outbound- NAT-Regeln können optional ebenfalls auf genau einen Hierarchie-Teilbaum zielen. Direkte Hosts und normale Gerätegruppen bleiben additiv kombinierbar. Die vier vorhandenen Tabellen benötigen dafür diese additive Migration:
docker compose exec postgres psql -U opnexus -d opnexus -v ON_ERROR_STOP=1 \
-c "ALTER TABLE firewall_aliases ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL; ALTER TABLE port_forward_rules ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL; ALTER TABLE one_to_one_rules ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL; ALTER TABLE outbound_nat_rules ADD COLUMN IF NOT EXISTS policy_hierarchy_group_id UUID REFERENCES policy_hierarchy_groups(id) ON DELETE SET NULL;"
Erst danach nutzen routers/hosts.py/schemas/host.py die neue Spalte
gefahrlos. Kein docker volume rm, keine Schreibzugriffe auf
10.0.0.2/.3 — reine additive Postgres-DDL gegen die laufende
Instanz, genau wie beim cluster_name-Präzedenzfall.
Neuer verifizierter API-Endpunkt:
| Zweck | Methode | Pfad | Status |
|---|---|---|---|
| Regel vor eine andere schieben | POST | /api/firewall/filter/moveRuleBefore/{selected_uuid}/{target_uuid} |
verifiziert (Core-Quellcode, FilterController::moveRuleBeforeAction) — reines Pairwise-Primitiv, kein "an den Anfang/ans Ende"-Sentinel; beide UUIDs müssen existierende Regeln im selben prio_group (Interface/Typ) sein; Erfolgsantwort {"status":"ok"} (kein result-Schlüssel wie bei addRule/setRule) — entspricht dem apply()-Branch in _mutation_result |
Weitere neue API-Endpunkte (analog zu /api/rules, siehe oben):
| Methode | Pfad | Zweck |
|---|---|---|
| GET / POST | /api/policy-hierarchy/groups |
Hierarchie-Gruppen auflisten / anlegen |
| PUT / DELETE | /api/policy-hierarchy/groups/{id} |
Gruppe ändern (inkl. Zyklus-Check) / löschen (blockiert bei Kindern/Regeln) |
| POST | /api/policy-hierarchy/groups/{id}/reassert-ordering |
Reihenfolge auf allen Hosts der Gruppe und ihrer Untergruppen manuell neu herstellen |
| GET / POST | /api/policy-hierarchy/rules |
Hierarchie-Regeln auflisten / anlegen |
| PUT | /api/policy-hierarchy/rules/{id} |
Regelentwurf mit expected_updated_at speichern |
| POST | /api/policy-hierarchy/rules/{id}/move |
Sequenz mit Nachbarregel tauschen ({"direction":"up"|"down"}) |
| PUT | /api/policy-hierarchy/groups/{group_id}/rules/{pre|post}/order |
Komplette Reihenfolge atomar speichern (Drag & Drop mit Set- und Konfliktprüfung) |
| GET | /api/policy-hierarchy/rules/{id}/preview |
Vorschau/Vorschau-Token |
| POST | /api/policy-hierarchy/rules/{id}/deploy | /confirm |
Push / Bestätigen |
| GET | /api/policy-hierarchy/rules/{id}/revisions |
Versionshistorie |
| POST | /api/policy-hierarchy/rules/{id}/deployments/{host_id}/remove | /retry-rollback |
Frühere Zielkopie entfernen / Rollback wiederholen |
Aktuelle Grenzen:
- Alias und NAT erben die Ziel-Hosts des Teilbaums, besitzen aber bewusst keine Pre-/Post-Rulebase: diese Reihenfolgesemantik gehört ausschließlich zum Filter-Regelwerk.
- Eine per Drag & Drop gespeicherte neue Reihenfolge wird bewusst nicht als versteckter Firewall-Write ausgeführt. Bereits ausgerollte Regeln werden anschließend über den sichtbaren Button "Reihenfolge neu herstellen" angewendet; andere OPNexus-Deployments reasserten automatisch.
- OPNsenses
moveRuleBeforedarf nur MVC-Regeln derselbenprio_groupverschieben. Legacy-/Automatikregeln werden daher weder als Zielanker verwendet noch verändert. - Keine Schreibtests gegen die echten Produktiv-Hosts (
10.0.0.2/.3) im Rahmen dieser Implementierung — ausschließlich respx-Mocks im Backend und reale, aber host-lose Testdaten (0 Ziel-Hosts, also kein möglicher OPNsense-Aufruf) im Browser. Echte Schreibtests inklusivemoveRuleBeforesind ein separater, gesondert abzustimmender Schritt.
Validierung: 24 neue Backend-Tests bestanden (CRUD/Vorschau/
Bearbeitungskonflikt, Deploy-Sperrschutz inkl. moveRuleBefore-Aufrufen
und Positionierungsfehlern, Reihenfolge-Richtung über mehrere
Hierarchie-Ebenen für Pre- und Post-Regeln, Gruppen-Zyklusschutz/
Löschsperren), plus die vollständige bestehende Suite weiterhin grün
(191 Tests insgesamt). Browser-Verifikation (Chromium/Playwright) bei
1440px und 375px auf /policy-hierarchy (Gruppenbaum, Pre-/Post-Tabellen,
Pfeil-Buttons, Prüfen-&-Push-Dialog mit host-loser Regel) und der um eine
"Policy-Hierarchie"-Spalte/-Auswahl erweiterten /hosts-Seite: keine
Konsolenfehler, kein horizontaler Seitenüberlauf. Die beiden echten Hosts
blieben dabei unangetastet (policy_hierarchy_group_id weiterhin NULL
für beide) — nur lesende Statusabfragen liefen im Hintergrund weiter, wie
schon zuvor.
Policy-Hierarchie vervollständigt 2026-09-10: Hierarchie-Targeting für
Aliase und alle drei NAT-Familien, dynamisch auch für später hinzugefügte
Untergruppen/Hosts; Löschschutz für referenzierte Hierarchiegruppen;
automatisches Reassert nach normalen Regel-, Alias- und NAT-Deploys;
Live-MVC-Bracketing (Pre vor, Post nach vorhandenen verschiebbaren Regeln)
auf Basis von searchRule; sowie Drag & Drop über einen atomaren Batch-
Endpunkt mit vollständigem Set-Abgleich und updated_at-Konfliktschutz.
12 zusätzliche Backend-Regressionstests, volle Suite 331 grün. Die additive
Migration wurde auf der lokalen Dev-DB ausgeführt. Produktive Firewalls
blieben unangetastet; echte Schreibvalidierung bleibt bewusst ein separater
Laborschritt. Browser-/Build-Nachweise: docs/verification/policy-hierarchy- productization-*.
Der im Handoff dokumentierte kopierte Seitentitel im Outbound-NAT-Panel
ist ebenfalls korrigiert (Outbound NAT statt Port Forward).
Zentrale Log-Suche, Korrelation und Dashboards (2026-09-08)
Nächster Punkt auf der Funktions-Roadmap: der bisherige /logs-
Viewer war ein reiner Live-Merge (pro Host/Cluster GET /diagnostics/firewall/log bei jedem Seitenaufruf, alles im Browser
zusammengemischt/sortiert/paginiert, keine Persistenz, keine echte
Suche). Das reichte für "Logs ansehen", aber nicht für Suche über einen
Zeitraum, Filter-Kombinationen oder Aggregationen — dafür muss OPNexus
die Logs selbst einsammeln und durchsuchbar machen.
- Neu verifiziert gegen OPNsense-Core-Source (
scripts/filter/ read_log.py, aufgerufen vonDiagnostics/Api/FirewallController:: logAction()): der bestehende EndpunktGET /diagnostics/firewall/logakzeptiert einen optionalendigest-Parameter — den MD5-Hash der zuletzt gesehenen Logzeile. Ist er gesetzt, liefert OPNsense nur noch Zeilen, die neuer sind als dieser Hash (die Logs werden intern rückwärts-chronologisch gelesen und beim passenden Digest abgebrochen). Jeder zurückgelieferte Datensatz trägt selbst ein__digest__-Feld — genau der Cursor-Wert für den nächsten Poll. Das ist die Grundlage für effizientes inkrementelles Einsammeln, ohne bei jedem Zyklus alles neu zu holen. - Neue Tabelle
firewall_log_entries: persistiert jede eingesammelte Logzeile (Zeit, Aktion, Interface, Richtung, Protokoll, Quelle/Ziel inkl. Port, Label, volle Rohzeile als JSON, der OPNsense-Digest). Ein Unique-Constraint auf (host_id,digest) verhindert Duplikate beim Einsammeln (ON CONFLICT DO NOTHING, dasselbe Upsert-Muster wie schon beim Theme-Preferences-Endpoint). Anders alsaudit_events(siehemodels/audit.py) ist diese Tabelle bewusst nicht unveränderlich — Pruning nach Aufbewahrungsfrist ist hier Kernanforderung, kein Bug. - Neue Tabelle
host_log_cursor: hält je Host den zuletzt gesehenen Digest sowie den Zeitpunkt der letzten Abfrage — als eigene Tabelle statt einer weiteren Spalte aufopnsense_hosts, damit für dieses Feature kein manuellerALTER TABLEan der bestehenden Tabelle nötig ist (siehe "Schema-Änderungen" unten — beide Tabellen sind komplett neu,Base.metadata.create_all()legt sie beim Neustart automatisch an). - Neue Settings-Tabelle
log_settings(Singleton-Zeile, exakt nach dem Muster vonNotificationSettings): Abfrageintervall (Default 30s, gefloort bei 10s überLOG_POLL_MIN_INTERVAL_SECONDS, analog zum Notifications-Intervall), Aufbewahrungsfrist in Tagen (Default 30) und ein Ein/Aus-Schalter. - Dritter Hintergrund-Loop (
_log_ingest_poll_loopinmain.py, neben dem Deploy-Revert-Sweep und dem Notification-Poll): pollt jeden aktivierten Host überlog_ingest_service.poll_host(), das den gespeicherten Digest-Cursor mitschickt, neue Zeilen einfügt und den Cursor auf den neuesten Digest vorrückt. Ein Host-Fehler (nicht erreichbar) überspringt nur diesen Host, ohne den Zyklus für die anderen abzubrechen. Alle 20 Zyklen läuft zusätzlich das Pruning (prune_old_entries) — bewusst nicht bei jedem Tick, weil eine zeitstempelbasierteDELETE-Abfrage auf einer wachsenden Tabelle keine Poll-Taktung braucht. GET /logsneu gegen die eigene Datenbank statt Live-Fetch:host_id(jetzt optional — weglassbar für "alle Hosts"),cluster_name, Freitextq(ILIKEüber Label/Quelle/Ziel — bei zwei Hosts und moderatem Aufkommen bewusst ohne Trigram-Index, siehe unten),action/interface/protocol,since/until,offset/limitmit Gesamtzahl für serverseitige Pagination.- Neuer
GET /logs/stats-Endpoint: feste Aggregat-Queries (keine generische Analytics-Engine) über denselben Filtersatz — Anzahl nach Aktion, Anzahl nach Interface, Top 10 geblockte Quell-IPs, Top 10 Zielports. - Neuer
GET /logs/filter-options-Endpoint: liefert die tatsächlich vorkommenden Aktion-/Interface-/Protokoll-Werte aus den eigenen, persistierten Daten für die Filter-Dropdowns — ersetzt die bisherige Abhängigkeit von OPNsenseslogFiltersAction(die ohnehin pro Host unterschiedliche Werte liefern könnte und für eine host-übergreifende Ansicht nicht mehr passt). - Frontend (
/logs) umgebaut: Suchfeld, Filter-Dropdowns (Aktion/ Interface/Protokoll, aus/logs/filter-options), Zeitraum-Presets (letzte Stunde/24h/7 Tage/gesamter Zeitraum/benutzerdefiniert) und echte serverseitige Pagination statt der bisherigen Client-seitigen Array-Slice-Paginierung. Neues Stats-Panel über den Aktions-/ Interface-Zählungen sowie Ranglisten (einfache, direkt beschriftete Balken in einer festen Akzentfarbe je Panel, keine generische Chart-Bibliothek) für Top-geblockte-Quellen und Top-Zielports. - Bekannte Einschränkung, beim Live-Test gegen die echten Hosts
entdeckt: OPNsenses
__timestamp__-Feld liefert einen reinenYYYY-MM-DDTHH:MM:SS-String ohne Zeitzonen-Offset (z. B."2026-09-08T12:45:05") — das ist die Uhrzeit der Firewall selbst, wie sie konfiguriert ist, nicht garantiert UTC. OPNexus speichert diesen Wert unkorrigiert; ist die Firewall nicht auf UTC gestellt, sind alle gespeicherten Zeitstempel (und damitsince/until-Filter) um genau diesen festen Offset verschoben. Die API liefert kein separates Feld, um das zu korrigieren — eine geratene Korrektur ohne Bestätigung der tatsächlich konfigurierten Zeitzone hätte nur eine unbestätigte Annahme gegen eine andere getauscht, siehe Arbeitskonventionen zur API-Verifikationsdisziplin.
Nicht in v1: kein echtes Live-Streaming (SSE/WebSocket) — das
Poll-Intervall macht die Ansicht "quasi live", ohne eine dauerhafte
Verbindung offenzuhalten. Keine Volltext-/Trigram-Indizierung (pg_trgm)
für die ILIKE-Suche — bei den heutigen zwei Hosts und moderatem
Log-Aufkommen ausreichend performant; falls die Suche spürbar langsam
wird, ist das die naheliegende spätere Optimierung.
Validierung: 20 neue Backend-Tests (Digest-Cursor-Verhalten inkl.
Dedupe über ON CONFLICT, Cursor-Fortschritt, Pruning, Router-Suche/
-Filter/-Pagination/-Stats) bestanden, volle bestehende Suite weiterhin
grün (211 Tests insgesamt). Browser-Verifikation bei 1440px/375px: keine
Konsolenfehler, kein horizontaler Seitenüberlauf; ein beim ersten
Durchlauf gefundener echter Bug (since/until wurden bei jedem Render
neu aus Date.now() berechnet, was einen Endlos-Fetch-Loop im
Stats-Panel auslöste und dabei den Debounce der Freitextsuche
dauerhaft zurücksetzte, sodass die Tabelle nie aktualisierte) wurde
gefunden und mit useMemo behoben, danach erneut verifiziert. Da diese
Ansicht ausschließlich die eigene Datenbank abfragt, löst allein das
Öffnen der Seite keinen OPNsense-Aufruf aus — die einzigen echten
Aufrufe kommen vom neuen, bereits laufenden Ingest-Loop selbst (reine
GET-Anfragen gegen 10.0.0.2/.3, identisch zu dem, was die App
vorher schon on-demand abgerufen hat, jetzt nur automatisch alle 30s).
Details: docs/verification/log-search-*.
NAT, Host-Wartung, Einstellungen und Deploy-Audit (2026-09-08)
- NAT (
/nat): Port Forward, 1:1 NAT und Outbound NAT mit getrennten Backend-Services und Formularen. Entwurf → Vorschau → Push → Bestätigen, Revisionen, Duplizieren, Gruppen-/Einzelziele, frühere Ziele bereinigen und Rollback erneut versuchen. Die Live-Ansicht ist lesend und trennt automatische OPNsense-Regeln von konfigurierten Regeln. Die Push-Vorschau vergleicht den zuletzt gespeicherten Payload, nicht den aktuellen Firewall-Stand. - Port Forward sendet
disabledinvertiert und verschachteltesource/destination-Objekte.passbleibt leer: eine passende Filterregel muss separat vorhanden sein. 1:1/Outbound NAT verwenden dagegen flache Felder unddescription; 1:1 Reflection verwendetenablestattpurenat. - Outbound-Modus wird lesend angezeigt und vor jedem Push geprüft.
automatic,disabled, unbekannte oder nicht lesbare Modi blockieren. Nurhybrid/advancederlauben manuelle Regeln. OPNexus ändert den globalen Modus nicht. Beide realen Hosts wurden mithybridverifiziert. Ändert sich der Modus zwischen Vorprüfung und einem späteren Host, bleiben bereits gespeicherte Deployments einschließlich Rollback-Frist erhalten. - Host-Wartung auf der Host-Detailseite: gecachter Update-Status, explizite Update-Suche, Reboot, stabile Firmware-Updates und Major-Upgrades mit fünf Minuten gültiger, einmal verwendbarer Vorschau und Eingabe des Hostnamens. Cluster-Wartung benötigt einen erreichbaren CARP-BACKUP und aktiven Partner; offene Deployments/Rollbacks und andere Wartung sperren die Aktion. Vor Updates/Upgrades muss ein lokales Backup vorhanden und höchstens 24 Stunden alt sein. Keine automatischen HA-Rollenwechsel.
- Update Center shipped 2026-09-11 (
/updates): zentrale Fleet-Ansicht über denselben abgesicherten Host-Wartungspfad. Zeigt installierte und verfügbare Versionen, stabil verfügbare Updates, Major-Upgrades, Neustartbedarf, Alter des lokalen Backups, Paketänderungen, Prüflog und offene Wartungsaufträge. Beim Öffnen startet für Admins einmalig automatisch eine echte Update-Suche auf allen geeigneten Hosts; sie kann anschließend einzeln oder für die Fleet wiederholt werden. Die Installation selbst bleibt pro Host an Vorschau, exakte Hostnamen-Eingabe, Downtime-Bestätigung, frisches Backup und die bestehenden HA-/Deploy-/Restore-Sperren gebunden. Für HA-Updates gibt es einen expliziten CARP-Ablauf im Update Center und auf der Host-Detailseite: Der aktuelle MASTER kann nach eigener Vorschau und Doppelbestätigung in den persistenten CARP-Wartungsmodus versetzt werden, damit der aktualisierte Partner übernimmt. Nach dem Update lässt sich der Normalbetrieb auf demselben Host kontrolliert wiederherstellen. Beide Schritte werden erst nach beobachteten gesunden MASTER/BACKUP-Rollen manuell abgeschlossen und auditiert. Da OPNsenses Endpunkt ein Toggle ist, wird die Vorschau vor dem Remote-Aufruf dauerhaft verbraucht und ein Timeout niemals automatisch wiederholt. Automatische CARP-Rollenwechsel und unbeaufsichtigte Update-Ketten sind bewusst nicht enthalten. Die API-Nutzung wurde erneut gegen OPNsense CoreFirmwareController.phpverifiziert (checkAction,statusAction,updateAction,runningAction,upgradestatusAction); Browsertests verwendeten ausschließlich vollständig gemockte/api/**- Antworten und führten keine echten Firewall-Schreibzugriffe aus. - Major-Upgrade-Ausführung shipped 2026-09-12, fast-follow im selben
Zug:
POST /core/firmware/upgrade(Core-Quelle bestätigt,FirmwareController::upgradeAction()— identische{"status":"ok"}-Form, denselbenfirmware flush+configd-Job und denselben/core/firmware/upgradestatus-Fortschritt wieupdateAction()) ist jetzt als eigene Wartungsaktionmajor_updateverkabelt, auf der Host- Detailseite und im Update Center. Sie nutzt exakt dieselben Gates wie das stabile Update (frisches Backup, HA-Reihenfolge BACKUP-zuerst,running == ready, offene Deploys/Restores sperren) plus eine zusätzliche, deutlich rot hervorgehobene Warnung vor dem Ausführen (Hauptversionswechsel, länger dauernd, kein automatischer Rollback in OPNexus). Freigeschaltet nur, wenn OPNsense selbststatus: "upgrade"meldet — das passiert lautstatusAction()nie, solange noch ein stabiles Paketupdate aussteht, ein versehentliches Vorpreschen vor der eigentlichen Update-Reihenfolge ist also durch OPNsense selbst ausgeschlossen. Verfügbare Ziel-Releases (upgrade_sets/all_sets) werden mit Alt-/Neu-Version in einer eigenen Tabelle im Update Center angezeigt. Wie bei Reboot/Update: nur gegen respx-Mocks getestet, nie gegen die echten Hosts10.0.0.2/.3ausgeführt — ein erster echter Major-Upgrade-Lauf gegen Produktion braucht ein separates, explizites Go-ahead. - Wartungsabschluss: „An Firewall übergeben“ bestätigt nur die Annahme. Nach Timeout bleibt der Ausgang unklar, ohne automatischen Wiederholungsversuch. Nach Prüfung von Erreichbarkeit, Version und Diensten dokumentiert der Nutzer den Abschluss. Offene Wartung sperrt neue Pushes im betroffenen Host/Cluster.
- Vorgang abbrechen (2026-09-11): Neuer Endpunkt
POST /hosts/{id}/maintenance/{operation_id}/cancel(Hostnamen-Bestätigung- Pflicht-Begründungstext, gleiche Mindestwartezeit wie beim Abschluss)
schließt einen hängenden Vorgang, ohne einen Erfolg zu behaupten — im
Unterschied zu „Abschluss bestätigen“ prüft er den Zielzustand nicht,
sondern setzt
status='cancelled'und protokolliert den Grund im Audit. Notwendig geworden, weil OPNsenses CARP-Wartungsmodus laut Upstream (opnsense/core#7877) den Rollentausch manchmal gar nicht auslöst — ohne Abbrechen-Option blieb der gesamte Host/Cluster dann dauerhaft gesperrt, weil „Abschluss bestätigen“ den nie eingetretenen Zielzustand zu Recht verweigert. Auf der Update-Center-Seite lagen die Buttons für „Abschluss prüfen“/„Vorgang abbrechen“ zusätzlich hinter dem erfolgreichen Laden des Live-Status verborgen — genau dann unsichtbar, wenn ein Host wegen eines hängenden Vorgangs nicht erreichbar ist; jetzt unabhängig davon sichtbar. Per Browsertest gegen einen Wegwerf-Host auf beiden Oberflächen verifiziert.
- Pflicht-Begründungstext, gleiche Mindestwartezeit wie beim Abschluss)
schließt einen hängenden Vorgang, ohne einen Erfolg zu behaupten — im
Unterschied zu „Abschluss bestätigen“ prüft er den Zielzustand nicht,
sondern setzt
- CARP-Wartungsmodus-Bug gefunden und gefixt (2026-09-11):
host_status_service.pyverglichcarp.maintenancemodeperstr(x) == "1". Echte OPNsense-Hosts liefern dieses eine Feld als natives JSON-Bool (true/false), alle anderencarp.*-Felder dagegen als"1"/"0"-Strings —str(True) == "1"istFalse, also warcarp_maintenance_modeserverseitig immerfalse, unabhängig vom echten Zustand. Dadurch verweigerte „Abschluss bestätigen“ nach einem CARP-Rollenwechsel dauerhaft, obwohl der Wechsel real funktioniert hatte. Live gegen10.0.0.2/.3bestätigt, Regressionstest intest_maintenance.pynutzt jetzt ein echtes Bool statt eines Strings. Zusätzlich zweimal live beobachtet: Wird nach einem Rollenwechsel nicht der ursprüngliche Host per „Wartungsmodus verlassen“ zurückgeschaltet, sondern stattdessen der andere Host per „Auf Partner umschalten“ angefasst, stehen beide Hosts gleichzeitig demotet — der Rollentausch bleibt dann aus (Upstream-Bugopnsense/core#7877). „Vorgang abbrechen“ (siehe oben) ist der Recovery-Pfad, falls es doch passiert. - Doppelte Demotion strukturell verhindert (2026-09-11): Der zweite
Vorfall oben war kein Zufall — der bereits BACKUP gewordene, demotete
Host meldet weiterhin einen validen
carp_status: BACKUP, also ließen die bisherigen Rollen-Checks ininspect_carp_switchdas erneute Demoten des anderen (jetzt legitim MASTER gewordenen) Hosts durch. Neue serverseitige Sperre:carp_enterwird jetzt zusätzlich blockiert, wenn irgendein anderer Cluster-Knoten bereitscarp_maintenance_mode=truemeldet, mit klarer Fehlermeldung, welcher Host zuerst „Normalbetrieb herstellen“ braucht.CarpSwitchControlbekommt dieselbe Information jetzt auch im normalen Host-Snapshot (peers[].carp_maintenance_mode, vorher nur im CARP-Vorschau-Snapshot vorhanden) und zeigt statt des Buttons direkt einen Hinweis auf den richtigen Host, bevor überhaupt eine Vorschau geöffnet wird. Regressionstest intest_maintenance.pyreproduziert exakt das beobachtete Szenario. - Einstellungen (
/settings): Hell/Dunkel/System pro Konto gespeichert, Passwortformular und Anzeige der zentralen Betriebswerte. Darstellung folgt bei „System“ dem Gerät und synchronisiert sich zwischen Browser-Tabs. - Deploy-Audit (
/audit): Anfragen, Ergebnisse pro Host, Bestätigungen, Löschungen, automatische/manuelle Rollbacks und Wartungsaktionen mit Benutzer, Zeitpunkt, Korrelations-ID und Payloads. Filter, Paginierung und JSON-Export. Daten bleiben nach Regel-/Host-Löschung erhalten. UPDATE/DELETE der Audit- Tabelle werden durch einen Datenbank-Trigger blockiert; Datenbankadministratoren können technisch weiterhin das Schema ändern. Keine rückwirkende Rekonstruktion früherer Aktionen. XML-Backups, Passwörter und API-Secrets werden nicht protokolliert. - Restore: separat ausgearbeiteter Restore-Plan. Es gibt keine implementierte Restore-Ausführung. Der Plan unterscheidet lokale OPNexus-XML-Dateien von bereits auf der Firewall vorhandenen Revisionen.
Alle Erweiterungen verwenden additive Tabellen. Bestehende Daten, Host-Secrets und Backup-Volumes bleiben erhalten. NAT-Zuweisungen fließen in die Gruppen- Auswirkungsvorschau ein; noch verwendete Gruppen und Hosts mit NAT-Deployments können nicht gelöscht werden.
API-Familien: /api/port-forward, /api/one-to-one, /api/outbound-nat
(jeweils CRUD, preview, revisions, deploy, confirm,
deployments/{host}/remove, deployments/{host}/retry-rollback),
/api/hosts/{id}/maintenance, /api/settings, /api/audit.
Die vollständigen Schemas stehen unter http://localhost:8100/docs.
Prüfprotokoll und Grenzen.
Schreibaktionen wurden mit Mocks geprüft; auf fw-primary und fw-secondary
wurden ausschließlich lesende Endpunkte aufgerufen. Keine NAT-Regeln,
Reboots, Updates oder Restores wurden dort ausgeführt.
Zentrale Policies und Gerätegruppen (2026-09-07)
OPNexus erweitert die zentrale Regelverwaltung um folgenden Ablauf: Entwurf speichern → Änderungen prüfen → Push → Bestätigen. Speichern, Gruppenzuweisungen und das Übernehmen einer alten Version lösen keine Schreibzugriffe auf Firewalls aus.
- Gerätegruppen (
/groups): anlegen, bearbeiten, löschen und suchen; Name, Beschreibung und mehrere Hosts pro Gruppe. Hosts können mehreren Gruppen angehören. Diese Policy-Gruppen sind unabhängig von den bestehenden HA-Clustern. Noch verwendete Gruppen lassen sich nicht löschen. - Gemeinsame Regelziele: eine zentrale Regel kann mehrere Gruppen und zusätzliche einzelne Hosts adressieren. Beim Push werden deren aktuelle Mitglieder zusammengeführt und dedupliziert; deaktivierte Hosts werden übersprungen. Neue Mitglieder erhalten Regeln erst beim nächsten Push.
- Regelentwürfe: bestehende Regeln bearbeiten, IPv4/IPv6 und Aktivierung
einstellen, Regeln duplizieren und nach Beschreibung, Quelle, Ziel,
Zielport, Interface oder Protokoll suchen. Gleichzeitige Bearbeitungen
werden über
expected_updated_aterkannt; veraltete Änderungen liefern 409. - Historie: jede Neuanlage und Bearbeitung speichert einen unveränderlichen Snapshot. Bei vorhandenen Regeln wird vor der ersten Bearbeitung der ursprüngliche Stand gesichert. Alte Versionen lassen sich ins Formular übernehmen und als neuen Entwurf speichern. Gelöschte Hosts/Gruppen werden dabei mit einem sichtbaren Hinweis aus dem Formular entfernt; die Zielauswahl muss vor dem Speichern geprüft werden. Die Historie speichert Gruppenzuweisungen, keine historischen Gruppenmitgliedschaften.
- Push-Vorschau: pro Host neue/geänderte/unveränderte Regeln und ein Feldvergleich. Grundlage ist der zuletzt von OPNexus gespeicherte Deploy-Payload, kein Live-Abgleich. Frühere Ziele bleiben mit Hinweis sichtbar; das Entfernen eines Ziels löscht dort keine Firewall-Regel. Die vorhandene Live-Ansicht bleibt für den Vergleich mit Firewalls zuständig.
- Veraltete Vorschauen: das Frontend sendet einen Hash des geprüften Entwurfs, der Ziele und Deployment-Zustände. Zwischenzeitliche Änderungen führen beim Push zu 409 und erfordern eine neue Vorschau. Der bisherige API-Aufruf ohne Body bleibt für bestehende Clients möglich; diese Clients nutzen den Vorschau-Abgleich nicht.
- Explizite Bestätigung: offene Deployments sperren erneuten Push und Bearbeiten. Nur der Bestätigen-Button bestätigt innerhalb der Frist. Ein fehlgeschlagener Rollback sperrt ebenfalls Push/Bearbeiten und erfordert einen erneuten Rollback-Versuch oder manuelle Klärung. Nach einem Push-Fehler kann der Entwurf korrigiert und Push nach erneuter Prüfung versucht werden. Fehler beim Löschen bleiben sichtbar und lassen sich durch erneutes Löschen erneut versuchen.
Neue API-Endpunkte:
| Methode | Pfad | Zweck |
|---|---|---|
| GET / POST | /api/groups |
Gruppen auflisten / anlegen |
| PUT / DELETE | /api/groups/{id} |
Gruppe ersetzen / löschen |
| PUT | /api/rules/{id} |
Vollständigen Regelentwurf mit expected_updated_at speichern |
| GET | /api/rules/{id}/preview |
Änderungen und Vorschau-Token abrufen |
| GET | /api/rules/{id}/revisions |
Gespeicherte Regelversionen abrufen |
| POST | /api/rules/{id}/deploy |
Push, optional mit {"preview_token":"…"} |
Datenerhaltendes Schema-Update: Es kommen ausschließlich neue Tabellen
hinzu: device_groups, device_group_members, rule_groups,
rule_revisions. Bestehende Tabellen/Spalten, Host-Secrets und Regeln
bleiben erhalten. create_all() legt diese zusätzlichen Tabellen beim
Start an. Für spätere Änderungen bestehender Spalten fehlt weiterhin ein
Migrationstool. Management-Änderungen und der Rollback-Worker werden über
eine zentrale PostgreSQL-Transaktionssperre serialisiert; ein laufender Push
kann andere Management-Änderungen bis zum Ende seiner API-Aufrufe verzögern.
Abgrenzung: Zentrale Policies sind derzeit einzelne wiederverwendbare Regeln mit Gruppenzielen. Hierarchische Vererbung mit Pre-/Post-Rulebases ist seit 2026-09-08 als separater Baum verfügbar (siehe "Hierarchische Policies" oben) — Konfigurations-Templates und Policy-Pakete mit atomarem Commit sind weiterhin nicht implementiert. Die Regelhistorie ist eine Entwurfshistorie und wird beim Löschen der Regel mit gelöscht. Die separat ergänzte Deploy-Audit-Historie bleibt dagegen erhalten (siehe Erweiterung vom 2026-09-08).
Validierung dieser Erweiterung: 39 Backend-Tests bestanden (einschließlich Schema-Upgrade mit vorhandenen Hosts/Regeln und konkurrierender Bearbeitung). Die Tests laufen gegen einen separaten, temporären PostgreSQL-Container mit gemockten Firewall-Aufrufen. Zusätzlich wurde der Ablauf Login → Gruppe/Mitglieder → Regelentwurf → Vorschau → Mock-Push → Bestätigen → Bearbeiten → Historie/Wiederherstellen → Duplizieren/Suche im Chromium-Browser geprüft, bei 1440px und 375px Breite ohne Seitenfehler oder horizontalen Seitenüberlauf. Der Next.js-Produktionsbuild einschließlich TypeScript-Prüfung ist erfolgreich. Keine Regeln wurden zu diesem Zweck auf die echten Firewalls gepusht.
Durchgängiger Gruppen- und Regelablauf
- Gruppen verlinken auf ihre Regeln (
/rules?group=<id>); einzelne Regeln lassen sich direkt öffnen (&rule=<id>). „Regel für diese Gruppe anlegen“ öffnet das Formular mit vorausgewählter Gruppe. Andere Regeln werden dabei nicht mit angezeigt. Gruppen- und Zustandsfilter lassen sich mit der Textsuche kombinieren. - Regelzustände werden im Backend aus Zielen und Deployments berechnet: Ziele fehlen, Ziel-Hosts deaktiviert, Änderungen ausstehend, Bestätigung offen, Fehler prüfen, frühere Ziele prüfen oder ausgerollt. „Ausgerollt“ bezieht sich auf den gespeicherten Deploy-Stand; manuelle Änderungen auf Firewalls müssen weiterhin über die Live-Ansicht geprüft werden.
- Vor dem Speichern einer bestehenden Gruppe zeigt die Oberfläche die Auswirkungen ihrer Mitgliedschaften: neue/entfernte Mitglieder und neue/entfernte Ziele pro Regel. Direkte Zuweisungen und andere Gruppen werden berücksichtigt. Bereits ausgerollte Kopien auf entfernten Zielen werden gesondert genannt. Ein Token schützt diese Vorschau vor zwischenzeitlichen Änderungen an Mitgliedschaften oder Regelzuweisungen.
- Unter „Prüfen & Push“ können frühere Ziele einzeln bereinigt werden. OPNexus entfernt die Regel nur von diesem Host; die zentrale Regel und ihre anderen Deployments bleiben erhalten. Die UI verlangt eine ausdrückliche Bestätigung. Diese Löschung wird sofort angewendet und hat keinen automatischen Rollback. Aktuelle Ziele, deaktivierte Hosts und offene Deployments sind von dieser Aktion ausgeschlossen. Scheitert die Löschung, bleibt die Zuordnung sichtbar und erneut ausführbar.
- Fehlgeschlagene Rollbacks lassen sich in derselben Vorschau gezielt wiederholen. Der gespeicherte vorherige Payload bleibt bei Fehlern erhalten. Bei Erfolg wird die frühere Regel wiederhergestellt oder eine erstmals angelegte Regel entfernt. Vorschau-Tokens werden auch vor Bereinigung und Rollback-Wiederholung geprüft.
- Leere Listen und erfolglose Suchanfragen bieten passende Aktionen: erste Gruppe/Regel anlegen, Hosts zuordnen oder Filter zurücksetzen. Regeln ohne Ziele bieten „Ziele zuweisen“; unveränderte Regeln zeigen „Kein Push notwendig“. Ladefehler und veraltete Vorschauen können direkt neu geladen werden. Es werden keine Beispielgruppen oder Beispielregeln in die produktive Datenbank eingefügt.
Zusätzliche API-Endpunkte:
| Methode | Pfad | Zweck |
|---|---|---|
| POST | /api/groups/{id}/preview |
Auswirkungen einer Gruppenänderung prüfen |
| POST | /api/rules/{id}/deployments/{host_id}/remove |
Regelkopie eines früheren Ziels gezielt entfernen |
| POST | /api/rules/{id}/deployments/{host_id}/retry-rollback |
Fehlgeschlagenen Rollback gezielt wiederholen |
Schreibantworten werden zusätzlich inhaltlich geprüft: Eine HTTP-200-Antwort
mit result: failed, Validierungsfehlern oder status: error zählt nicht
als Erfolg. Löschfehler behalten ihre Zuordnung; abgelehnte Entwürfe können
korrigiert und erneut geprüft werden. Grundlage für die Antwortauswertung
sind die offiziellen OPNsense-Implementierungen
ApiMutableModelControllerBase
und FilterBaseController
(geprüft am 2026-09-07).
Die beiden Deployment-Aktionen erfordern {"preview_token":"…"} aus
GET /api/rules/{id}/preview. PUT /api/groups/{id} akzeptiert diesen
Schutz ebenfalls als preview_token aus der Gruppen-Vorschau; bestehende
API-Clients ohne Token bleiben kompatibel. Für die Änderungen an diesem
Ablauf sind keine zusätzlichen Tabellen oder Spalten nötig.
Backend-Validierung: 51 Tests bestanden, inklusive Zustandswechseln, Gruppenüberschneidungen, veralteter Vorschauen, gezielter Bereinigung und wiederholbaren Rollback-/Löschfehlern. Tests laufen auf einer separaten PostgreSQL-Instanz und mit gemockten Firewall-Aufrufen.
Der vollständige Ablauf wurde zusätzlich im Chromium-Browser mit Mock-Firewalls geprüft: gruppenspezifische Regelanlage und Filter, Zustandsanzeigen, Vorschau eines Mitgliedswechsels, gezielte Bereinigung, Push auf das neue Mitglied, Suchzustände sowie Wiederholung eines fehlgeschlagenen Rollbacks. Desktop (1440px) und Mobilansicht (375px) liefen ohne Seitenfehler oder horizontalen Seitenüberlauf.
Der vervollständigte Ablauf wurde am 2026-09-07 in OPNexus eingespielt;
Healthcheck, Gruppen- und Regelseite antworten erfolgreich. Die vorhandenen
zwei Hosts und die vorhandene Gruppe blieben erhalten. Das Backup vor
diesem Update liegt unter
/root/opnexus-backups/before-group-lifecycle-20260907.dump.
Die temporären Testcontainer wurden anschließend entfernt.
Rollout der Policy-Erweiterung
Am 2026-09-07 im vorhandenen OPNexus-Compose-Stack eingespielt. Die zwei
vorhandenen Hosts blieben erhalten; die vier zusätzlichen Tabellen wurden
angelegt. Backend-Healthcheck und /groups, /rules, /about antworten
nach dem Rollout erfolgreich. InfraPanel-Container wurden nicht ersetzt.
Vorheriges Datenbank-Backup (PostgreSQL Custom Format, nur root lesbar):
/root/opnexus-backups/before-policy-upgrade-20260907.dump.
Die Lesbarkeit des Backups wurde mit pg_restore --list geprüft.
Beim ersten Start verhinderte ein voller Datenträger das Anlegen der
Tabellen (DiskFullError). Nach Entfernen temporärer Testcontainer,
Testimages und gezielt ausgewählter OPNexus-Build-Zwischendaten startete
das Backend erfolgreich. Zunächst waren rund 830 MB verfügbar. Auf
Nutzerwunsch wurden anschließend alle unbenutzten Docker-Images und
Build-Caches bereinigt; danach waren 18.5 GiB frei. Laufende
Container, Daten und Backups blieben erhalten. Datenbank-
und Secret-Volumes wurden nicht gelöscht oder zurückgesetzt.
Hinweis zum Projektstart
Der Auftrag ging davon aus, dass unter backend/ bereits ein
FastAPI-Grundgerüst liegt. Das war auf diesem System nicht der Fall (weder
im Dateisystem noch in der Git-Historie) — das gesamte Projekt wurde daher
neu aufgebaut, nach Rücksprache mit dem Nutzer.
Architektur
- Backend: FastAPI, Python 3.12, SQLAlchemy 2.0 (async) + asyncpg, Postgres 16, httpx als OPNsense-API-Client.
- Frontend: Next.js 15 (App Router), TypeScript, Tailwind CSS.
- Datenbank: eigener Postgres-Container, kein Host-Port (nur intern im Compose-Netzwerk erreichbar).
- Secrets: OPNsense-API-Secrets landen nie in der Datenbank, sondern
als Datei pro Host in einem eigenen Docker-Volume (
chmod 600) — analog zum Muster, das InfraPanel selbst für Proxmox-/Webhook-Secrets nutzt.
Reale Topologie (Stand: 2026-09-07)
Zwei OPNsense-Firewalls im HA-Verbund: primär 10.0.0.2, backup
10.0.0.3, beide im Management-Netz, mit gemeinsamer CARP-VIP
10.0.0.1. Hosts in diesem Tool müssen mit den individuellen
Management-IPs (.2/.3) angelegt werden, nicht mit der VIP — Regeln
müssen auf jeden Node einzeln gepusht werden, und über die VIP wäre
ohnehin nur der gerade aktive Master erreichbar, nie beide gezielt. Die
frühere Read-only-Verifikation (siehe unten) lief noch gegen die VIP
.1, bevor diese Topologie-Information bekannt war.
Ports (auf dieser VM geprüft frei, vor Rollout erneut prüfen)
| Service | Host-Port | Hinweis |
|---|---|---|
| Frontend | 3100 | InfraPanel belegt bereits 3000 |
| Backend | 8100 | InfraPanel belegt bereits 8000 |
| Postgres | – | kein Host-Port, nur intern erreichbar |
Geprüft via docker ps und ss -tlnp vor dem Anlegen des Compose-Files.
Setup
cd /opt/opnexus
cp .env.example .env # POSTGRES_PASSWORD anpassen
docker compose up -d --build
Danach (von der VM selbst, oder per SSH-Tunnel — siehe unten für Zugriff von außerhalb):
- Frontend:
http://localhost:3100— leitet unangemeldet automatisch auf/loginum - Backend/API:
http://localhost:8100/api(Health-Check:/api/health, öffentlich ohne Login)
Das Frontend ermittelt die Backend-Adresse zur Laufzeit im Browser aus
window.location.hostname (Port fix 8100) — funktioniert also gleichermaßen
über localhost, die LAN-IP der VM oder einen Hostnamen, ohne Rebuild.
Seit dem Sicherheits-Härtungs-Pass vor dem öffentlichen Release (siehe unten)
sind beide Ports aber standardmäßig nur an 127.0.0.1 gebunden — für
Zugriff von einer anderen Maschine ist eine der beiden folgenden Optionen
nötig.
Zugriff von außerhalb dieser VM / Produktivbetrieb mit TLS
Seit 1.7.0 ist ein TLS-Proxy (Caddy) mitgeliefert und Standard — siehe den Abschnitt „Sichere Übertragung“ weiter oben. Die folgenden Hinweise zu einem eigenen Proxy bzw. zum Betrieb ohne TLS gelten nur noch für Sonderfälle.
Empfohlen: Reverse-Proxy mit TLS davor. Backend/Frontend bleiben auf
127.0.0.1 gebunden, ein Proxy auf demselben Host terminiert TLS und leitet
weiter. Minimalbeispiel mit Caddy (automatisches Let's-Encrypt-Zertifikat):
opnexus.example.com {
reverse_proxy /api/* localhost:8100
reverse_proxy localhost:3100
}
Danach in .env:
FRONTEND_ORIGIN=https://opnexus.example.com
COOKIE_SECURE=true
TRUST_PROXY_HEADERS=true
und docker compose up -d neu ausführen (Backend-Neustart nötig, damit
FRONTEND_ORIGIN/COOKIE_SECURE/TRUST_PROXY_HEADERS greifen). Ohne
COOKIE_SECURE=true bleibt das Session-Cookie unverschlüsselt übertragbar;
ohne passendes FRONTEND_ORIGIN lehnt das Backend CORS-Anfragen von dieser
Origin ab. TRUST_PROXY_HEADERS=true sorgt dafür, dass der Login-Sperrschutz
die echte Client-IP aus X-Forwarded-For liest statt der IP des Proxys
selbst — nur setzen, wenn wirklich ein Reverse-Proxy davor steht (jedes
Beispiel oben setzt den Header korrekt); ohne Proxy direkt gesetzt könnte
jeder Client die Sperre durch eine gefälschte X-Forwarded-For-Kopfzeile
umgehen.
Ohne TLS (nur für vertrauenswürdige LANs, z.B. reines Testen): die
Portbindung in docker-compose.yml von 127.0.0.1:8100:8000 /
127.0.0.1:3100:3000 zurück auf 8100:8000 / 3100:3000 ändern und
FRONTEND_ORIGIN auf http://<lan-ip-oder-hostname>:3100 setzen. Das
Session-Cookie geht dabei weiterhin im Klartext übers Netz — nicht für
Zugriff über ein unvertrautes Netz oder das Internet geeignet.
Anmeldung
Mehrere Benutzer-Accounts mit zwei Rollen (seit 2026-09-10, siehe
"Benutzerverwaltung mit Rollen" weiter unten): admin (voller Zugriff,
inkl. Benutzerverwaltung unter /users) und read_only (jede GET-Anfrage
funktioniert, jede schreibende Anfrage wird von AuthMiddleware mit 403
abgelehnt — Ausnahme: das eigene Passwort bleibt immer änderbar). Weiterhin
keine Multi-Tenancy/Organisationen. Ohne gesetztes ADMIN_PASSWORD in
.env wird beim ersten Start ein zufälliges Passwort erzeugt und einmalig
in die Backend-Logs geschrieben:
docker compose logs backend | grep -A5 "generated password"
Danach in .env ein eigenes ADMIN_PASSWORD setzen (empfohlen) oder das
Passwort über die künftige "Passwort ändern"-Funktion (POST /api/auth/change-password, aktuell nur per API, kein UI-Formular) ändern.
Login-Versuche sind pro (IP, Benutzername) und pro IP rate-limitiert
(Standard: 5 Fehlversuche / 5 Minuten Sperre).
Schema-Migrationen & Upgrade (seit 1.9.0)
Das Datenbankschema wird von Alembic
verwaltet (backend/migrations/). Ein Update ersetzt nur den Code — die
Daten bleiben im Volume postgres_data, die Konfiguration in .env. Beim
Start des Backends (app/migrate.py) passiert automatisch:
- Ein Postgres-Advisory-Lock sorgt dafür, dass nur eine Instanz migriert.
- Liegt die Datenbank hinter der Code-Version, wird zuerst ein
pg_dumpnach/backups/pre-upgrade-<von>-to-<nach>-<zeit>.dumpgeschrieben (Volumepostgres_backups, die letzten 5 bleiben; die Tabellenfirewall_log_entriesundhost_resource_samplesnur als Schema). Schlägt das Backup fehl, wird nicht migriert und der Start bricht mit einer klaren Meldung ab (SKIP_MIGRATION_BACKUP=trueüberspringt es bewusst). - Dann läuft
alembic upgrade headin einer Transaktion — ein Fehler rollt alles zurück.
| Ausgangslage | Verhalten |
|---|---|
| Leere Datenbank | Schema wird von Revision 0001 aufgebaut |
| Installation bis 1.8.x (Tabellen, aber kein Alembic) | Backup, dann einmalig die eingefrorene Legacy-Brücke (create_all() + app/legacy_schema_patches.py), Stempel auf 0001, upgrade head |
| Alembic-Installation, Schema aktuell | nichts zu tun |
| Alembic-Installation, Schema veraltet | Backup, upgrade head |
| Datenbank von einer neueren Version migriert | Start bricht ab (Code kennt die Revision nicht) — neuere Version starten oder Backup einspielen |
Rollback ist bewusst "Backup einspielen" (macht opnexus rollback,
siehe "Update und Rollback mit opnexus"), nicht automatisches Downgrade.
pg_dump-Version = Postgres-Version. Ein von pg_dump 17 geschriebenes
Backup lässt sich in einen Postgres-16-Server nicht zurückspielen
(SET transaction_timeout). Das Backend-Image installiert deshalb den
Client in der Hauptversion des Servers (backend/scripts/install_pg_client.sh,
derzeit 16 wie postgres:16-alpine), und der Start bricht vor der Migration
ab, wenn beide nicht zusammenpassen. Wer den Server auf eine andere
Hauptversion hebt, ändert Skript-Argument und Compose-Image gemeinsam.
Upgrade-Pfad: Von 1.8.x direkt auf 1.9.x ist unterstützt. Wer noch älter ist, geht erst auf 1.9.x, bevor weitere Releases übersprungen werden (die Legacy-Brücke legt fehlende Tabellen mit den aktuellen Modellen an).
Für Entwickler: Eine Schema-Änderung ist jetzt immer eine Migration:
# Modell ändern, dann Migration erzeugen und PRÜFEN (Autogenerate sieht keine
# Trigger, Datenmigrationen oder Umbenennungen):
docker compose run --rm --no-deps -v "$PWD/backend:/app" backend \
alembic revision --autogenerate -m "kurze beschreibung"
backend/tests/test_migrations.py baut eine leere Datenbank per Migrationen
auf und vergleicht sie mit den Modellen — wer ein Modell ändert, ohne eine
Migration zu schreiben, scheitert dort. Migrationen sollen
rückwärtskompatibel sein (erst hinzufügen, in einem späteren Release
entfernen), damit ein Release nie auf Daten trifft, die der Vorgänger nicht
lesen konnte.
Für ein komplettes Neuaufsetzen ohne jede Altdaten bleibt gültig:
docker compose down
docker volume rm opnexus_postgres_data
docker compose up -d --build
Release-Images & CI
Release-Images werden nicht auf dem Zielsystem gebaut, sondern von CI
(.github/workflows/) für linux/amd64 und linux/arm64 gebaut und in
eine Registry gepusht: <repo>/opnexus-backend, <repo>/opnexus-frontend,
<repo>/opnexus-proxy (der Proxy-Container enthält seine Caddyfile jetzt selbst). Die Images
enthalten keine installationsspezifischen Werte — Zugangsdaten, Origin und
Adressen kommen zur Laufzeit aus .env.
Installieren/Aktualisieren aus fertigen Images (.env, siehe
.env.example):
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus # Registry + Namespace (offizielle Images)
OPNEXUS_VERSION=1.9.0 # fest pinnen
docker compose pull && docker compose up -d --no-build
docker-compose.yml enthält nur Images (kein build:) und ist damit
zugleich das Compose-File der Release-Installation. Im Repo-Checkout lädt
Docker Compose daneben automatisch docker-compose.override.yml, das die
Build-Kontexte (und das Test-DB-Init-Skript) ergänzt — ohne diese Variablen
baut docker compose up -d --build wie bisher lokal (Image-Namen
local/opnexus-<dienst>).
Release-Bundle ohne Repo-Checkout: scripts/make_release_bundle.sh <version> <image-repo> erzeugt dist/opnexus-<version>.tar.gz mit der
Compose-Datei (Registry und Version als Standard eingebacken), .env.example,
dem opnexus-Skript und einer Kurzanleitung. Das Archiv wird manuell an die
Release-Seite angehängt (GitHub und Forgejo haben dafür verschiedene APIs, die
CI lädt es deshalb nicht hoch). Anwender brauchen damit nur Docker und diese
vier Dateien.
Ein Release veröffentlichen: Versionsdateien und CHANGELOG.md anpassen,
committen, dann git tag v1.9.1 && git push origin v1.9.1. Der Release-
Workflow prüft per scripts/check_version.sh, dass Tag, version.py,
version.ts und CHANGELOG zusammenpassen, lässt die Tests laufen und pusht
erst dann die Tags 1.9.1, 1.9 und 1 (Vorabversionen wie v1.10.0-rc1
nur mit vollem Tag).
Entwicklungs-Repo und öffentliches Repo (seit 2026-10-05): Entwickelt wird
im privaten Repo (volle Historie). Öffentlich ist git.opnexus.dev/opnexus/opnexus,
und dort landet pro Release nur ein sauberer Snapshot-Commit — nie die
Entwicklungs-Historie (frühere Lizenzstände, interne Übergaben,
Live-Verifikation mit echten Adressen). Ablauf eines Releases:
git tag v1.11.0 # im Entwicklungs-Repo, nach check_version.sh
scripts/publish_public.sh v1.11.0 # Trockenlauf: Snapshot nach .public-repo/ prüfen
scripts/publish_public.sh v1.11.0 --push # Snapshot + Tag ins öffentliche Repo
scripts/build_release_images.sh v1.11.0 --push # Images bauen + in die Registry (ohne CI-Runner)
scripts/make_update_feed.sh v1.11.0 # erst jetzt: Feed für updates.opnexus.dev
publish_public.sh exportiert den Tag (nicht den Working Tree), lässt
.claude/, docs/HANDOFF-*, docs/verification* und .env weg und bricht
ab, wenn im Export interne Adressen/Hostnamen (Muster stehen im Skript) oder
Schlüsselblöcke stehen — ein Sicherheitsnetz, kein Ersatz fürs Durchsehen.
Folge: Verweise auf docs/verification* laufen im öffentlichen Repo ins Leere
(siehe Hinweis oben im Handbuch). Hinter dem Cloudflare-Proxy (Free) sind
Uploads auf 100 MB pro Request begrenzt; der Push des 1.10.0-rc1 ging
problemlos durch, bei größeren Layern die Registry-Domain auf „DNS only“
stellen oder aus dem LAN pushen.
Registry ist reine Konfiguration, nicht Teil der Workflow-Datei —
Repository-Variablen REGISTRY (Default ghcr.io) und IMAGE_NAMESPACE
(Default: Repo-Besitzer), Secrets REGISTRY_USERNAME/REGISTRY_TOKEN. Für
mehrere Registries (z. B. GitHub und ein eigenes Forgejo) denselben Tag zu
mehreren Remotes pushen: jeder Remote baut mit seiner eigenen
Konfiguration. #### Hinweis auf neue Versionen
Optional zeigt OPNexus Administratoren oben einen Hinweis, wenn eine neuere
Version veröffentlicht ist. Quelle ist ein Release-Feed im Format von
GitHub/Forgejo releases/latest, den man in .env einträgt:
UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
Das ist der offizielle Feed des Projekts (statische JSON-Datei aus
website/latest.json, siehe website/README.md). Jeder andere Feed im
selben Format funktioniert ebenso, etwa …/api/v1/repos/<owner>/opnexus/releases/latest
auf einer eigenen Forgejo/GitHub-Instanz.
Ohne UPDATE_CHECK_URL (Standard) ist die Funktion aus und OPNexus macht
keine ausgehende Anfrage — wichtig für abgeschottete Installationen.
Ist sie an, geht höchstens alle 12 Stunden (UPDATE_CHECK_INTERVAL_HOURS) ein
einzelnes GET mit dem User-Agent OPNexus/<Version> hinaus, ohne Instanz- oder
Nutzerdaten. Ein unerreichbarer Feed bleibt unauffällig (der letzte bekannte
Stand bleibt, Neuversuch frühestens nach 15 Minuten); Vorabversionen
(-rc1) werden nie vorgeschlagen. Der Hinweis lässt sich pro Version
wegklicken und installiert nichts — dafür gibt es opnexus update.
Update und Rollback mit opnexus
scripts/opnexus (eine einzelne Bash-Datei, braucht nur Docker; im
Verzeichnis mit docker-compose.yml und .env ausführen oder
OPNEXUS_DIR=… setzen) bündelt den Ablauf. Es arbeitet nur mit
Release-Images (OPNEXUS_IMAGE_REPO gesetzt):
| Kommando | Wirkung |
|---|---|
opnexus status |
Version, DB-Revision, Zustand der Container |
opnexus update [VERSION] |
zieht die neuen Images bevor etwas angefasst wird (scheitert der Pull, bleibt alles wie es war), wechselt, wartet bis alle Dienste gesund sind; sonst automatisch Rollback |
opnexus rollback |
stellt Version und, falls das Update migriert hat, die Datenbank aus dem Pre-Upgrade-Backup wieder her |
opnexus backup |
Sofort-Backup nach /backups/manual-….dump |
OPNEXUS_VERSION in .env ist die "Spur": 1.9.0 pinnt exakt, 1.9 folgt
Patch-Releases, 1 folgt allen 1.x. opnexus update ohne Argument holt die
neueste Version der gewählten Spur; mit Argument wird die Spur umgestellt.
Nach einem Rollback steht OPNEXUS_VERSION fest auf der alten Version.
Rollback mit Datenbank-Wiederherstellung bedeutet: Alles steht auf dem Stand
vor dem Update, was danach geschrieben wurde, ist weg — und die
Log-Historie und Ressourcen-Messwerte sind danach leer, weil das Backup
sie bewusst nur als Schema enthält (sie füllen sich aus den Firewalls wieder).
Vor dem Zurückspielen schreibt das Skript eine Sicherheitskopie des
jetzigen Zustands (/backups/pre-rollback-….dump), prüft vorher, dass das
Backup lesbar ist, und stellt bei einem Fehler die Sicherheitskopie wieder
her. Hat das Update die Datenbank gar nicht verändert, wird nichts
zurückgespielt. Mit OPNEXUS_UPDATE_TIMEOUT (Sekunden, Standard 240) lässt
sich die Wartezeit auf "gesund" für lange Migrationen verlängern,
OPNEXUS_NO_AUTO_ROLLBACK=1 schaltet das automatische Zurückrollen ab.
Der gesamte Weg (Update, Rollback, kaputtes Update mit automatischem Rollback, nicht existierende Version) wurde mit echten Images gegen eine lokale Registry durchgespielt, inklusive des realen 1.8.1-Stands als Ausgangspunkt.
Forgejo: Die Workflows liegen in .github/workflows/, das liest
Forgejo mit, solange es kein .forgejo/workflows/ gibt. Voraussetzungen:
Actions im Repository aktiviert, ein forgejo-runner mit Docker-Zugriff (für
den Image-Build zusätzlich privilegiert, wegen QEMU/arm64), ein Runner-Label,
das zu runs-on: ubuntu-latest passt, und als Secret REGISTRY_TOKEN ein
persönliches Zugriffstoken mit write:package (das automatische Job-Token
darf nicht in die Paket-Registry schreiben). Die verwendeten Actions
(actions/checkout, docker/*) liegen als Spiegel auf data.forgejo.org.
Der Build-Cache ist bewusst aus, weil type=gha nur auf GitHub existiert.
Ein späterer Umzug auf eine eigene Domain ändert nur diese
Einstellungen und OPNEXUS_IMAGE_REPO bei den Anwendern.
Datenbank-Namen
Neue Installationen legen Datenbank und Benutzer als opnexus an (Test-
Datenbank opnexus_test). Beide Namen lassen sich über POSTGRES_DB und
POSTGRES_USER in der .env setzen; sie wirken nur beim allerersten Start
eines leeren Volumes. docker-compose.yml, die Backup-Funktion und das
opnexus-Kommando lesen dieselben Variablen.
Ältere Installation, die mit anderen Namen angelegt wurde: die bisherigen
Namen unverändert in der .env eintragen (POSTGRES_DB=<bisheriger Name>,
POSTGRES_USER=<bisheriger Name>; nachsehen mit
docker compose exec postgres psql -U <Benutzer> -l). Dann läuft alles wie
zuvor, ohne Umbau der Daten. Wer umbenennen möchte: Dienste außer postgres
stoppen, per docker compose exec postgres psql -U <Benutzer> -d postgres
zuerst ALTER DATABASE <alt> RENAME TO opnexus; und ALTER ROLE <alt> RENAME TO opnexus; ausführen, danach POSTGRES_DB/POSTGRES_USER in der .env auf
opnexus setzen oder entfernen und neu starten. Vorher mit opnexus backup
sichern; das Passwort bleibt erhalten (SCRAM).
Backup & Restore der eigenen OPNexus-Datenbank
Zu unterscheiden vom App-Feature unter „Backups" auf der Host-Detailseite
(das sichert die Config der verwalteten Firewalls): der neue
postgres-backup-Service in docker-compose.yml sichert OPNexus'
eigene Datenbank (Hosts, Regeln, NAT, VPN-/Zertifikat-Entitäten,
Audit-Trail, Deployment-Historie) — läuft automatisch mit, ein
pg_dump direkt beim Start und danach alle 24h, Aufbewahrung 14 Tage, im
Volume postgres_backups. Secrets (API-Keys, PSKs, private Schlüssel)
liegen ohnehin nie in Postgres (siehe die *_secret_store.py-Module) und
sind entsprechend auch nicht im Dump enthalten — deren eigene Volumes
(secrets, ipsec_psk_secrets, etc.) separat sichern, falls gewünscht.
Bewusst ausgeschlossen: firewall_log_entries/host_resource_samples
(--exclude-table) — betriebliche Historie, kein Konfigurationszustand,
und bei aktiver Log-Erfassung potenziell mehrere GB groß (siehe
"Log-Erfassung" in den Einstellungen sowie den nächsten Absatz) — ein
Restore bringt also keine alte Log-/Resource-Historie zurück, nur den
eigentlichen Konfigurationszustand.
Restore (getestet gegen eine frische, leere Datenbank — nicht
--clean gegen die bestehende verwenden: schlägt fehl, wenn das Ziel-
Schema nicht exakt in Dump-Reihenfolge abgebaut werden kann, siehe
unten):
docker compose exec postgres-backup ls /backups # verfügbare Dumps anzeigen
docker compose cp postgres-backup:/backups/<dateiname>.dump /tmp/restore.dump
docker compose cp /tmp/restore.dump postgres:/tmp/restore.dump
docker compose stop backend frontend
docker compose exec -e PGPASSWORD=<PASSWORT> postgres \
psql -U opnexus -d postgres -c "DROP DATABASE opnexus; CREATE DATABASE opnexus OWNER opnexus;"
docker compose exec -e PGPASSWORD=<PASSWORT> postgres \
pg_restore -h localhost -U opnexus -d opnexus /tmp/restore.dump
docker compose up -d backend frontend
Der Zwischenschritt DROP DATABASE+CREATE DATABASE ist nötig, weil
pg_restore beim Wiederherstellen in eine bereits (z.B. von
create_all()) befüllte Datenbank auf Konstrukt-Konflikte läuft (leere
Zieldatenbank ist der von pg_restore selbst empfohlene Weg für einen
vollständigen Custom-Format-Dump) — entsprechend nur im echten
Notfall-Restore ausführen, nicht routinemäßig.
Backend-Tests ausführen
docker compose exec -e DATABASE_URL="postgresql+asyncpg://opnexus:<PASSWORT>@postgres:5432/opnexus" \
backend python -m pytest -v
Postgres legt beim ersten Start automatisch eine zweite Datenbank
opnexus_test an (backend/postgres-init/001-create-test-db.sh).
tests/conftest.py hängt _test an den DB-Namen aus DATABASE_URL an,
damit Tests nie echte Daten anfassen; jede Tabelle wird nach jedem Test
geleert. Alle OPNsense-API-Aufrufe sind in den Tests mit respx gemockt.
Historisches Ergebnis vor der Policy-Erweiterung (27 Tests):
tests/test_auth.py::test_login_success PASSED
tests/test_auth.py::test_login_wrong_password PASSED
tests/test_auth.py::test_login_lockout_after_repeated_failures PASSED
tests/test_auth.py::test_unauthenticated_request_rejected PASSED
tests/test_auth.py::test_me_reports_authenticated_state PASSED
tests/test_auth.py::test_logout_invalidates_session PASSED
tests/test_auth.py::test_health_and_docs_stay_public PASSED
tests/test_deploy_safety.py::test_deploy_starts_in_pending_state PASSED
tests/test_deploy_safety.py::test_confirm_makes_deployment_permanent PASSED
tests/test_deploy_safety.py::test_redeploy_unchanged_payload_skips_pending PASSED
tests/test_deploy_safety.py::test_sweep_reverts_fresh_deploy_by_deleting_it PASSED
tests/test_deploy_safety.py::test_sweep_reverts_redeploy_by_restoring_previous_payload PASSED
tests/test_deploy_safety.py::test_sweep_marks_revert_failed_when_host_unreachable PASSED
tests/test_hosts.py::test_create_list_delete_host PASSED
tests/test_hosts.py::test_create_host_duplicate_name_rejected PASSED
tests/test_hosts.py::test_delete_unknown_host_404 PASSED
tests/test_hosts.py::test_update_host_cluster_name PASSED
tests/test_hosts.py::test_live_rules_parses_real_shape PASSED
tests/test_hosts.py::test_live_rules_host_unreachable_returns_502 PASSED
tests/test_hosts.py::test_firmware_info_parses_real_shape PASSED
tests/test_hosts.py::test_firmware_info_host_unreachable_returns_502 PASSED
tests/test_rules.py::test_create_rule PASSED
tests/test_rules.py::test_deploy_rule_success PASSED
tests/test_rules.py::test_deploy_rule_mixed_host_failure PASSED
tests/test_rules.py::test_delete_rule PASSED
tests/test_rules.py::test_deploy_rule_without_targets_rejected PASSED
tests/test_status.py::test_status_mixed_online_offline PASSED
27 passed in 4.36s
Abgedeckt: Login (Erfolg/Fehlschlag/Lockout), unauthentifizierter Zugriff abgelehnt, Host-CRUD, Host-Bearbeiten (Cluster-Zuweisung), Live-Regeln (reale Antwortform + Fehlerfall bei nicht erreichbarem Host), Firmware-/Versionsinfo (reale Antwortform + Fehlerfall), Regel anlegen, Regel-Deploy (Erfolg/Fehlerfall bei nicht erreichbarem Host, gemischt mit einem erfolgreichen Host in derselben Regel), Regel-Löschung, Status-Endpoint mit gemischtem online/offline-Ergebnis, sowie der komplette Deploy-Sperrschutz-Zyklus (pending → bestätigt / pending → automatisch zurückgerollt / Rollback selbst fehlgeschlagen).
OPNsense-API: verifizierte Endpunkte
Der Auftrag markierte mehrere Pfade in opnsense_client.py als "vor
Rollout verifizieren". Verifiziert gegen die offizielle OPNsense-
Dokumentation (docs.opnsense.org) und den OPNsense-Core-Quellcode auf
GitHub (FilterController.php, Diagnostics/Api/InterfaceController.php,
Diagnostics/Api/FirewallController.php):
| Zweck | Methode | Pfad | Status |
|---|---|---|---|
| Regeln suchen/auflisten | GET/POST | /api/firewall/filter/searchRule |
verifiziert (Core-Quellcode) |
| Regel anlegen | POST | /api/firewall/filter/addRule |
verifiziert |
| Regel ändern | POST | /api/firewall/filter/setRule/{uuid} |
verifiziert |
| Regel löschen | POST | /api/firewall/filter/delRule/{uuid} |
verifiziert |
| Änderungen anwenden | POST | /api/firewall/filter/apply |
verifiziert — zwingend nach jedem add/set/del, sonst bleibt die Änderung nur "staged" |
| CARP/VIP-Status | GET | /api/diagnostics/interface/getVipStatus |
verifiziert (getVipStatusAction() im Core-Quellcode) |
| CARP-Wartungsmodus umschalten | POST | /api/diagnostics/interface/carp_status/maintenance |
verifiziert (InterfaceController::carpStatusAction() und scripts/interfaces/carp_set_status.php) — persistenter, absichtlich nicht idempotenter Toggle; nur über zustandsgebundene Einmal-Vorschau, niemals automatischer Retry |
| Firewall-Log | GET | /api/diagnostics/firewall/log?limit=N[&digest=...] |
verifiziert (logAction() im Core-Quellcode); digest (inkrementeller Cursor, siehe "Zentrale Log-Suche" oben) zusätzlich gegen scripts/filter/read_log.py verifiziert |
| Firmware-/Versionsinfo | GET | /api/core/firmware/info |
verifiziert (FirmwareController::infoAction()) — bewusst nicht firmware/status, das einen langsamen Netzwerk-Update-Check auslöst; info liefert die installierte Version ohne das (live gemessen: ~0,9s) |
| IPsec Connection anlegen/ändern/löschen | POST | /api/ipsec/connections/{add|set|del}Connection[/{uuid}] |
verifiziert (Core-Quellcode, ConnectionsController.php) — nur gegen respx-Mocks getestet, nie gegen echte Hosts (siehe "IPsec-VPN-Management" oben) |
| IPsec Local/Remote-Identität anlegen/ändern/löschen | POST | /api/ipsec/connections/{add|set|del}{Local|Remote}[/{uuid}] |
verifiziert (ConnectionsController.php, gleiche Controller-Klasse) — nur respx-Mocks |
| IPsec Phase 2 (Child) anlegen/ändern/löschen | POST | /api/ipsec/connections/{add|set|del}Child[/{uuid}] |
verifiziert (ConnectionsController.php) — nur respx-Mocks |
| IPsec Pre-Shared-Key anlegen/ändern/löschen | POST | /api/ipsec/preSharedKeys/{add|set|del}Item[/{uuid}] |
verifiziert (PreSharedKeysController.php, separates Modell OPNsense\IPsec\IPsec) — nur respx-Mocks |
| IPsec-Dienst neu laden | POST | /api/ipsec/service/reconfigure |
verifiziert (ServiceController::reconfigureAction, geerbt von ApiMutableServiceControllerBase) — Erfolgsantwort {"status":"ok"}, kein result-Schlüssel; nur respx-Mocks |
| OpenVPN-Instanz anlegen/ändern/löschen | POST | /api/openvpn/instances/{add|set|del}[/{uuid}] |
verifiziert (Core-Quellcode, InstancesController.php, extends ApiMutableModelControllerBase) — nur respx-Mocks |
| OpenVPN Static-Key anlegen/ändern/löschen | POST | /api/openvpn/instances/{add|set|del}StaticKey[/{uuid}] |
verifiziert (InstancesController.php, gleiche Controller-Klasse, separates Modell StaticKeys.StaticKey) — nur respx-Mocks |
| OpenVPN-Dienst neu laden | POST | /api/openvpn/service/reconfigure |
verifiziert (ServiceController::reconfigureAction, eigenständige ApiControllerBase-Methode, nicht von ApiMutableServiceControllerBase geerbt) — Erfolgsantwort {"result":"ok"}, kein status-Schlüssel (bewusst nicht symmetrisch zu IPsec angenommen); nur respx-Mocks |
| WireGuard Server anlegen/ändern/löschen | POST | /api/wireguard/server/{add|set|del}Server[/{uuid}] |
verifiziert (Core-Quellcode, ServerController.php, extends ApiMutableModelControllerBase) — nur respx-Mocks |
| WireGuard Peer anlegen/ändern/löschen | POST | /api/wireguard/client/{add|set|del}Client[/{uuid}] |
verifiziert (ClientController.php) — setClientAction/delClientAction pflegen als Nebeneffekt auch die denormalisierte Server.peers-Liste (siehe "WireGuard-Tunnel-Verwaltung" oben); nur respx-Mocks |
| WireGuard-Dienst neu laden | POST | /api/wireguard/service/reconfigure |
verifiziert (ServiceController::reconfigureAction, eigenständige Override-Methode) — Erfolgsantwort {"result":"ok"}, wie OpenVPN, kein status-Schlüssel; nur respx-Mocks |
| Dienste auflisten | GET | /api/core/service/search |
verifiziert (Core\Api\ServiceController::searchAction()) — live gegen fw-primary/fw-secondary gelesen (2026-09-17), liefert id/name/description/locked/running je Dienst |
| Dienst starten/stoppen/neustarten | POST | /api/core/service/{start|stop|restart}/{name}[/{id}] |
verifiziert (ServiceController::{start|stop|restart}Action($name,$id='')) — Erfolgsantwort {"result":"ok"|"failed"}; nur respx-Mocks, nie gegen echte Hosts ausgelöst |
| Speicherbelegung | GET | /api/diagnostics/system/systemResources |
verifiziert — live gegen fw-primary gelesen, memory.total/memory.used in Bytes |
| Load-Average/Uptime | GET | /api/diagnostics/system/systemTime |
verifiziert — live gelesen, loadavg als "1min, 5min, 15min"-String |
| Plattenbelegung | GET | /api/diagnostics/system/systemDisk |
verifiziert — live gelesen, devices[].used_pct je Mountpoint |
| Interface-Zähler | GET | /api/diagnostics/interface/getInterfaceStatistics |
verifiziert — live gelesen, kumulative received-bytes/sent-bytes je Interface-Eintrag |
Plugin-Frage geklärt
Es gibt kein OPNsense-Plugin namens os-firewall. Die Firewall-
Filter-/Alias-API ist Bestandteil des OPNsense-Core-Systems und auf jeder
Installation ohne zusätzliches Plugin vorhanden. Es muss also nichts
installiert werden, bevor dieses Tool Regeln pushen kann.
Live-Verifikation gegen die echten OPNsense-Instanzen — ✅ erfolgreich
Der Nutzer hat beide echten Hosts (fw-primary = 10.0.0.2,
fw-secondary = 10.0.0.3) direkt über die Panel-Oberfläche mit
eigenen API-Keys angelegt. Damit sind beide "vor Rollout verifizieren"
markierten Endpunkte gegen echte Hardware bestätigt:
getVipStatus:GET /api/hostsliefert für beide Hosts korrektstatus: "online"sowiecarp_status: "MASTER"(fw-primary) bzw."BACKUP"(fw-secondary) — exakt passend zur bekannten Topologie.- Firewall-Log:
GET /api/logs?host_id=...liefert reale Log-Einträge als flaches JSON-Array (kein{"rows": [...]}-Envelope — der Fallback-Zweig inrouters/logs.pygreift korrekt). Die tatsächlichen Feldnamen entsprechen exakt der Annahme im Frontend:action,interface,dir,protoname,src/srcport,dst/dstport,label,__timestamp__— keine Anpassung nötig.
Weiterhin nicht getestet: addRule/setRule/delRule/apply
(Regel-Deploy) gegen diese echten Hosts — bewusst, um die produktiven
Firewalls nicht ungetestet zu verändern (siehe "Offene Punkte").
About-Seite: Fleet-Status & Versionen (/about)
Das Logo liegt als Inline-SVG in frontend/components/Logo.tsx und
wird von frontend/components/Nav.tsx auf /about verlinkt. Die
Infoseite ist in frontend/app/about/page.tsx implementiert.
Erreichbar über Klick auf das OPNexus-Logo oben links (auf jeder Seite
außer Login). Eine Tabelle über alle Hosts mit Name, Adresse, Live-
Status, CARP-Rolle, Cluster, installierter OPNsense-Version
(GET /api/hosts/{id}/firmware-info, neu) und Live-Regelanzahl. Lädt
progressiv wie die Cluster-Ansicht: die Tabelle erscheint sofort mit
allen Hosts, Version und Regelanzahl füllen sich pro Host unabhängig
nach, sobald die jeweilige Firewall geantwortet hat.
Bewusst keine erfundene "OPNexus-Versionsnummer" — für dieses Projekt gibt es keine Release-/Versionierungs-Infrastruktur, eine Zahl hier wäre reine Staffage. Die Seite zeigt, was real ist: den Zustand der verwalteten Firewall-Flotte.
Live verifiziert: beide echten Hosts zeigen 26.7.3_11 als
installierte OPNsense-Version — stimmt überein mit dem, was
fw-primary/fw-secondary tatsächlich laufen haben.
Diese Versionsangabe dokumentiert die damalige Live-Prüfung, keinen festen Sollstand. Die Seite fragt die Daten beim Öffnen ab; ein regelmäßiges automatisches Aktualisieren ist derzeit nicht implementiert. Fehler bei Version oder Regelanzahl erscheinen pro Host als „nicht abrufbar“ mit der Fehlermeldung im Tooltip.
Übernahmeprüfung am 2026-09-07: SVG-Komponente,
Logo-Verlinkung und Inhalte der Infoseite wurden im Quellcode geprüft.
Alle drei OPNexus-Container liefen, PostgreSQL meldete healthy und
der Backend-Healthcheck lieferte {"status":"ok"}. Bei dieser Prüfung
wurden weder die bisherigen Browser-/Backend-Tests wiederholt noch
Firewall-Versionen erneut live abgefragt. Die oben dokumentierten
Test- und Live-Ergebnisse stammen aus der vorherigen Umsetzung.
Deploy-Sperrschutz (Lockout Protection)
OPNsense's Filter-API hat keinen eingebauten Rollback-/Bestätigungs-
Mechanismus (direkt im Core-Quellcode FilterController.php geprüft —
kein revert/rollback/savepoint/confirm vorhanden; eine
Web-Suche, die einen savepoint/cancelRollback()-Mechanismus
behauptete, war falsch, vermutlich mit einem anderen Produkt verwechselt).
Deshalb ist das komplett selbst gebaut:
- Ein Deploy wird sofort angewendet (
addRule/setRule+apply), landet danach aber im Status "pending" mit einer Ablauffrist (DEPLOY_CONFIRM_WINDOW_SECONDS, Standard 180s). - Klick auf "Bestätigen" (im Frontend bei jeder Regel mit offenem
Pending-Status sichtbar) macht die Änderung endgültig (
POST /api/rules/{id}/confirm). - Ein Hintergrund-Task im Backend (alle 10s) prüft auf abgelaufene,
unbestätigte Deployments und rollt sie automatisch zurück: War es die
erste Bereitstellung auf diesem Host, wird die Regel dort komplett
wieder entfernt (
delRule+apply); war es ein erneutes Deployment mit geändertem Inhalt, wird der vorherige Stand wiederhergestellt (setRule+applymit dem zuvor gespeicherten Payload). Schlägt der Rollback selbst fehl (Host nicht erreichbar), bleibt der Datensatz mit Status"revert_failed"und Fehlermeldung sichtbar stehen — das braucht dann manuelle Aufmerksamkeit. - Ein erneutes Deploy einer bereits bestätigten Regel mit unverändertem
Inhalt führt keine Firewall-Aufrufe aus. Solange ein Deployment noch
pendingist, wird ein erneuter Push mit 409 abgelehnt. Bestätigung ist eine eigene Aktion und nach Ablauf der Frist nicht mehr möglich.
Bewusste Scope-Entscheidung: Der Schutz gilt nur für Deploy (Regel anlegen/aktualisieren auf einem Host), nicht für Löschen einer Regel. Löschen ist bereits eine bewusste, explizite Aktion (kein "push-and-hope"), und beides gleichzeitig abzusichern hätte den Umfang dieser Phase deutlich vergrößert — mit dem Nutzer so abgestimmt.
Live gegen eine echte Instanz verifiziert (2026-09-12). Der gesamte
Ablauf oben (addRule→apply, Status pending, Bestätigen→success,
sowie eine zweite Runde mit Bearbeitung→setRule→apply, und
abschließend Löschen→delRule→apply) wurde real gegen fw-secondary
durchgespielt — nicht nur die respx-Mocks. Details, Sicherheitsvorkehrungen
(RFC-5737-Testadressen, ungenutztes Dummy-Interface) und Ergebnis siehe
"Offene Punkte" unten.
Einzel- und Clusteransicht
Zwei neue Ansichten, plus eine echte Regel-Bearbeitung für Hosts (Name, Cluster-Zugehörigkeit, TLS-Prüfung, Aktiv-Status) — bisher gab es dafür nur Anlegen/Löschen:
- Einzelansicht (
/hosts/{id}, per Klick auf einen Hostnamen): Status/ CARP-Detail, die von OPNexus zentral verwalteten Regeln für genau diesen Host, und die tatsächlich auf der Firewall konfigurierten Regeln live abgefragt (GET /api/hosts/{id}/live-rules→searchRule) — inklusive Regeln, die nie über dieses Tool angelegt wurden (z.B. die eingebauten Default-Regeln oder von Hand konfigurierte). - Clusteransicht (
/cluster): gruppiert Hosts nach einem frei vergebbarencluster_name-Feld (z.B.ha-mgmtfür ein HA-Paar) und zeigt sie nebeneinander mit Live-Regelanzahl je Host — samt Warnung, wenn die Anzahl zwischen den Hosts eines Clusters abweicht (einfache Drift-Erkennung, kein vollständiger Feld-für-Feld-Diff). Hosts ohne Cluster-Namen erscheinen separat unter "Nicht gruppiert".
cluster_name ist bewusst kein eigenes DB-Modell mit eigener Verwaltung,
sondern nur ein freies Textfeld auf OpnsenseHost — ein Host gehört zu
höchstens einer Gruppe, es gibt keine clusterspezifischen Daten jenseits
des Namens selbst.
Live an den echten Firewalls verifiziert: Cluster-Namen für
fw-primary/fw-secondary gesetzt (Cluster heißt inzwischen fw-seg,
vom Nutzer selbst umbenannt), beide Ansichten im Browser durchgeklickt.
Dabei direkt eine reale Diskrepanz gefunden und korrekt angezeigt.
Fleet-weite Regel-Drift und Cluster-Logs
Auf Wunsch nach mehr zentralen Fleet-Funktionen — eine zentrale, fleet-weite Sicht statt nur pro Host:
-
Regeln ▸ "Live über alle Hosts" (neuer Tab neben dem bisherigen "Zentral verwaltet"): pro Cluster eine Tabelle, die genau zeigt, welche Regeln abweichen — nicht nur, dass sie abweichen (das reichte über die Clusteransicht allein nicht, siehe Feedback des Nutzers: "ich sehe, dass es einen Regel-Drift gibt, aber nicht welche"). Jede Zeile ist eine Regel, die nicht auf allen Hosts des Clusters identisch vorhanden ist, mit einer ✓/✗-Spalte pro Host. Die Clusteransicht verlinkt bei einer Abweichung direkt dorthin (
/rules?tab=live).Regeln haben keine über Hosts hinweg stabile ID (jede OPNsense-Instanz vergibt ihre UUID unabhängig) — der Abgleich läuft daher über einen Best-Effort-Fingerabdruck aus Beschreibung, Interface, Richtung, Protokoll, Quelle, Ziel und Aktion. Eine Regel, die auf zwei Hosts unterschiedlich konfiguriert ist, ohne dass sich eines dieser Felder unterscheidet, würde als "gleich" durchgehen — für den heutigen Zweck (grobe Drift-Erkennung) ausreichend, aber kein vollständiger Feld-für- Feld-Diff.
-
Logs ▸ Host-Auswahl: zusätzlich zu einzelnen Hosts steht jetzt "Cluster:
<name>(alle Hosts)" zur Auswahl — ruft die Logs aller Hosts des Clusters ab, mischt sie zeitlich sortiert in eine Tabelle und ergänzt eine Host-Spalte, damit die Herkunft jedes Eintrags erkennbar bleibt.
Live gegen beide echten Firewalls verifiziert: Live-Tab zeigt real
94 unterschiedliche Regel-Fingerabdrücke über den Cluster fw-seg, 66
auf beiden Hosts identisch, 28 weichen ab — mit genauer Angabe, auf
welchem Host welche fehlt. Cluster-Log-Auswahl liefert einen
zeitlich gemischten Strom beider Hosts mit korrekter Host-Spalte, keine
Konsolenfehler.
Frontend
Sieben geschützte Ansichten plus Login; / leitet auf /hosts um. Design wie gewünscht (abgerundete Ecken,
dezente Schatten, hellblaue Standard-Buttons, rote Löschen-Buttons,
dunkelblaue Bearbeiten-/Deploy-Buttons). Klick auf das Logo oben links
führt von jeder Seite zu /about:
- Login (
/login): eigenständige Seite ohne Navigation, leitet nach erfolgreichem Login auf/hostsweiter. Jede andere Seite prüft beim LadenGET /api/auth/meund leitet unangemeldet dorthin um (components/AuthGate.tsx). - About (
/about): Fleet-Status & OPNsense-Versionen — siehe eigener Abschnitt oben. - Hosts (
/hosts): Tabelle mit Status-Ampel (online/offline/ deaktiviert), CARP-Badge (MASTER/BACKUP) und Cluster-Spalte, Host hinzufügen (Modal: Name, Basis-URL, API-Key, API-Secret, Cluster, TLS-Prüfung), Host bearbeiten (dunkelblau — Name/Cluster/TLS/Aktiv), Host löschen. Klick auf einen Hostnamen → Einzelansicht (/hosts/{id}, siehe "Einzel- und Clusteransicht" oben). - Gruppen (
/groups): zentrale Policy-Gruppen mit Host-Mitgliedschaften, Beschreibung und zugewiesenen Regeln — siehe „Zentrale Policies“ oben. - Cluster (
/cluster): siehe "Einzel- und Clusteransicht" oben. - Regeln (
/rules): zwei Tabs — "Zentral verwaltet" (Liste aller zentralen Regeln mit Ziel-Host-Chips, "Neue Regel"-Formular, Deploy- Vorschau mit Feldvergleich und Ergebnis-Liste pro Host inkl. Pending/Bestätigen/Rollback, Bearbeiten, Duplizieren, Historie, Suche, Regel löschen, pollt alle 5s solange etwas pending ist) und "Live über alle Hosts" (Regel-Drift, siehe "Fleet-weite Regel-Drift" oben). - Logs (
/logs): zentrale, serverseitige Suche über die persistierten Firewall-Logs aller Hosts (siehe "Zentrale Log-Suche" oben) — Host- oder Cluster-Auswahl (oder alle Hosts), Freitextsuche, Filter (Aktion/Interface/Protokoll), Zeitraum-Presets, Stats-/Dashboard-Panel (Aktions-/Interface-Verteilung, Top-geblockte-Quellen, Top-Zielports), echte serverseitige Pagination (25 Einträge/Seite) statt Client-seitiger Array-Slice-Paginierung.
Responsive angepasst (verifiziert bei 375px/768px): Navigation klappt
unterhalb sm auf reine Icons zusammen, Formulare/Feldgruppen stapeln
sich einspaltig, Tabellen scrollen horizontal statt abgeschnitten zu
werden, Modals begrenzen sich auf 90% der Viewport-Höhe mit eigenem
Scroll. Ladezustände nutzen durchgängig Skeleton/Spinner
(components/ui/); Cluster-Ansicht und Regel-Live-Tab laden progressiv
— die Struktur erscheint sofort, jeder Host/Cluster füllt sich
unabhängig nach, ein langsamer Host blockiert nicht die ganze Seite.
Vollständig im echten Browser durchgeklickt (Playwright/Chromium, headless), inklusive Auth-Flow und Deploy-Sperrschutz:
- Unangemeldeter Zugriff → Redirect auf
/login; falsches Passwort wird abgelehnt; korrektes Login führt zu/hosts; Abmelden führt zurück zu/login. - Host anlegen → erscheint mit korrektem Status. Für den
Deploy-Sperrschutz-Test wurde ein kleiner Mock-OPNsense-Server
(Python-
http.server, im selben Docker-Netzwerk) als Host hinterlegt, daaddRule/applygegen eine echte oder unerreichbare Adresse nicht in einen "pending"-Erfolg münden kann — nur echte 200er-Antworten erzeugen den Pending-Zustand. Damit gezeigt: Deploy → Status "pending" mit Live-Countdown → Szenario A (nicht bestätigt): nach Ablauf von Fenster + Sweep-Intervall verschwindet der Deployment-Eintrag komplett (automatisch zurückgerollt) → Szenario B (bestätigt): Klick auf "Bestätigen" macht den Status dauerhaft "success", kein Countdown mehr. - Regel-Deploy gegen einen nicht erreichbaren Host zeigt weiterhin das korrekte Fehler-Ergebnis (rotes Warn-Icon + Fehlermeldung, kein Absturz).
- Keine Konsolen-Fehler außer erwarteten 401/502 nach Logout bzw. gegen nicht erreichbare Test-Hosts.
Netzwerk, DNS und DHCP (2026-10-09)
Neuer Bereich Netzwerk (/network). Je Firewall: Inventar und Änderungen für
Schnittstellen, VLANs, virtuelle IPs, Gateways, statische Routen, DNS-Einträge
(Unbound) und DHCP (dnsmasq-Hosts, Kea-Subnetze und -Reservierungen). Diese Objekte
sind firewall-spezifisch (eigene IPs, eigenes VLAN-Layout), deshalb gibt es hier
keine zentrale Kopie und keinen Multi-Host-Push: Die Firewall ist die Quelle der
Wahrheit, jede Änderung geht direkt dorthin, aber nur über Vorschau und Ausführung.
Ablauf einer Änderung (services/network_change_service.py):
- Vorschau (
POST /api/hosts/{id}/network/changes/preview): liest den Live-Stand, berechnet Diff, Risiken, Warnungen und Sperren, liefert einen Token über genau diesen Stand. - Ausführen (
POST /api/hosts/{id}/network/changes): liest den Stand erneut und verweigert bei abweichendem Token. Risikoreiche Änderungen brauchenacknowledge(die UI verlangt zusätzlich die Eingabe des Firewall-Namens). Dann: Konfig-Backup speichern (schlägt es fehl, wird nichts geändert), schreiben, anwenden (reconfigure), zurücklesen und mit dem Gewünschten vergleichen. Jeder Schritt landet im Audit-Log (kind=network:started,applied,unverified,apply_uncertain,rejected,aborted). - Nur Felder aus einer festen Liste je Objektart werden akzeptiert (
FIELDS), alles andere ergibt 422. Auswahllisten im Formular kommen von der Firewall selbst (GET …/network/template/{kind}): OPNsense bietet z. B. für eine Route nur die Gateways an, die es für gültig hält.
Schutz vor dem Kappen der eigenen Verbindung: OPNexus ermittelt aus der
Schnittstellenliste der Firewall, über welche Schnittstelle sie unter der
hinterlegten Adresse erreichbar ist (management_device). Bestätigungspflichtig
sind: Änderung oder Löschen dieser Schnittstelle, ihres VLANs oder ihres Gateways,
Standard-Gateway ändern/löschen, eine Standardroute, eine Route, die die
Firewall-Adresse abdeckt, und jede Änderung bestehender Objekte, wenn sich der
Verbindungsweg nicht bestimmen lässt (NAT, CARP-Adresse). Es gibt keinen
automatischen Rollback (anders als bei Regeln): OPNexus kann eine Änderung nicht
zurücknehmen, die seine eigene Verbindung trennt. Der Rückweg ist das Backup vor
der Änderung. DNS- und DHCP-Daten können die Verbindung nicht kappen und lösen
diesen Schutz nicht aus.
Schnittstellen-Zuweisung (/interfaces/assignment/*): zweistufig. set/add/del
merken die Änderung nur vor (Datei auf der Firewall), erst reconfigure wendet sie
an. Deshalb blockiert OPNexus eine neue Änderung, solange dort schon etwas
vorgemerkt ist (interface_changes_pending), sonst würde das Anwenden fremde
Änderungen mitnehmen. Beim Löschen entfernt OPNsense selbst alle Regeln und NAT-
Einträge, die die Schnittstelle nutzen; die Vorschau listet sie. Gesperrte
Schnittstellen (lock) lassen sich nicht löschen, das sagt die Vorschau vorab.
Beschreibungen dürfen nur Buchstaben, Ziffern und Unterstriche enthalten.
Die API kann die IP-Konfiguration erst ab OPNsense 26.7.6; auf 26.7.1 liefert
die Zuweisungsliste nur Name und Gerät. OPNexus erkennt das an den Daten
(can_configure_interfaces) und sperrt die Änderung mit Begründung.
Geheimnisse: vip_settings liefert das CARP-Passwort im Klartext, Kea-Subnetze
den DDNS-Schlüssel. Beides wird nie zurückgegeben, gespeichert oder auditiert: Das
Inventar wählt Felder namentlich aus, der „Vorher“-Zustand wird auf die erlaubten
Felder begrenzt, ein neues CARP-Passwort geht nur beim Ausführen an die Firewall.
Von der Firewall übernehmen (/rules → „Von Firewall übernehmen“,
services/rule_adopt_service.py): Regeln, die von Hand in OPNsense angelegt wurden,
werden als verwaltete Regel mit der vorhandenen UUID erfasst. Die Übernahme liest
die Regel frisch von der Firewall und schreibt nichts dorthin; erst die erste
Änderung läuft durch den normalen Ablauf (Vorschau, Push, Bestätigung, Auto-Revert).
Nicht übernommen werden Regeln, die das Modell verfälschen würde: invertierte
Schnittstelle/Quelle/Ziel, mehrere oder keine Schnittstelle, „Empfangen auf“, keine
Quick-Regel, Richtung weder ein noch aus, IPv4+IPv6 in einer Regel, fehlende
Beschreibung, Systemregeln. Die Gründe zeigt die Oberfläche. Regeln haben jetzt ein
Protokollieren-Flag (log, Migration 0003), weil jeder Push bisher fest log=0
schrieb. Felder, die OPNexus nicht kennt (Gateway, Zustandsoptionen, Zeitpläne, …),
bleiben unverändert, denn setRule überschreibt nur mitgesendete Felder (live
bestätigt).
Verifikation 2026-10-09: Endpunkte gegen die Core-Quellen geprüft und live auf
drei Test-Firewalls (test-fw-1/-2 mit 26.7.6 als HA-Paar, test-fw-3 mit 26.7.1_1):
je anlegen, ändern, löschen mit Backup, Apply und Rücklesen für VLAN, virtuelle IP,
Gateway, Schnittstellen-Zuweisung inkl. IP, Unbound-Eintrag, dnsmasq-Host,
Kea-Subnetz mit verschachtelten Optionen und Kea-Reservierung; Übernahme einer
Handregel mit Gateway und log=1, danach Bearbeiten und Ausrollen ohne Verlust.
Der Guard verweigerte das Löschen des Standard-Gateways und die Änderung der
WAN-Schnittstelle ohne Bestätigung. Browser (1440 px und 375 px, keine
Konsolenfehler, kein Overflow): Anlegen und Löschen von VLAN, Schnittstelle und
DNS-Eintrag über die Oberfläche. Der Live-Test fand drei Fehler, die Mocks nicht
zeigten (Gateway-priority als Zahl, Routen-Feld enabled statt disabled,
Beschreibungsmuster der Schnittstelle), der Browser-Test einen weiteren (Dialog
verschwand nach dem Neuladen). 534 Backend-Tests grün.
Weitere Verifikation (zweiter Durchlauf): Der Bestätigungs-Pfad wurde live
ausgeführt: Beschreibung der WAN-Schnittstelle mit Bestätigung geändert, und die
WAN-IP von test-fw-2 auf eine freie Adresse im selben Netz gesetzt, womit OPNexus
den Weg zur Firewall verliert (Ergebnis „angewendet, nicht prüfbar“, Audit
unverified, Backup vorhanden). Wiederherstellung: neue Adresse unter Hosts ›
Bearbeiten eintragen, WAN-IP über die Engine zurücksetzen, Adresse zurücksetzen.
Außerdem: Routen-Lebenszyklus (aktivieren/deaktivieren), CARP-VIP auf dem HA-Paar
(Backup zuerst, dann Master; Advskew ändern), Übernahme von Regeln mit Alias-Quelle,
IPv6 und Block/Log, Ablehnung einer Regel mit invertierter Quelle, Auto-Revert einer
unbestätigten Änderung an einer übernommenen Regel (Status reverted, ursprüngliche
Beschreibung und log=1 zurück), Übernahme auf beiden Knoten des HA-Paars,
Kea-Reservierung samt Subnetz, Unbound-Eintrag mit echter DNS-Abfrage (NXDOMAIN →
Antwort → NXDOMAIN). Browser: Anlegen, Bearbeiten und Löschen für VIP, Gateway, Route,
dnsmasq-Host, Kea-Subnetz und -Reservierung, Übernahme per Dialog, Nur-Lesen-Rolle
(alle Aktions-Buttons gesperrt), Englisch und Dark Mode ohne deutsche Reste,
axe-core-Prüfung der sieben Netzwerkseiten. Die Migration 0003 lief auf einer Kopie
der Prod-Datenbank (ohne Log-Tabelle): 0002 → 0003, bestehende Regeln log=false.
Lesend gegen die Prod-Firewalls (26.7.5): Inventar und Erkennung der
Verwaltungs-Schnittstelle (vlan0110) stimmen. Nicht getestet: die tatsächliche
Vergabe von DHCP-Leases (Kea ist auf den Test-Firewalls deaktiviert).
Bekannte Grenzen: Übernommene Regeln werden je Firewall einzeln übernommen. Wer eine übernommene Regel zusätzlich auf einen weiteren Host als Ziel setzt, legt dort eine zweite, doppelte Regel an (die Vorschau zeigt „create“). Keine Multi-Host-Vorlagen für Netzwerkobjekte, kein Rolling-Apply auf CARP-Paaren (die Vorschau weist auf den Partner hin), keine LAGG/Bridge/GRE/VXLAN-Verwaltung, keine dnsmasq-Bereiche, keine Kea-DDNS-Felder, Gateway-Gruppen nicht enthalten.
Definition of Done — Stand
docker compose up -d --buildstartet DB, Backend, Frontend fehlerfrei- Backend-Tests laufen grün (aktueller Stand im verlinkten Prüfprotokoll)
- Bestehendes InfraPanel läuft unverändert weiter (Container-Diff vor/
nach dem Setup verglichen:
infrapanel-frontend-1,infrapanel-backend-1,infrapanel-postgres-1unverändert) - Frontend im echten Browser durchgeklickt (Login/Logout, Host/Regel anlegen, Deploy, Bestätigen, automatischer Rollback, Logs, Löschen) — siehe "Frontend" oben
- Admin-Login mit Session-Cookie, Brute-Force-Schutz, Bootstrap aus
ADMIN_PASSWORD - Deploy-Sperrschutz: Pending-Status, Bestätigen-Button, automatischer Rollback nach Ablauf, Rollback-Fehler sichtbar gemacht
- Live-Verifikation von
getVipStatus/Log gegen die echten Hosts10.0.0.2/.3— siehe "Live-Verifikation" oben - Einzel- und Clusteransicht inkl. Live-Regelabfrage (
searchRule), live gegen beide echten Hosts verifiziert — siehe "Einzel- und Clusteransicht" oben - Fleet-weite Regel-Drift-Anzeige (welche Regeln genau abweichen) und Cluster-übergreifende Log-Ansicht — siehe "Fleet-weite Regel-Drift und Cluster-Logs" oben
- Geräte-/responsive Anpassungen (Nav, Tabellen, Modals, Logs- Paginierung) — verifiziert bei 375px/768px Viewport-Breite
- About-Seite mit Fleet-Status & OPNsense-Versionen pro Host, per
Logo-Klick erreichbar — live gegen beide echten Hosts verifiziert
(beide zeigen
26.7.3_11) - IPsec-VPN-Management (Site-to-Site, PSK) — Komposit-Entität, vereinfachtes Deploy-Status-Modell ohne Auto-Rollback, UI-Warn-Gate vor jedem Push, 11 neue Backend-Tests + volle Suite grün, Browser-Verifikation mit vollständig gemocktem Schreibpfad — siehe "IPsec-VPN-Management" oben
- OpenVPN-Instanz-Verwaltung — 2-Objekt-Komposit (Instance + optionaler Static-Key), gleiches Status-/Warn-Gate-Muster wie IPsec, 17 neue Backend-Tests + volle Suite grün, Browser-Verifikation mit vollständig gemocktem Schreibpfad — siehe "OpenVPN-Instanz- Verwaltung" oben
- WireGuard-Tunnel-Verwaltung — Server+Peer-Komposit (v1: genau ein
Peer pro Tunnel, kein Multi-Peer-Hub), denormalisierte
Peer↔Server-Verknüpfung serverseitig über
ClientControllergepflegt statt manuell, optionaler WireGuard-eigener Peer-PSK, gleiches Status-/Warn-Gate-Muster wie IPsec/OpenVPN, 16 neue Backend-Tests + volle Suite grün (255 gesamt), Browser-Verifikation mit vollständig gemocktem Schreibpfad — siehe "WireGuard-Tunnel- Verwaltung" oben. Damit ist die VPN-Management-Kategorie der Funktions-Roadmap (IPsec + OpenVPN + WireGuard) vollständig abgedeckt. - Zertifikatsverwaltung: Trust-Store (Core) + ACME/Let's Encrypt
(Plugin
os-acme-client) — bewusst pro-Host modelliert (hostlokale UUIDs), kein Warn-Gate für Trust-Store (kein Lockout-Risiko,opnsense/core#9399), zweistufiges Warn-Gate nur für ACMEsregister/sign(echter externer CA-Kontakt statt Verbindungsabbruch-Risiko), eigener Response-Parser für ACMEs dritte Antwortform, UUID-Injektion für den verlinkten Account inkl. Regressionstest, 22 neue Backend-Tests + volle Suite grün (277 gesamt), Browser-Verifikation mit vollständig gemocktem/api/**-Schreibpfad (inkl.register/sign) — siehe "Zertifikatsverwaltung" oben. - Benutzerverwaltung mit Rollen (2026-09-10).
AdminUserbekommt eine neue Spalterole(admin|read_only, manuelleALTER TABLEgegen die echte DB nötig — siehe "Schema-Änderungen"). Neuer RouterGET/POST /api/users,PATCH /api/users/{id}/role,POST /api/users/{id}/reset-password,DELETE /api/users/{id}(Selbst-Löschen blockiert; ein Admin kann sich nicht selbst zum letzten verbleibenden Admin herunterstufen —PATCH .../roleprüft das,DELETEbraucht keine eigene Prüfung mehr, weil der Selbst-Löschen-Check strukturell bereits ausschließt, dass dabei der letzte Admin verschwindet). Durchsetzung zentral inAuthMiddleware, nicht pro Router: jede nicht-GET-Anfrage einerread_only-Rolle wird mit 403 abgelehnt, bevor sie den jeweiligen Router erreicht — AusnahmePOST /api/auth/change-password, damit das eigene Passwort immer änderbar bleibt. Neue Seite/users(Nav-Gruppe „System"), Nur-Lesen-Badge im Header wenn die eigene Rolleread_onlyist. 12 neue Backend-Tests, volle Suite (318) grün, Browser-verifiziert (Benutzer per UI angelegt, alsread_onlyeingeloggt, Schreibzugriff mit 403 bestätigt abgelehnt,GETweiterhin funktionsfähig). Zusätzlich am selben Tag:PUT /settings(eigenes Farbschema) zur Allowlist ergänzt, da dieser Endpunkt ausschließlich die eigeneUserPreferences- Zeile schreibt — reine Selbstverwaltung, keine App-Konfiguration. Die ursprüngliche v1-Grenze (Schreib-Buttons bleiben im Frontend sichtbar/aktiv) wurde am selben Tag geschlossen: neuer Hookfrontend/lib/useReadOnly.tsplus natives<fieldset disabled={readOnly} className="contents">(kein Layout-Einfluss dankdisplay: contents) um Create-Modals, Zeilen-Aktionen (Bearbeiten/Löschen/Deploy) und Review-Overlays in allen 12 schreibfähigen Ansichten (Hosts, Regeln, Policy- Hierarchie, Gruppen, Aliase, alle drei NAT-Panels, IPsec, OpenVPN, WireGuard, Zertifikate Trust-/ACME-Tabs, Benachrichtigungen in den Einstellungen, Benutzerverwaltung selbst). Bewusst NICHT blockiert: Such-/Filter-/Zurücksetzen-Felder (Regeln/Aliase/ Gruppen/NAT haben alle eine Suche — die soll fürread_onlyweiter funktionieren), die Gruppenbaum-Navigation in der Policy- Hierarchie (Auswählen einer Gruppe ist keine Schreibaktion — HTML erlaubt kein "Wieder-Aktivieren" durch ein verschachteltes<fieldset disabled={false}>, sobald ein Vorfahr-Fieldset deaktiviert ist, daher musste die Baum-Navigation als Geschwister außerhalb des Fieldsets stehen, nicht als Kind), sowie Darstellung/Passwort in den Einstellungen (beide bereits laut Allowlist erlaubt)./certificates' „Live-Übersicht"-Tab sowie Cluster/Logs/Audit/About brauchten keine Änderung (keineapi.create/update/delete/deploy-Aufrufe dort, per grep bestätigt). Keine Backend-Änderung. Browser-verifiziert: alle zehn geschützten Hauptseiten mit deaktiviertem primären Anlegen-Button für einenread_only-Testaccount, alle Such-/ Filterfelder und die Baum-Navigation bestätigt weiterhin aktiv (inkl. echtem Klick-Durchlauf), Admin-Sitzung im Anschluss auf Regression geprüft — keine gefunden. Keine Konsolenfehler. - Fleet-Dashboard, neue UI-Bausteine, Konsistenz-Pass & vollständige
Toast-Abdeckung (2026-09-14/15).
/dashboardals neue Startseite (offene Aktionen, Fleet-/CARP-Gesundheit, Sicherheits-Warnungen, Aktivitäts-Feed); CSS-Variablen-Dark-Mode fürslate; neueBadge/Tabs/Table/EmptyState/Toast-Primitives, projektweit auf bestehende Seiten migriert; Erfolgs-Toasts auf praktisch jeder schreibenden Aktion inkl. aller 9 Deploy-Review-Overlays und aller VPN-/Zertifikats-Seiten — siehe "Fleet-Dashboard, neue UI-Bausteine und Konsistenz-Pass" oben. - CARP/HA vertieft (2026-09-15). Ereignis-Historie pro Cluster,
Split-Brain-/Kein-Master-Alarm über die bestehende
Benachrichtigungs-Infrastruktur, NAT-Drift-Erkennung für alle drei
NAT-Typen, optionaler Auto-Revert-Timer für den CARP-Wartungsmodus
(15/30/60 Minuten oder dauerhaft). 19 neue Backend-Tests, volle
Suite (357) grün, live gegen die reale Flotte browser-verifiziert
(Lese-/Anzeigepfade) — siehe "CARP/HA vertiefen" oben. Drei neue
notification_settings-Spalten benötigten den manuellen-ALTER TABLE-Workaround, siehe "Schema-Änderungen" und "Offene Punkte". - Versionierung: Semantic Versioning mit Codenamen (2026-09-17).
CHANGELOG.mdals Versions-/Namens-Register, erstes Release1.0.0"Falcon", Versionsanzeige auf Login/Footer//about/Einstellungen, Backend-FastAPI-Version synchron — siehe "Versionierung" oben. - Direct-View-Link, Dienst-Steuerung, Resource-Monitoring, geplante Wartung (2026-09-17). Vier DynFi-Manager-Lücken geschlossen (kein Multi-Tenancy/MSP — bewusst außerhalb des Ziels). 14 neue Backend-Tests, volle Suite (371) grün. Dienst-Auflistung und Resource-Sampling live gegen fw-primary/fw-secondary verifiziert (Read-only); Dienst-Start/Stopp/Neustart und das Zünden geplanter Wartungen bleiben mock-only. Das End-zu-Ende-Verhalten der Zeitplanung (Anlegen/Liste/Abbrechen) live gegen fw-primary verifiziert, ohne dass dabei je ein echter OPNsense-Call ausgelöst wurde — siehe "Direct-View-Link, Dienst-Steuerung, Resource-Monitoring, geplante Wartung" oben.
Fehlerbehebung (2026-10-06)
Gefunden beim ersten Live-Test gegen zwei frische OPNsense-26.7-Firewalls (Labor-Instanz, getrennt von der Produktiv-Instanz).
Beschädigte API-Antworten bei OPNsense 26.x (kTLS)
Symptom: Firmware-Info und Log-Ingest einer Firewall schlagen fehl, obwohl
die Firewall online ist und kleine Abfragen (Interfaces, Status, Regeln)
funktionieren. Im Backend-Log steht
malformed chunk footer bzw. beschädigte HTTP-Antwort, in der
Oberfläche ein allgemeiner 502-Fehler.
Ursache: OPNsense 26.7 verschlüsselt TLS im Kernel (kTLS,
kern.ipc.tls.enable=1). In der getesteten Umgebung (Proxmox/virtio) wurden
dadurch Antworten ab etwa 64 KiB beim Abruf von einem anderen Rechner
verfälscht – ab Byte ~65 266 steht Inhalt von einer anderen Stelle im Text.
Direkt auf der Firewall (curl https://127.0.0.1/…) und zwischen zwei
Firewalls war dieselbe Antwort fehlerfrei; TLS-Version (1.2/1.3) und
HTTP-Version spielten keine Rolle. Es ist kein Fehler in OPNexus.
Prüfen: Dieselbe große Antwort (z. B. /api/core/firmware/info) einmal von
OPNexus aus und einmal auf der Firewall selbst abrufen und die Prüfsummen
vergleichen.
Beheben: Auf der Firewall unter System ▸ Einstellungen ▸ Tunables den
Eintrag kern.ipc.tls.enable mit Wert 0 anlegen und anwenden (wirkt sofort,
bleibt nach einem Neustart erhalten). Per API: POST /api/core/tunables/addItem
mit {"sysctl": {"tunable": "kern.ipc.tls.enable", "value": "0", "descr": "…"}},
danach POST /api/core/tunables/reconfigure. Danach lieferte die Firewall
die 357 762 Byte große Antwort vollständig und mit identischer Prüfsumme.
Was OPNexus daran verbessert hat: Der Fehler wird jetzt als „beschädigte HTTP-Antwort“ mit dem Hinweis auf diesen Workaround gemeldet (statt nur „Verbindung fehlgeschlagen“), und die Log-Ingest-Warnung nennt den echten Grund statt pauschal „nicht erreichbar?“.
Zweite OPNexus-Instanz auf demselben Rechner (z. B. zum Testen)
Backend und Frontend veröffentlichen ihre Host-Ports (Standard 8100/3100,
nur auf 127.0.0.1). Sie lassen sich jetzt in der .env über BACKEND_PORT
und FRONTEND_PORT ändern. Für eine vollständig getrennte Instanz (eigene
Datenbank, eigene Secrets) das Compose-File in ein eigenes Verzeichnis legen
(oder docker compose -p <name> verwenden), dort eine eigene .env mit anderen
POSTGRES_PASSWORD/ADMIN_PASSWORD, PROXY_HTTPS_PORT (z. B. 8443) und
FRONTEND_ORIGIN setzen. Wichtig: Browser trennen Cookies nicht nach Port –
zwei Instanzen unter derselben IP daher in getrennten Browser-Profilen oder
Fenstern öffnen.
Äußere NAT-Regeln werden abgelehnt (OUTBOUND_MODE_BLOCKS_PUSH)
Das ist gewollt: Steht die Firewall auf „Automatic Outbound NAT“, würden manuell gepushte Regeln nicht wirken. OPNexus ändert den globalen Modus nie. Auf Firewall ▸ NAT ▸ Outbound auf „Hybrid“ oder „Manual“ stellen.
CARP-Verlauf: Erkennung im Takt der Benachrichtigungsprüfung
Rollenwechsel (MASTER/BACKUP) werden im Intervall der
Benachrichtigungsprüfung erkannt (Einstellungen ▸ Benachrichtigungen). Ein
Wechsel, der zwischen zwei Durchläufen wieder zurückgeht, taucht im Verlauf
nicht auf; der allererste Durchlauf nach dem Anlegen eines Clusters
protokolliert nur den Anfangszustand (– → MASTER/BACKUP).
Offene Punkte / bekannte Einschränkungen
- Rule-Deploy und Port-Forward-NAT-Deploy jetzt live gegen eine echte
OPNsense-Instanz verifiziert (2026-09-12, mit explizitem Go-ahead des
Nutzers). Gegen
fw-secondary(10.0.0.3) wurden nacheinander eine Firewall-Regel (addRule→apply, dann Bearbeitung →setRule→apply) und eine Port-Forward-NAT-Regel (gleicher Ablauf) über die echte App-UI-Pipeline (Vorschau → Deploy →pending→ Live-Fetch- Verifikation → Confirm → Delete →delRule/apply→ erneute Live-Fetch-Verifikation) angelegt, bestätigt und wieder entfernt. Bewusst so gewählt, dass dabei nie echter Verkehr betroffen sein konnte: Ziel-Interface waropt6("Dummy01", ein ungenutztes Test-Interface auf diesem Host), Quelle/Ziel/Portziel ausschließlich RFC-5737-Dokumentations-Adressen (203.0.113.0/24/198.51.100.0/24), die nie realem Datenverkehr entsprechen. Beide Testregeln wurden nach Abschluss vollständig wieder entfernt (live per erneutem Fetch bestätigt, keine verwaisten DB-Zeilen). Damit ist der komplette Schreibpfad (addRule/setRule/delRule/apply) für Regeln UND für Port-Forward-NAT erstmals real bestätigt, nicht mehr nur gegen Mocks. Weiterhin ungetestet gegen eine echte Instanz: 1:1- und Outbound-NAT (identischer Deploy-Code-Pfad wie Port-Forward, aber nicht einzeln live durchexerziert), sowiemoveRuleBefore(hierarchische Policies) — siehe nächster Punkt. IPsec/OpenVPN/ WireGuard/Zertifikate bleiben aus den bereits dokumentierten Risikogründen ebenfalls bei Mock-Verifikation (siehe deren eigene Abschnitte unten). moveRuleBefore(hierarchische Policies) weiterhin nie gegen eine echte OPNsense-Instanz getestet — nur respx-Mocks und host-lose Testdaten im Browser (siehe "Hierarchische Policies" oben).- Hierarchische Policies: Die früheren v1-Lücken (automatisches Reassert, Hierarchie-Targeting für Aliase/NAT und Drag & Drop) sind seit 2026-09-10 geschlossen. Offen bleibt ausschließlich die echte Schreibvalidierung gegen eine Nicht-Produktiv-OPNsense; die produktiven Hosts wurden dafür bewusst nicht verwendet.
- Kein automatisches Schema-Downgrade. Alembic verwaltet das Schema
seit 1.9.0 (siehe "Schema-Migrationen & Upgrade"); Rollback ist das
Pre-Upgrade-Backup einspielen, kein
alembic downgrade. - Release-Workflows nie auf einem echten Runner gelaufen. Die
Backend-Testschritte, der Frontend-Job (Lint, Typen, Build) und der Weg
Update → Rollback wurden lokal nachgespielt (siehe
docs/verification-2026-10-05-updates.md), aber wederrelease.ymlnoch der Multi-Arch-Build noch der Push in eine echte Registry. Für Forgejo fehlt dazu noch einforgejo-runner; ein Test-Tag wiev1.10.1-rc1ist der erste echte Lauf. - Pre-Upgrade-Backups aus 1.9.0 sind nicht zurückspielbar. Das erste
Backup der Live-Instanz (
pre-upgrade-legacy-to-0002-…) wurde noch mitpg_dump17 geschrieben (siehe CHANGELOG 1.10.0); ab 1.10.0 passt der Client. Die nächtlichen Dumps despostgres-backup-Services waren davon nie betroffen. - CARP-Auto-Revert-Sweep (2026-09-15) nie gegen eine echte OPNsense-
Instanz ausgelöst — nur respx-/zustandsbehaftete Mock-Tests. Ein
erster echter Lauf gegen
10.0.0.2/.3braucht ein eigenes, explizites Go-ahead, dieselbe Disziplin wie bei jedem anderen schreibfähigen Feature. - Kein Schutz gegen Regel-Löschen (siehe "Deploy-Sperrschutz" oben) — bewusste Scope-Entscheidung für diese Phase.
- Aus der vorherigen Phase weiterhin offen: keine Mandantenfähigkeit (laut Nutzer nicht benötigt), kein Export/Import der Regel- Definitionen.
- IPsec-VPN-Management, v1-Scope: nur PSK-Auth (kein Zertifikat/EAP),
ein Phase-2 (Child) pro Tunnel, keine Pools/Wildcard-Traffic-Selektoren,
kein OpenVPN/WireGuard — siehe "IPsec-VPN-Management" oben. Bewusst
kein Pending-/Bestätigen-/Auto-Revert-Fenster wie bei Regeln/NAT/
Aliasen — ein fehlgeschlagener
reconfigurekann die Management-Verbindung selbst kappen, ein automatischer Rollback-Versuch wäre dann ohnehin nicht mehr erreichbar; die App zeigt stattdessen nur ein klares Warn-Gate vor jedem Push, keinen falschen Sicherheitsschein. - IPsec-Schreibpfad (
addConnection/.../reconfigure) nie gegen eine echte OPNsense-Instanz getestet — nur respx-Mocks (Backend) und ein vollständig im Browser gemocktes Backend (Browser-Verifikation), nicht einmal host-lose echte Testdaten wie bei den Regel-Features, weil dieses Feature laut eigener Risikoanalyse gefährlicher live zu testen ist als Firewall-Regeln (siehe "IPsec-VPN-Management" oben). Vor dem ersten echten Rollout unbedingt zuerst gegen eine Nicht-Produktiv-Instanz testen. Gilt identisch für den OpenVPN- und den WireGuard-Schreibpfad (addServer/addClient/reconfigureetc.) — dieselbe Risikoeinstufung, dieselbe nur-respx-Mocks/vollständig-gemockter-Browser- Verifikationstiefe, siehe "OpenVPN-Instanz-Verwaltung"/"WireGuard- Tunnel-Verwaltung" oben. - WireGuard-Tunnel-Verwaltung, v1-Scope: genau ein Peer pro Tunnel
(kein Multi-Peer-Hub, obwohl OPNsenses reales Modell 1:n erlaubt), kein
Schlüsselpaar-Generator (Betreiber liefert Server-Privatschlüssel,
Server-Public-Key und Peer-Public-Key manuell), kein Live-Status
(
wg show), kein Config-Builder/Peer-Export, keine CARP-Abhängigkeit — siehe "WireGuard-Tunnel-Verwaltung" oben. Bewusst kein Pending-/ Bestätigen-/Auto-Revert-Fenster, identische Begründung wie bei IPsec/ OpenVPN (ein fehlgeschlagenerreconfigurekann die Management- Verbindung selbst kappen). Zusätzlich nicht unabhängig verifiziert: observeraddress/serverporttatsächlich als persistierter Wählpunkt für einen aktiv verbindenden Peer wirken (vs. nur Config-Builder-Eingabe) und ob OPNsenses separater, dienstweiterGeneral.enabled-Schalter (siehe oben) ein von OPNexus gepushtes Interface blockieren kann, wenn er nie manuell aktiviert wurde. - Zertifikatsverwaltung, v1-Scope: kein Mehrfach-Host-Push (hostlokale
UUIDs), keine DNS-Provider-/Validierungsverwaltung (Betreiber
konfiguriert das selbst in OPNsense, nur UUID-Referenz), kein eigener
ACME-Renewal-Scheduler (OPNsenses eigenes Cron bleibt maßgeblich), keine
restartActions-Verwaltung — siehe "Zertifikatsverwaltung" oben.register/signnie gegen eine echte externe Zertifizierungsstelle getestet — nur respx-Mocks (Backend) und ein vollständig im Browser gemockter/api/**-Schreibpfad (Browser-Verifikation), dieselbe Risikoeinstufung wie IPsec/OpenVPN/WireGuards Schreibpfad, plus die noch höhere Sensibilität eines möglichen echten CA-Kontakts. Vor dem ersten echtenregister/sign-Einsatz unbedingt zuerst mitletsencrypt_testgegen einen Test-/Staging-Account arbeiten. - Korrektur einer älteren Einschränkung: "kein Live-Log-Streaming,
keine Suchfunktion in Host-/Log-Tabellen" galt bis zur zentralen
Log-Suche oben (2026-09-08) — Suche/Historie/Dashboards über die
persistierten Firewall-Logs sind jetzt real (siehe "Zentrale
Log-Suche" oben). Weiterhin bewusst kein echtes Live-Streaming
(SSE/WebSocket) — schnelles Polling (Default 30s) macht die Ansicht
"quasi live", hält aber keine dauerhafte Verbindung offen. Ebenfalls
weiterhin offen: keine Volltext-/Trigram-Indizierung (
pg_trgm) für die Log-Suche, und OPNsenses__timestamp__-Feld liefert keinen Zeitzonen-Offset — gespeicherte Zeitstempel sind die Firewall-eigene Uhrzeit unkorrigiert (siehe "Zentrale Log-Suche" oben für Details). - Generische Dienst-Steuerung, v1-Scope: keine kuratierte Allowlist —
jeder von
/api/core/service/searchgemeldete Dienst ist grundsätzlich start-/stopp-/neustartbar, mit nur einem generischen Warnhinweis statt eines Dienst-spezifischen Risikotexts (bewusste Nutzer-Entscheidung gegen eine zu pflegende Liste) — siehe "Direct-View-Link, Dienst- Steuerung, Resource-Monitoring, geplante Wartung" oben. Start/Stopp/ Neustart nie gegen eine echte OPNsense-Instanz ausgelöst — nur respx-Mocks; nur die Auflistung (search) ist live verifiziert. - Resource-Monitoring, v1-Scope: fester 60s-Poll, keine admin-editierbare Einstellungszeile/-UI dafür (anders als z. B. die Log-Ingest-Intervalleinstellung); Interface-Zähler werden als rohe kumulative Bytes gespeichert, keine Rate-Berechnung (Bytes/Sekunde) zwischen zwei Samples — ein möglicher Ausbau, keine Korrektheitslücke bei der aktuellen 60s-Abtastrate.
- Geplante Wartung, v1-Scope: nur einmalige Zeitpunkte, kein
Recurring/Cron-Schema (bewusste Nutzer-Entscheidung). Ein blockierter
oder fehlgeschlagener Zündversuch wird geloggt und im Status der
geplanten Wartung sichtbar, aber nicht an das bestehende Webhook-/
E-Mail-Alerting angebunden — das System ist auf persistente
NotificationProblem-Zeilen mit eigenen Einstellungen zugeschnitten; eine neue Problem-Art dafür ist ein möglicher Ausbau, kein für dieses Feature nötiger Umfang. Das tatsächliche Zünden (Reboot/Update/ Major-Upgrade nach Ablauf der Zeit) nie gegen eine echte OPNsense- Instanz ausgelöst — nur respx-Mocks; das Anlegen/Auflisten/Abbrechen einer Zeitplanung selbst wurde live gegen fw-primary verifiziert, ohne dass dabei je ein OPNsense-Call stattfand (eine geplante Wartung ist vor dem Zünden nur eine OPNexus-eigene Datenbankzeile).













