Compare commits

..
36 Commits
Author SHA1 Message Date
Julian Boldt abd15318a3 Release v1.30.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Regel-Analyse: Bereinigung (nur Ansicht):** Auf **Regelwerk › Regel-Analyse** gibt es den Tab **Bereinigung** (`?tab=cleanup`). Er listet je Firewall die
  Regeln, die einen Blick wert sind: **ungenutzt** (OPNexus hat in 7/30/90/180 Tagen keinen Treffer gesehen), **verdeckt** (eine frühere Quick-Regel
  trifft schon alles, was sie trifft), **doppelt** und **lange deaktiviert**. Jeder Befund trägt eine Sicherheit (**sicher / vermutlich / unbekannt**, Tooltip mit
  dem Grund): „sicher“ gibt es nur mit vollständigen, frischen Belegen, bei einer Regel mit Zeitplan, ohne geladenen pf-Regelsatz oder bei Aliasen bleibt es
  höchstens „vermutlich“. Die Tabelle zeigt auch den letzten Treffer und ob die Regel entfernbar wäre (nicht: automatische Regeln, Regeln im Altformat, von OPNexus
  verwaltete Regeln – die entfernt man über die Regelseite). Es wird nur gelesen; **Entfernen folgt in einer späteren Version**. Die Sicherheitslage verlinkt ihre
  Befunde „ungenutzt/verdeckt/doppelt“ direkt dorthin; per API `GET /api/hosts/{id}/rule-analysis/cleanup?unused_days=30`, per MCP `rule_cleanup_candidates`.
- **Änderungsverlauf je Regel:** OPNexus liest alle 15 Minuten die Regeln jeder Firewall und merkt sich, was sich geändert hat – auch bei Regeln, die nicht von OPNexus
  stammen: angelegt, geändert (mit Vorher/Nachher), aktiviert, deaktiviert, entfernt, jeweils mit der Herkunft „über OPNexus“ (passender Audit-Eintrag) oder „extern“.
  Auf der Live-Regel-Ansicht (Regelwerk › Live über alle Hosts) öffnet die Zeilenaktion **Verlauf** die Ereignisse zusammen mit den gespeicherten Fassungen und dem Audit der
  verwalteten Regel. Die erste Beobachtung speichert nur den Ausgangszustand; der Verlauf wird 365 Tage aufbewahrt (`GET /api/hosts/{id}/live-rules/{uuid}/history`).
  Eine Umsortierung (andere Reihenfolge oder Gruppe) gilt als „verschoben“, nicht als Änderung. Eine leere oder unvollständige Antwort der Firewall (ohne
  automatische Regeln) wird verworfen; verschwinden mehr als die Hälfte der Regeln auf einmal, zählt erst die zweite gleiche Beobachtung. Die Herkunft „über OPNexus“
  erklärt nur Audit-Einträge seit der vorherigen Beobachtung, und jeder Eintrag nur eine Änderung.
  Schemaänderungen 0027 und 0028 (Tabellen und ein Feld für den Beobachtungszeitraum) laufen beim Start.
- **Regelnutzung genauer:** Die Zähler kommen jetzt aus `ruleStats` (von der Firewall schon je Regel summiert; ältere Versionen weiterhin über den pf-Regelsatz) und werden alle
  15 Minuten statt alle 6 Stunden gelesen. Regeln im Altformat (noch nicht migriert) werden ebenfalls erfasst. Das Beobachtungsfenster für „ungenutzt“ beginnt neu, wenn eine Regel eingeschaltet oder geändert wird, und nach einem pf-Neuladen mit langer Lücke bei den Zählern.
- **Verdeckt/doppelt in der Sicherheitslage genauer:** Der Vergleich kennt jetzt die echte Reihenfolge (Floating vor Interface-Gruppen vor Interface-Regeln), Netze und
  Portbereiche als Mengen (z. B. 10.1.0.0/16 liegt in 10.0.0.0/8), `inet46` und `tcp/udp`, und übergeht Regeln mit Zeitplan, Markierung oder Limits. Es findet dadurch
  mehr verdeckte Regeln als bisher, nie weniger belegte.
2026-10-22 10:27:02 +02:00
Julian Boldt ec20940139 Release v1.29.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Fehlende API-Rechte werden sichtbar:** Darf der API-Schlüssel einer Firewall den Gateway- oder Dienststatus nicht lesen (OPNsense antwortet
  mit 403), fiel das bisher niemandem auf, und der standardmäßig aktive Gateway-Alarm konnte nie auslösen. Jetzt zeigen die Host-Seite
  (Abschnitt „Gateways“) und die Dashboard-Karte „Fleet-/CARP-Gesundheit“ ein Warn-Badge „API-Rechte fehlen: Gateways, Dienste“; der Tooltip nennt die
  OPNsense-Berechtigung, die beim Benutzer oder bei der Gruppe fehlt (System: Gateways, Status: Services, Lobby: Dashboard). Zusätzlich gibt es
  den neuen Alarm **API-Rechte fehlen** (standardmäßig an, abschaltbar unter Einstellungen › Benachrichtigungen, gleiche Entprellung wie die
  anderen Messungs-Alarme) und die Prometheus-Metrik `opnexus_host_api_rights_missing{host,read}`. Nur eine ausdrückliche Ablehnung zählt;
  Zeitüberschreitungen oder Serverfehler bleiben „unbekannt“.
- **Gateway-Alarm pro Gateway abschaltbar:** In der Gateway-Liste der Host-Seite schaltet eine Glocke den Alarm für ein einzelnes Gateway aus
  (z. B. ruhendes Zweit-WAN oder tote IPv6-Gateways); ein „Alarm aus“-Badge zeigt es an. Ein offener Alarm dieses Gateways löst sich dabei
  auf. Nur Administratoren, die Änderung steht im Audit-Log. Das Dashboard zeigt bei mehr als acht Gateways „+ N weitere“.
- **Firewall-Oberfläche (GUI-Zugriff) härter:** Ein frei gewordener Platz wird für eine **andere** Firewall erst 5 Minuten nach dem Ende der letzten
  Sitzung vergeben (Schutz, falls dort noch ein Tab offen ist, der die Cookies des Platzes teilt); dieselbe Firewall bekommt ihn sofort wieder.
  Sind alle Plätze belegt oder noch in der Abkühlzeit, nennt die Meldung den Grund. Ändern sich Adresse, festgesetztes Zertifikat oder Tunnelweg
  einer Firewall, während ihre Oberfläche offen ist, endet die Sitzung spätestens nach 20 Sekunden. Das Limit von 64 gleichzeitigen Sitzungen
  verdrängt keine laufende Sitzung mehr: eine neue wird abgelehnt (mit Meldung), bis eine endet. **Hinweis:** Das GUI-Gateway-Image muss zur
  Backend-Version passen (wie bisher beim Update beider Container).
- **Anmeldung (Cookies) härter:** Das alte Sitzungs-Cookie `opnexus-session` wird bei HTTPS **nicht mehr akzeptiert**. **Alle, die sich seit der
  Umstellung auf 1.27 nicht neu angemeldet haben, müssen sich einmal neu anmelden**; Skripte mit rohem Sitzungs-Cookie brauchen den Namen
  `__Host-opnexus-session` (API-Tokens sind nicht betroffen, ohne HTTPS bleibt `opnexus-session` der normale Name). Beim Abmelden wird ein altes
  Cookie weiterhin gelöscht. Das Cookie, das die SSO-Anmeldung an den Browser bindet, heißt bei HTTPS jetzt `__Host-opnexus-oidc-state`; der
  Rückweg vom Anbieter wird nur noch als Seitenaufruf (Navigation) von einer fremden Seite zugelassen, und ohne dieses Cookie scheitert er immer.
- **Webhook, Benachrichtigungskanäle und SSO-Anbieter: Schutz vor DNS-Rebinding:** Bisher prüfte OPNexus die Adresse, auf die ein Name zeigt, und
  der HTTP-Client löste den Namen danach noch einmal auf; ein Server unter Fremdkontrolle konnte beim zweiten Mal eine interne Adresse liefern. Jetzt
  verbindet OPNexus genau zu der geprüften Adresse (Host-Name, TLS-Zertifikatsprüfung und Anfrage bleiben beim Namen); zeigt eine Antwort auch nur
  teilweise auf Loopback, Link-Local oder einen Cloud-Metadaten-Dienst (auch AWS-IPv6 und Alibaba), kommt keine Verbindung zustande und die Zustellung meldet „Zieladresse nicht
  erlaubt“. Private Netze bleiben erlaubt (eigener ntfy, Gotify, Matrix). Proxy-Umgebungsvariablen werden für diese Aufrufe nicht mehr beachtet.
- **Benachrichtigungen parallel zugestellt:** Webhook, E-Mail und alle Kanäle werden gleichzeitig bedient, jeder mit eigener Zeitgrenze (20 Sekunden),
  und ein Prüfdurchlauf sendet insgesamt höchstens 90 Sekunden. Ein hängender Kanal verzögert weder die anderen noch die nächsten Prüfungen; im Log
  steht dann „timed out“ (ohne Adresse oder Token). Wie bisher wird einmal je Ereignis zugestellt, ohne Wiederholung, **außer** der Durchlauf war so voll, dass
  für ein Ereignis noch gar kein Versuch stattfand: es wird im nächsten Durchlauf zugestellt. Die Zustellung beginnt jetzt nach allen Prüfungen
  eines Durchlaufs statt mitten darin.
- **Rollouts: Fingerabdruck, verantwortliche Person, überlappende Termine:** Der Fingerabdruck eines aktivierten Plans umfasst jetzt auch die
  ausgelassenen und verschobenen Termine seines Fensters: **wer einen Termin auslässt oder verschiebt, muss einen automatischen Plan danach neu
  aktivieren** (ohne solche Ausnahmen bleiben bestehende Aktivierungen gültig; **Pläne, deren Fenster schon Ausnahmen haben, nach dem Update
  einmal neu aktivieren**, sonst meldet der Plan „Plan geändert“ und startet nicht automatisch). Die verantwortliche Person (Aktivierende, Startende, Freigebende)
  wird zusätzlich als Konto-ID gespeichert (Migration 0026, bestehende Einträge werden über den Namen zugeordnet): ein gelöschtes und unter gleichem
  Namen neu angelegtes Konto gilt nicht mehr als dieselbe Person, der Lauf pausiert bzw. der automatische Start unterbleibt mit „Person darf nicht mehr
  ausrollen“. Angezeigt wird weiter der Benutzername. Überlappen sich zwei Termine eines Wartungsfensters, zählt der zuletzt begonnene (bisher der
  früheste), sodass ein älterer, längst abgeschnittener Termin keinen neuen Start mehr blockiert.
- **Kleinigkeiten:** Ressourcen-Alarme zeigen bei fehlendem Messwert keine Zahl mehr („None %“ entfällt). Der Dialog „Von Firewall übernehmen“ zeigt ohne aktive
  Firewall einen Hinweis statt dauerhaft „Lädt…“. Fehlermeldungen beim Übernehmen und Verknüpfen nennen die Gründe in Klartext (Deutsch/Englisch) statt
  roher Codes wie `name_exists`; dasselbe gilt für „GUI-Zugriff nicht eingerichtet“ und blockierte Netzwerkänderungen. Der Test-Knopf der
  Benachrichtigungskanäle ist für Nicht-Admins ausgeblendet (der Server verweigert den Test ohnehin). Der Tooltip der aktiven Plätze nennt keine
  Umgebungsvariable mehr. Eine Gateway-Sitzung, die der Gateway-Container nicht mehr bestätigt (Absturz), zählt für die Abkühlzeit der Plätze als
  beendet.
- **Grafana-Dashboard erweitert:** neue Felder für Gateways (erreichbar, Paketverlust, Latenz), Swap, gestoppte Dienste, fehlende API-Rechte und
  Rollouts nach Status; dazu die Metrik `opnexus_rollout_runs{status}`.
2026-10-22 01:58:06 +02:00
Julian Boldt 8128d14da4 Release v1.28.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Übernehmen: mit vorhandenem zentralem Objekt verknüpfen:** Dieselbe von Hand angelegte Regel, derselbe Alias oder dieselbe NAT-Regel auf mehreren
  Firewalls (z. B. beide HA-Knoten) wird nicht mehr als zweites Objekt übernommen. Im Dialog „Von Firewall übernehmen“ erscheint bei exakter
  Übereinstimmung (nach Einsetzen der Host-Variablen, bei Aliasen inkl. Verweisen) die Aktion **Verknüpfen**: die Firewall wird weiteres Ziel des
  vorhandenen Objekts, die Vorschau zeigt „keine Änderung“, und der nächste Rollout aktualisiert alle Firewalls an einem Objekt. Auf der Firewall wird
  dabei nichts geändert. Unterscheidet sich ein gleichnamiges Objekt, zeigt der Dialog die abweichenden Felder; verknüpft wird dann nie. „Alle
  übernehmbaren“ fragt vorher nach („3 verknüpfen, 2 neu übernehmen, 1 nicht übernehmbar“). Aliase, die bisher wegen „Name existiert“ abgelehnt wurden,
  lassen sich so übernehmen.
  Wartet das vorhandene Objekt gerade auf eine Bestätigung, wird nicht verknüpft; „Alle übernehmbaren“ lässt den Eintrag dann aus. Verknüpfen
  macht ein gerade geöffnetes Bearbeiten-Formular des Objekts ungültig, damit die neue Firewall nicht versehentlich wieder aus den Zielen fällt.

- **Tooltips überarbeitet:** Der deaktivierte Button „Oberfläche öffnen“ nennt jetzt den Grund im Klartext samt nächstem Schritt (statt nur einer
  Variablen wie `GUI_GATEWAY_TOKEN`); dasselbe gilt für ausgeschaltete Funktion, deaktivierte Firewall, Nicht-HTTPS-Adresse und ausgeschaltete
  Zertifikatsprüfung. Texte durchgehend in Du-Form, kürzere VPN-Einleitungen, verständlichere Hinweise bei gesperrten Buttons (Zertifikate, Benutzer,
  Dienste). Icon-Buttons (Bearbeiten, Löschen, Schließen, Reihenfolge) zeigen beim Darüberfahren ihren Namen.
2026-10-21 23:05:16 +02:00
Julian Boldt 95f297d3f2 Release v1.27.2
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **`opnexus update` prüft den Proxy vor dem Wechsel:** Nach dem Ziehen der Images validiert das Werkzeug die Caddy-Konfiguration des neuen
  Proxy-Images mit den Umgebungsvariablen *dieser* Installation. Ist sie ungültig, bricht das Update ab, bevor etwas gewechselt wurde.
  Der Fall aus 1.27.0 (Compose-Datei mit leerem `OPNEXUS_GUI_SITES`) wird dabei automatisch repariert: Das Werkzeug trägt den Platzhalter
  `OPNEXUS_GUI_SITES=https://localhost:9441` in die `.env` ein und sagt es. Installationen, die noch die Compose-Datei aus 1.27.0 haben,
  müssen also nur einmal `opnexus update` ausführen.
- **`opnexus update` erkennt einen abstürzenden Proxy:** Ein Dienst ohne Healthcheck (der Proxy) gilt jetzt erst nach 10 Sekunden
  durchgehender Laufzeit als gesund. Bisher meldete das Update „Fertig“, obwohl der Proxy in einer Neustart-Schleife hing, und das
  automatische Zurückrollen griff nicht.
2026-10-21 21:23:53 +02:00
Julian Boldt ad17dcad25 Release v1.27.1
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Fix (1.27.1): Proxy startete mit der neuen Compose-Datei nicht.** Mit der `docker-compose.yml` aus 1.27.0 gab Compose die Variable
  `OPNEXUS_GUI_SITES` als leeren Text an den Proxy weiter; Caddy nutzt seinen Standardwert aber nur bei *nicht gesetzter* Variable und
  brach mit „server block without any key“ ab, OPNexus war dann nicht mehr erreichbar. Die Compose-Datei setzt jetzt einen gültigen
  Platzhalter als Standard. Betroffen waren nur Installationen, die die neue Compose-Datei aus 1.27.0 übernommen hatten (ohne
  `OPNEXUS_GUI_SITES` in der `.env`); wer nur die Images aktualisiert hatte, war nicht betroffen. Sofort-Abhilfe ohne Update:
  `OPNEXUS_GUI_SITES=https://localhost:9441` in die `.env` eintragen und `docker compose up -d proxy` ausführen.
2026-10-21 20:44:14 +02:00
Julian Boldt 5cb2ebe1c6 Release v1.27.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Vorgesehen als MINOR-Release **1.27.0** „Firewall-Oberfläche über OPNexus“.

- **Firewall-Oberfläche über OPNexus:** Auf der Host-Seite öffnet **Oberfläche öffnen** die Weboberfläche der Firewall
  in einem neuen Tab, auch bei Firewalls hinter NAT im Management-Tunnel. Nur für Admins; jede Firewall läuft in einer
  eigenen Browser-Origin (Platz = Port), Cookies bleiben auf dem Server, das Ticket gilt einmalig 30 Sekunden,
  Inaktivitäts- und Gesamtlimit sind einstellbar, Ticket, Start und Ende stehen im Audit (Art „GUI-Zugriff“).
  Standardmäßig **aus**: braucht das Compose-Profil `gui`, `OPNEXUS_GUI_SITES`, `GUI_PORTS`, `GUI_GATEWAY_TOKEN`,
  `COOKIE_SECURE=true` und den Schalter unter Einstellungen (Handbuch, „Firewall-Oberfläche über OPNexus“).
  Live getestet auf OPNsense 26.7.
- **Einschränkungen:** kein WebSocket, keine automatische Anmeldung, höchstens 4 Plätze gleichzeitig. OPNexus-Adresse
  vorher in die Whitelist des Anmeldeschutzes (sshguard) der Firewall eintragen, sonst können Fehlanmeldungen die
  API-Zugriffe von OPNexus aussperren. Nach einem Neustart des wg-hub braucht der Tunnel 2 bis 4 Minuten.
- **Hinweis für Betreiber mit eigenen Skripten:** Bei HTTPS heißt das Sitzungs-Cookie jetzt `__Host-opnexus-session`
  (bestehende Sitzungen werden ohne Abmelden übernommen), und Cookie-Anfragen an `/api` mit `Sec-Fetch-Site: same-site/cross-site`
  werden abgelehnt. Zugriffe mit API-Token sind nicht betroffen.
2026-10-21 20:14:06 +02:00
Julian Boldt 18d489693d Release v1.26.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Geplant als MINOR-Release **1.26.0** (Regel-Analyse). Backend, MCP und Oberfläche.

- **Regel-Analyse „Welche Regel greift?“ (API):** `GET /api/hosts/{id}/rule-analysis/match` und
  `GET /api/rule-analysis/match` (ganze Flotte, Auswahl oder Gerätegruppe) beantworten für Quelle, Ziel, Protokoll
  und Port, ob eine Firewall das erste Paket einer neuen Verbindung durchlässt oder blockiert, welche Regel das
  entscheidet und warum (Prüfprotokoll Regel für Regel). Grundlage ist der tatsächlich geladene pf-Regelsatz
  inklusive automatischer Regeln, Floating- und Gruppenregeln, `quick`/letzter Treffer, Negation, verschachtelter
  Aliase (pf-Tabellentest der Firewall), Port-Aliase und Adressfamilien. Jede Antwort trägt eine ehrliche
  Konfidenz **sicher / vermutlich / unbekannt** mit Begründungscodes; was nicht belegbar ist (z. B. `tagged`,
  nicht auflösbare Aliase, geratenes Interface, NAT in der Nähe), wird nie als „sicher“ gemeldet. Nur lesend,
  auch mit `read`-Token nutzbar; es wird nichts gespeichert und nichts an der Firewall geändert.
- **MCP:** neues Werkzeug `rule_match` für KI-Assistenten (gleiche Abfrage, ohne Prüfprotokoll).
- **Seite „Regel-Analyse“ (Regelwerk):** Formular für Hosts oder Gerätegruppe, Quell-/Ziel-IP, Protokoll, Ports,
  Eingangs-Interface („automatisch“ oder Liste) und optional die Ausgangsseite. Die Abfrage steht in der Adresszeile
  (teilbar, wird beim Öffnen sofort ausgeführt). Je Firewall eine Karte mit Erlaubt/Blockiert/Abgewiesen, Sicherheit
  des Urteils, entscheidender Regel und Hinweisen; ein „unbekannt“-Ergebnis erscheint bewusst nicht als Urteil.
  Aufklappbares „Warum?“ zeigt jede geprüfte Regel mit ✓/✗/? je Feld, überstimmte Regeln durchgestrichen. Auch mit
  Nur-Lesen-Rolle nutzbar.
2026-10-21 13:12:11 +02:00
Julian Boldt a9f562eec6 Release v1.25.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Geplant als MINOR-Release **1.25.0** (Flotten-Rollout).

- **Rollouts in Wellen (System › Rollouts):** Firmware-Updates für die ganze Flotte in Ringen, z. B. erst
  `ring-canary`, dann `ring-pilot`, dann `ring-prod` (Wellen aus Gerätegruppen). Jeder Host läuft über die
  bestehende geplante Wartung (Backup vor dem Update, CARP-Übergabe für den MASTER, Audit), immer nur ein Host
  gleichzeitig und nur ein Rollout zur selben Zeit; im Cluster zuerst der BACKUP, dann der MASTER. Nach jeder Welle
  folgen eine Beobachtungszeit und eine **Gesundheitsprüfung** (erreichbar, Zielversion installiert, CARP je VHID,
  Gateways und Dienste wie vorher, keine offenen Sperren, optional Sicherheits-Score). Rot hält den Rollout an
  (nach einer Nachprüfung zwei Minuten später) und meldet sich. Anhalten, Fortsetzen (prüft zuerst alles bisher
  Aktualisierte erneut), Welle freigeben, Host überspringen und Abbrechen auf der Lauf-Seite; die Seite zeigt
  Wellen, Hosts (Version vorher → nachher, aktueller Schritt) und die Prüfmatrix.
- **Sicherheitsnetz:** Weicht die angebotene Version in einem späteren Ring von der des ersten Rings ab, hält der
  Rollout an (OPNsense installiert immer die neueste). Ein Host ohne Update wird übersprungen, ein fehlgeschlagener
  nie von selbst wiederholt. Angehalten wird auch, wenn die verantwortliche Person das Recht zum Ausrollen verliert,
  eine fremde Wartung im Cluster länger als 15 Minuten offen ist, der MASTER ohne Übergabe dran wäre oder ein Lauf
  älter als 14 Tage ist. Höchstens 10 Wellen und 100 Hosts je Plan.
- **Wiederkehrende Wartungsfenster:** wöchentlich oder monatlich (n-ter Wochentag, letzter, fester Tag) in einer
  Zeitzone, mit Sommerzeit-Regel, Abschneidezeit („neue Hosts nur in den ersten N Minuten“) und einzelnen Terminen
  zum Auslassen oder Verschieben. Ein Plan mit Fenster startet neue Hosts nur darin; mit **automatischem Start**
  (nur stabile Updates) beginnt er zu jedem Termin von selbst, aber nur, solange Plan, Gruppen und Fenster seit
  der Aktivierung unverändert sind. Jede Änderung deaktiviert ihn.
- **Geplante Wartung prüft sich selbst:** Neue Option „Ergebnis automatisch prüfen und abschließen“ (im Dialog
  vorausgewählt, bei Rollouts immer an). Nach dem Auftrag prüft OPNexus, ob die Firmware wieder bereit ist, der
  erwartete Neustart stattfand und die Version lesbar ist, und schließt die Wartung dann selbst ab. Bisher blieb
  sie auf „übergeben“ und sperrte Host und Cluster, bis jemand sie von Hand abschloss.
- **Benachrichtigungen:** „Rollout angehalten“ und „Rollout nicht automatisch gestartet“ (ein Schalter, standardmäßig an).
- **Rechte:** Rollout starten/aktivieren/fortsetzen/freigeben/überspringen/abbrechen und Wartungsfenster ändern
  gelten als Ausrollen (Aufgabentrennung); **Anhalten** darf jeder mit Schreibrecht. Update Center: Hosts im
  laufenden Rollout sind für manuelle Firmware-Aktionen gesperrt. Ein OPNexus-Update während eines Rollouts ist
  sicher, wird aber angekündigt.
- **Getestet:** live auf den Test-Firewalls (OPNsense 26.7): automatischer Start im Fenster, Canary-Update
  26.7.1_1 → 26.7.7, absichtlich gestoppter Dienst → Gate rot → angehalten (zweiter Ring unberührt,
  Benachrichtigung), Fortsetzen, Pilot-Update 26.7.6 → 26.7.7 auf dem damaligen MASTER mit CARP-Übergabe und einem
  Neustart des OPNexus-Backends mitten im Auftrag (genau ein Update-Auftrag), Freigabe, Host ohne Update
  übersprungen, Anhalten/Fortsetzen/Abbrechen von Hand, Termin auslassen. **Nur gemockt:** Major-Upgrade als
  Rollout, ein Cluster in *einer* Welle, Cluster-Prüfung vor dem zweiten Knoten, abweichende Zielversion,
  Zeitüberschreitungen, Rechteverlust, Fenster schließt mitten im Lauf; die Sommerzeit-Regeln sind Rechentests.
2026-10-21 11:59:41 +02:00
Julian Boldt a3274f64ae Release v1.24.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Alarme für Gateways, Ressourcen und Dienste:** Neue Ereignisse in Einstellungen › Benachrichtigungen.
  **Gateway gestört** (standardmäßig **an**) meldet ein Gateway, das OPNsense als „down“ führt oder dessen Paketverlust (ab 20 %) bzw.
  Latenz (ab 500 ms) über der Schwelle liegt; bewusst abgeschaltete und nicht überwachte Gateways bleiben still. **Ressourcen-Schwelle**
  (standardmäßig aus) prüft Festplatte (ab 90 %), Arbeitsspeicher (ab 95 %), Swap (ab 50 %) und CPU-Last (ab 2 pro Thread), **Dienst ausgefallen**
  (standardmäßig aus) eine einstellbare Liste (Unbound, Dnsmasq, DHCP, NTP, OpenVPN, WireGuard, IPsec …) und meldet nur Dienste, die zuvor
  liefen. Alle drei stützen sich auf die Minutenmessung und lösen erst aus, wenn die letzten 3 (Ressourcen: 5) Messungen übereinstimmen, und
  enden erst, wenn alle wieder in Ordnung sind: Einzelspitzen und flackernde Verbindungen schicken nichts. Der Gateway-Status (Verlust, Latenz,
  Abweichung) steht außerdem auf der Host-Seite, als Kachel auf dem Dashboard und als Prometheus-Metriken (`opnexus_host_gateway_*`,
  `opnexus_host_swap_used_percent`, `opnexus_host_service_running`). Die Messung speichert dafür zusätzlich Swap, CPU-Threads, Gateways
  und Dienste in der vorhandenen Messreihe (keine neue Tabelle).
- **Weitere Benachrichtigungskanäle:** ntfy, Gotify, Telegram, Microsoft Teams (Workflows-/Incoming-Webhook) und Matrix, beliebig viele
  je Typ, mit „Testen“-Knopf pro Kanal. Zugangsdaten (Tokens, die Teams-Adresse) liegen wie das SMTP-Passwort in der Geheimnis-Ablage, nie
  in der Datenbank oder in Antworten, und tauchen nicht in Fehlermeldungen oder Logs auf. Webhook und neue Kanäle prüfen die Zieladresse:
  nur http(s), keine Zugangsdaten in der Adresse, keine lokalen/Link-Local-/Metadaten-Adressen; Weiterleitungen werden nicht verfolgt.
  Betrifft die bestehende Webhook-Adresse: `localhost`, Loopback, 169.254.x.x und Zugangsdaten in der Adresse (`https://user:pass@…`)
  werden jetzt abgelehnt. Eine solche gespeicherte Adresse wird nicht mehr beliefert (nur ein Log-Eintrag), und die
  Benachrichtigungs-Einstellungen lassen sich erst wieder speichern, wenn sie korrigiert oder geleert ist (die Meldung nennt den Grund).
  Neue Einstellungen kommen mit Migration 0020.

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

- **Images für ARM:** Alle Release-Images (backend, frontend, proxy, updater) gibt es jetzt für `linux/amd64` und `linux/arm64`
  (Raspberry Pi 4/5, ARM-Server, Apple-Silicon-Docker).
2026-10-21 01:57:30 +02:00
Julian Boldt 9ba6357193 Release v1.23.11
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Updates per Klick prüfen jetzt ebenfalls die Signatur:** Der Updater-Container enthält `cosign` (feste Version, Prüfsumme beim Bau
  geprüft) und verifiziert die neuen Images vor dem Wechsel, wenn `cosign.pub` im Projektverzeichnis liegt. Das Image wird dadurch größer
  (rund 266 statt 123 MB).
2026-10-20 15:46:34 +02:00
Julian Boldt 23d3a00ae1 Release v1.23.10
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
  Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
  installiert ist. Anleitung: Handbuch, „Images prüfen“.
2026-10-20 15:38:35 +02:00
Julian Boldt 4379a17b15 Release v1.23.9
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
  statt „baut aus dem Quellcode“.
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
2026-10-20 15:16:51 +02:00
Julian Boldt 983e05a816 Release v1.23.8
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
  die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
  `docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
2026-10-20 14:52:10 +02:00
Julian Boldt dd6a64d953 Release v1.23.7
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
2026-10-20 14:43:39 +02:00
Julian Boldt 6964ec6537 Release v1.23.6
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
  und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
  `UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
2026-10-20 14:38:19 +02:00
Julian Boldt 5e5d51b57f Release v1.23.5
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
  und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
  bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
  darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
2026-10-20 14:22:12 +02:00
Julian Boldt 1aba618ad5 Release v1.23.4
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
  im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
  anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
  Prüfung weicht ab“.
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
  einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
2026-10-20 13:56:25 +02:00
Julian Boldt ba954c05bf Release v1.23.3
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
  Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
  Garage, MinIO, RustFS und Versity S3 Gateway.
2026-10-20 12:56:39 +02:00
Julian Boldt 63d014b14c Release v1.23.2
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
  lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
  `0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
2026-10-20 09:17:31 +02:00
Julian Boldt 4838dad588 Release v1.23.1
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
  - Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
  - Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
  - SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
  - Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
  - Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
  - Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
  - Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
2026-10-20 02:24:15 +02:00
Julian Boldt c4c8c4ff3a Release v1.23.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
  Befunden (je Firewall und Prüfung, mit „behoben“-Meldung; standardmäßig aus; Migration `0017`); druckbarer **Sicherheitsbericht**
  (Knopf „Bericht“, Drucken/PDF über den Browser).
2026-10-20 02:02:04 +02:00
Julian Boldt 295555cfd6 Release v1.22.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
  (Host-Seite › Standort-Variablen, listet die verwendeten Platzhalter automatisch). Je Host aufgelöst in Vorschau, Ausrollen, Zählern und
  Audit; fehlt ein Wert, wird nichts gesendet (Vorschau „Variable fehlt“, Ausrollen wird vor jedem Schreiben abgelehnt, zusätzlich eine
  Sperre im API-Client). Neue Tabelle `host_variables` (Migration `0016`).
2026-10-20 01:42:30 +02:00
Julian Boldt f5dbe8a41a Release v1.21.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Management-Tunnel** (System › Management-Tunnel): Firewalls hinter NAT/CGNAT verwalten. Ein optionaler WireGuard-Hub auf dem
  OPNexus-Server (Compose-Profil `overlay`, Container `wg-hub` aus dem Backend-Image mit `NET_ADMIN`) nimmt die Firewalls entgegen, die sich
  nach außen einwählen; OPNexus erreicht deren API über einen kleinen CONNECT-Proxy des Hubs, der nur ins Tunnel-Netz verbindet. Je Firewall
  eine Tunnel-Adresse und ein eigener Pre-Shared Key, Anleitung mit kopierbaren Werten, Live-Status (Handshake, Datenmenge). Der Hub nimmt
  keine neuen Verbindungen aus dem Tunnel an. Ende-zu-Ende gegen eine echte OPNsense 26.7 geprüft (Handshake, API, Zertifikat festsetzen).
  Neue Tabellen `overlay_settings`, `overlay_peers` (Migration `0015`); das Backend-Image enthält jetzt `wireguard-tools`, `iproute2`, `iptables`.
2026-10-20 01:21:45 +02:00
Julian Boldt 552c88e425 Release v1.20.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Single Sign-on (OIDC)**: Anmeldung über Authentik, Keycloak, Entra ID, Google u. a. (Authorization Code mit PKCE). Die Rolle
  kommt bei jeder Anmeldung aus den Gruppen des Anbieters, ohne zugeordnete Gruppe kommt niemand hinein; SSO-Konten werden nie mit
  lokalen Konten verknüpft; lokale Anmeldung bleibt als Notzugang. ID-Token werden streng geprüft (Signatur nur asymmetrisch, Aussteller,
  Zielgruppe, Ablauf, Nonce, einmaliger State), gegen einen nachgebauten Anbieter getestet (inkl. Fälschungsversuche). Neue Tabellen
  `oidc_settings`, `oidc_login_states`, Spalten `admin_users.auth_source/oidc_subject` (Migration `0013`). Neue Abhängigkeit: PyJWT.
- **Behoben:** `opnexus backup --full` und `opnexus restore` (Vollsicherung, in 1.19.0 beschrieben) fehlten im 1.19.0-Bundle und -Updater-Image,
  weil die Änderung am Skript nicht mit eingecheckt war; sie sind erst ab dieser Version enthalten.
- **Aufgabentrennung**: Schalter, mit dem nur Admins ausrollen dürfen, Operatoren aber vorbereiten (Migration `0014`).
2026-10-20 00:50:45 +02:00
Julian Boldt b2c69f51a0 Release v1.19.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende
(sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“.

- **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine
  Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit.
  Tokens eines Operators gehen bis zum Bereich „Ausrollen“.
- **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens
  einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`).
- **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM,
  Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen
  zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte
  `notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen
  AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus.
- **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer
  verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen).
- **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und
  Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle
  `rule_hits` (Migration `0012`).
- Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
2026-10-20 00:26:57 +02:00
Julian Boldt 218a1f14ac Release v1.18.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.

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

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

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

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

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

- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Dazu Gateway-Gruppen,
  DNS-Weiterleitungen (Unbound) und DHCP-Bereiche (dnsmasq).
- **Neue Gruppe „Host-System“:** Tunables (immer mit Bestätigung, Vorgaben nur per
  „Überschreiben“), Cron, Syslog-Ziele und ZFS-Snapshots (anlegen und löschen; der
  aktive Snapshot ist geschützt, das Aktivieren bleibt OPNsense selbst überlassen).
- **Netzwerk › Abgleich:** zeigt Unterschiede in der Netzwerk-Konfiguration zwischen den
  Knoten eines Clusters. Neuer Alarm „Netzwerk-Abweichung im Cluster“ (Einstellungen ›
  Benachrichtigungen, Migration 0004).
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts lässt es sich mit
  einem Klick auch auf dem anderen Cluster-Knoten anlegen (eigene Vorschau, eigenes Backup).
- **Kopfzeile:** Mit acht Gruppen zeigt die Navigation die Namen ab 1150 px, darunter
  nur Icons, damit Suche und Abmelden in einer Zeile bleiben.
2026-10-09 22:08:39 +02:00
Julian Boldt 20fd575420 Release v1.12.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
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.
2026-10-09 21:23:04 +02:00
Julian Boldt 1fc2d2c87c Release v1.11.1
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:

- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
  endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
  `/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
  Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
  gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
  es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
  `cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
  Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
  `/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
  Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
  (VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
  kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
  er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
  unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
  `SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
  Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
  („instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
  Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
  Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
  („Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
  einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
  und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
  Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
  sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
  Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
  „Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
  zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
  noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
  der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
  abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
  Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
  ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
  die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
  Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
  auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
  Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
  Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
  Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
  WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
  allen Seiten, hell und dunkel, nichts mehr.

- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
  blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
  bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
  Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
  jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
  (Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
  sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
  Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
  das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
2026-10-09 18:35:53 +02:00
371 changed files with 52442 additions and 3977 deletions

No files matched your search

+44 -6
View File
@@ -36,7 +36,7 @@ PROXY_BIND_ADDRESS=0.0.0.0
# Host interface the backend/frontend ports are published on. Defaults
# to 127.0.0.1 (this host only) if unset. Set to 0.0.0.0 for access from
# other machines without a reverse proxy (see README's "Produktivbetrieb"
# other machines without a reverse proxy (see the handbook, "Zugriff aus dem Netz und TLS"
# for the safer TLS-proxy alternative), or to one specific interface IP.
BIND_ADDRESS=
# Host-Ports von Backend und Frontend (nur nötig, wenn 8100/3100 schon belegt
@@ -44,15 +44,15 @@ BIND_ADDRESS=
# BACKEND_PORT=8100
# FRONTEND_PORT=3100
# Set to true once this is running behind TLS (see README's
# "Produktivbetrieb" section) — otherwise the session cookie is sent
# Set to true once this is running behind TLS (see the handbook,
# "Zugriff aus dem Netz und TLS") — otherwise the session cookie is sent
# unencrypted and browsers won't even attach a Secure cookie without
# HTTPS in the first place.
COOKIE_SECURE=false
# Set to true ONLY when running behind a reverse proxy you trust to
# always set/overwrite X-Forwarded-For itself (every example in README's
# "Produktivbetrieb" does). Used to attribute failed logins to the real
# always set/overwrite X-Forwarded-For itself (every example in the handbook's
# "Zugriff aus dem Netz und TLS" does). Used to attribute failed logins to the real
# client IP for the login lockout instead of the proxy's own IP. Leave
# false (default) if the app is reachable directly with no proxy in
# front — otherwise any client could spoof this header to dodge the
@@ -71,10 +71,20 @@ TRUST_PROXY_HEADERS=false
# Release-Feed (GitHub/Forgejo "releases/latest"). Leer = aus: dann macht
# OPNexus keine ausgehende Anfrage. Gesendet wird nur ein GET mit
# User-Agent "OPNexus/<Version>". Admins sehen bei einer neueren Version einen
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update`.
# Hinweis; aktualisiert wird nie automatisch, sondern mit `opnexus update` oder
# (optional, siehe unten) per Klick im Webinterface.
# Einfacher: im Webinterface unter Einstellungen › OPNexus aktualisieren einschalten.
# Ein Wert hier hat Vorrang und sperrt den Schalter dort.
# UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json
# UPDATE_CHECK_INTERVAL_HOURS=12
# ── Updates aus dem Webinterface (optional, braucht Release-Images) ───
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
# AUS. Braucht außerdem die Versionsprüfung (Webinterface oder UPDATE_CHECK_URL, oben). Siehe Handbuch, "Updates per Klick".
# COMPOSE_PROFILES=updater
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
# Firewall-API-Zugangsdaten gehen per HTTP-Basic-Auth raus, deshalb sind
# http://-Adressen für Firewalls abgelehnt (beim Anlegen UND bei jeder
@@ -85,3 +95,31 @@ TRUST_PROXY_HEADERS=false
# SMTP_CA_FILE=/data/smtp-ca.pem
# SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt.
# ALLOW_INSECURE_SMTP_AUTH=false
# Management-Tunnel (WireGuard-Hub für Firewalls hinter NAT), optional.
# Zum Aktivieren (Handbuch „Firewalls hinter NAT“): COMPOSE_PROFILES=overlay
# und den UDP-Port aus dem Internet erreichbar machen.
#OVERLAY_PORT=51820
# ── GUI-Fernzugriff auf verwaltete Firewalls (optional, Standard AUS) ──
# Öffnet die Weboberfläche einer Firewall über OPNexus, auch hinter NAT
# (Management-Tunnel), jeweils in einem eigenen Browser-Ursprung (Slot-Port).
# Dreifach aus, bis alles hier gesetzt ist UND ein Admin den Schalter unter
# Einstellungen einschaltet. Voraussetzungen: HTTPS über den Proxy und
# COOKIE_SECURE=true. Siehe Handbuch, „Firewall-Oberfläche über OPNexus“.
# 1. Profil aktivieren (mit anderen Profilen kommagetrennt, z. B. overlay,gui):
# COMPOSE_PROFILES=gui
# 2. Slot-Ursprünge: dieselbe Adresse wie in OPNEXUS_SITES, je Slot ein Port
# aus GUI_PORTS. Darf NICHT in FRONTEND_ORIGIN stehen (Start wird sonst verweigert).
# OPNEXUS_GUI_SITES=https://10.0.0.5:9441, https://10.0.0.5:9442, https://10.0.0.5:9443, https://10.0.0.5:9444
# 3. Gemeinsames Geheimnis Backend <-> Gateway, mind. 32 Zeichen, z. B.
# `openssl rand -hex 32`:
# GUI_GATEWAY_TOKEN=
# 4. Wo die Slot-Ports lauschen. Standard nur lokal (127.0.0.1); für den
# Zugriff aus dem Netz die Adresse dieses Hosts eintragen (wie bei
# PROXY_BIND_ADDRESS). GUI_PORTS ist ein Bereich, Host- und Container-Port
# sind gleich. GUI_PORTS zum Einschalten SETZEN: ohne den Eintrag nimmt
# Docker zufällige freie Ports (damit ein Update nie an belegten Ports wie
# 9443 scheitert), und die Slot-Adressen oben wären nicht erreichbar.
# GUI_BIND_ADDRESS=127.0.0.1
# GUI_PORTS=9441-9444
+2
View File
@@ -59,3 +59,5 @@ jobs:
- uses: actions/checkout@v4
- run: python3 scripts/check_i18n.py
- run: bash -n scripts/opnexus && bash -n scripts/check_version.sh && bash -n scripts/make_release_bundle.sh
# Updater-Container (Zustandsübergänge, Ablehnungen, Auth) ohne Docker-Daemon.
- run: python3 -m unittest discover -s updater -p 'test_*.py'
+2 -2
View File
@@ -12,7 +12,7 @@
# Berechtigung "write:package" eintragen)
# Wer auf mehrere Registries veröffentlichen will, pusht den Tag einfach zu
# mehreren Remotes — jeder Remote baut mit seiner eigenen Konfiguration.
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy}:<version>
# Ergebnis: <REGISTRY>/<IMAGE_NAMESPACE>/opnexus-{backend,frontend,proxy,updater}:<version>
# (flache Namen, weil verschachtelte Pfade je Registry unterschiedlich gehen).
#
# Der Publish-Job braucht einen Runner mit Docker-Zugriff, der QEMU-
@@ -42,7 +42,7 @@ jobs:
strategy:
fail-fast: true
matrix:
service: [backend, frontend, proxy]
service: [backend, frontend, proxy, updater]
steps:
- uses: actions/checkout@v4
+599
View File
@@ -31,6 +31,605 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.30.0] "Owl" — 2026-10-22
- **Regel-Analyse: Bereinigung (nur Ansicht):** Auf **Regelwerk › Regel-Analyse** gibt es den Tab **Bereinigung** (`?tab=cleanup`). Er listet je Firewall die
Regeln, die einen Blick wert sind: **ungenutzt** (OPNexus hat in 7/30/90/180 Tagen keinen Treffer gesehen), **verdeckt** (eine frühere Quick-Regel
trifft schon alles, was sie trifft), **doppelt** und **lange deaktiviert**. Jeder Befund trägt eine Sicherheit (**sicher / vermutlich / unbekannt**, Tooltip mit
dem Grund): „sicher“ gibt es nur mit vollständigen, frischen Belegen, bei einer Regel mit Zeitplan, ohne geladenen pf-Regelsatz oder bei Aliasen bleibt es
höchstens „vermutlich“. Die Tabelle zeigt auch den letzten Treffer und ob die Regel entfernbar wäre (nicht: automatische Regeln, Regeln im Altformat, von OPNexus
verwaltete Regeln – die entfernt man über die Regelseite). Es wird nur gelesen; **Entfernen folgt in einer späteren Version**. Die Sicherheitslage verlinkt ihre
Befunde „ungenutzt/verdeckt/doppelt“ direkt dorthin; per API `GET /api/hosts/{id}/rule-analysis/cleanup?unused_days=30`, per MCP `rule_cleanup_candidates`.
- **Änderungsverlauf je Regel:** OPNexus liest alle 15 Minuten die Regeln jeder Firewall und merkt sich, was sich geändert hat – auch bei Regeln, die nicht von OPNexus
stammen: angelegt, geändert (mit Vorher/Nachher), aktiviert, deaktiviert, entfernt, jeweils mit der Herkunft „über OPNexus“ (passender Audit-Eintrag) oder „extern“.
Auf der Live-Regel-Ansicht (Regelwerk › Live über alle Hosts) öffnet die Zeilenaktion **Verlauf** die Ereignisse zusammen mit den gespeicherten Fassungen und dem Audit der
verwalteten Regel. Die erste Beobachtung speichert nur den Ausgangszustand; der Verlauf wird 365 Tage aufbewahrt (`GET /api/hosts/{id}/live-rules/{uuid}/history`).
Eine Umsortierung (andere Reihenfolge oder Gruppe) gilt als „verschoben“, nicht als Änderung. Eine leere oder unvollständige Antwort der Firewall (ohne
automatische Regeln) wird verworfen; verschwinden mehr als die Hälfte der Regeln auf einmal, zählt erst die zweite gleiche Beobachtung. Die Herkunft „über OPNexus“
erklärt nur Audit-Einträge seit der vorherigen Beobachtung, und jeder Eintrag nur eine Änderung.
Schemaänderungen 0027 und 0028 (Tabellen und ein Feld für den Beobachtungszeitraum) laufen beim Start.
- **Regelnutzung genauer:** Die Zähler kommen jetzt aus `ruleStats` (von der Firewall schon je Regel summiert; ältere Versionen weiterhin über den pf-Regelsatz) und werden alle
15 Minuten statt alle 6 Stunden gelesen. Regeln im Altformat (noch nicht migriert) werden ebenfalls erfasst. Das Beobachtungsfenster für „ungenutzt“ beginnt neu, wenn eine Regel eingeschaltet oder geändert wird, und nach einem pf-Neuladen mit langer Lücke bei den Zählern.
- **Verdeckt/doppelt in der Sicherheitslage genauer:** Der Vergleich kennt jetzt die echte Reihenfolge (Floating vor Interface-Gruppen vor Interface-Regeln), Netze und
Portbereiche als Mengen (z. B. 10.1.0.0/16 liegt in 10.0.0.0/8), `inet46` und `tcp/udp`, und übergeht Regeln mit Zeitplan, Markierung oder Limits. Es findet dadurch
mehr verdeckte Regeln als bisher, nie weniger belegte.
## [1.29.0] "Caracara" — 2026-10-22
- **Fehlende API-Rechte werden sichtbar:** Darf der API-Schlüssel einer Firewall den Gateway- oder Dienststatus nicht lesen (OPNsense antwortet
mit 403), fiel das bisher niemandem auf, und der standardmäßig aktive Gateway-Alarm konnte nie auslösen. Jetzt zeigen die Host-Seite
(Abschnitt „Gateways“) und die Dashboard-Karte „Fleet-/CARP-Gesundheit“ ein Warn-Badge „API-Rechte fehlen: Gateways, Dienste“; der Tooltip nennt die
OPNsense-Berechtigung, die beim Benutzer oder bei der Gruppe fehlt (System: Gateways, Status: Services, Lobby: Dashboard). Zusätzlich gibt es
den neuen Alarm **API-Rechte fehlen** (standardmäßig an, abschaltbar unter Einstellungen › Benachrichtigungen, gleiche Entprellung wie die
anderen Messungs-Alarme) und die Prometheus-Metrik `opnexus_host_api_rights_missing{host,read}`. Nur eine ausdrückliche Ablehnung zählt;
Zeitüberschreitungen oder Serverfehler bleiben „unbekannt“.
- **Gateway-Alarm pro Gateway abschaltbar:** In der Gateway-Liste der Host-Seite schaltet eine Glocke den Alarm für ein einzelnes Gateway aus
(z. B. ruhendes Zweit-WAN oder tote IPv6-Gateways); ein „Alarm aus“-Badge zeigt es an. Ein offener Alarm dieses Gateways löst sich dabei
auf. Nur Administratoren, die Änderung steht im Audit-Log. Das Dashboard zeigt bei mehr als acht Gateways „+ N weitere“.
- **Firewall-Oberfläche (GUI-Zugriff) härter:** Ein frei gewordener Platz wird für eine **andere** Firewall erst 5 Minuten nach dem Ende der letzten
Sitzung vergeben (Schutz, falls dort noch ein Tab offen ist, der die Cookies des Platzes teilt); dieselbe Firewall bekommt ihn sofort wieder.
Sind alle Plätze belegt oder noch in der Abkühlzeit, nennt die Meldung den Grund. Ändern sich Adresse, festgesetztes Zertifikat oder Tunnelweg
einer Firewall, während ihre Oberfläche offen ist, endet die Sitzung spätestens nach 20 Sekunden. Das Limit von 64 gleichzeitigen Sitzungen
verdrängt keine laufende Sitzung mehr: eine neue wird abgelehnt (mit Meldung), bis eine endet. **Hinweis:** Das GUI-Gateway-Image muss zur
Backend-Version passen (wie bisher beim Update beider Container).
- **Anmeldung (Cookies) härter:** Das alte Sitzungs-Cookie `opnexus-session` wird bei HTTPS **nicht mehr akzeptiert**. **Alle, die sich seit der
Umstellung auf 1.27 nicht neu angemeldet haben, müssen sich einmal neu anmelden**; Skripte mit rohem Sitzungs-Cookie brauchen den Namen
`__Host-opnexus-session` (API-Tokens sind nicht betroffen, ohne HTTPS bleibt `opnexus-session` der normale Name). Beim Abmelden wird ein altes
Cookie weiterhin gelöscht. Das Cookie, das die SSO-Anmeldung an den Browser bindet, heißt bei HTTPS jetzt `__Host-opnexus-oidc-state`; der
Rückweg vom Anbieter wird nur noch als Seitenaufruf (Navigation) von einer fremden Seite zugelassen, und ohne dieses Cookie scheitert er immer.
- **Webhook, Benachrichtigungskanäle und SSO-Anbieter: Schutz vor DNS-Rebinding:** Bisher prüfte OPNexus die Adresse, auf die ein Name zeigt, und
der HTTP-Client löste den Namen danach noch einmal auf; ein Server unter Fremdkontrolle konnte beim zweiten Mal eine interne Adresse liefern. Jetzt
verbindet OPNexus genau zu der geprüften Adresse (Host-Name, TLS-Zertifikatsprüfung und Anfrage bleiben beim Namen); zeigt eine Antwort auch nur
teilweise auf Loopback, Link-Local oder einen Cloud-Metadaten-Dienst (auch AWS-IPv6 und Alibaba), kommt keine Verbindung zustande und die Zustellung meldet „Zieladresse nicht
erlaubt“. Private Netze bleiben erlaubt (eigener ntfy, Gotify, Matrix). Proxy-Umgebungsvariablen werden für diese Aufrufe nicht mehr beachtet.
- **Benachrichtigungen parallel zugestellt:** Webhook, E-Mail und alle Kanäle werden gleichzeitig bedient, jeder mit eigener Zeitgrenze (20 Sekunden),
und ein Prüfdurchlauf sendet insgesamt höchstens 90 Sekunden. Ein hängender Kanal verzögert weder die anderen noch die nächsten Prüfungen; im Log
steht dann „timed out“ (ohne Adresse oder Token). Wie bisher wird einmal je Ereignis zugestellt, ohne Wiederholung, **außer** der Durchlauf war so voll, dass
für ein Ereignis noch gar kein Versuch stattfand: es wird im nächsten Durchlauf zugestellt. Die Zustellung beginnt jetzt nach allen Prüfungen
eines Durchlaufs statt mitten darin.
- **Rollouts: Fingerabdruck, verantwortliche Person, überlappende Termine:** Der Fingerabdruck eines aktivierten Plans umfasst jetzt auch die
ausgelassenen und verschobenen Termine seines Fensters: **wer einen Termin auslässt oder verschiebt, muss einen automatischen Plan danach neu
aktivieren** (ohne solche Ausnahmen bleiben bestehende Aktivierungen gültig; **Pläne, deren Fenster schon Ausnahmen haben, nach dem Update
einmal neu aktivieren**, sonst meldet der Plan „Plan geändert“ und startet nicht automatisch). Die verantwortliche Person (Aktivierende, Startende, Freigebende)
wird zusätzlich als Konto-ID gespeichert (Migration 0026, bestehende Einträge werden über den Namen zugeordnet): ein gelöschtes und unter gleichem
Namen neu angelegtes Konto gilt nicht mehr als dieselbe Person, der Lauf pausiert bzw. der automatische Start unterbleibt mit „Person darf nicht mehr
ausrollen“. Angezeigt wird weiter der Benutzername. Überlappen sich zwei Termine eines Wartungsfensters, zählt der zuletzt begonnene (bisher der
früheste), sodass ein älterer, längst abgeschnittener Termin keinen neuen Start mehr blockiert.
- **Kleinigkeiten:** Ressourcen-Alarme zeigen bei fehlendem Messwert keine Zahl mehr („None %“ entfällt). Der Dialog „Von Firewall übernehmen“ zeigt ohne aktive
Firewall einen Hinweis statt dauerhaft „Lädt…“. Fehlermeldungen beim Übernehmen und Verknüpfen nennen die Gründe in Klartext (Deutsch/Englisch) statt
roher Codes wie `name_exists`; dasselbe gilt für „GUI-Zugriff nicht eingerichtet“ und blockierte Netzwerkänderungen. Der Test-Knopf der
Benachrichtigungskanäle ist für Nicht-Admins ausgeblendet (der Server verweigert den Test ohnehin). Der Tooltip der aktiven Plätze nennt keine
Umgebungsvariable mehr. Eine Gateway-Sitzung, die der Gateway-Container nicht mehr bestätigt (Absturz), zählt für die Abkühlzeit der Plätze als
beendet.
- **Grafana-Dashboard erweitert:** neue Felder für Gateways (erreichbar, Paketverlust, Latenz), Swap, gestoppte Dienste, fehlende API-Rechte und
Rollouts nach Status; dazu die Metrik `opnexus_rollout_runs{status}`.
## [1.28.0] "Sparrowhawk" — 2026-10-21
- **Übernehmen: mit vorhandenem zentralem Objekt verknüpfen:** Dieselbe von Hand angelegte Regel, derselbe Alias oder dieselbe NAT-Regel auf mehreren
Firewalls (z. B. beide HA-Knoten) wird nicht mehr als zweites Objekt übernommen. Im Dialog „Von Firewall übernehmen“ erscheint bei exakter
Übereinstimmung (nach Einsetzen der Host-Variablen, bei Aliasen inkl. Verweisen) die Aktion **Verknüpfen**: die Firewall wird weiteres Ziel des
vorhandenen Objekts, die Vorschau zeigt „keine Änderung“, und der nächste Rollout aktualisiert alle Firewalls an einem Objekt. Auf der Firewall wird
dabei nichts geändert. Unterscheidet sich ein gleichnamiges Objekt, zeigt der Dialog die abweichenden Felder; verknüpft wird dann nie. „Alle
übernehmbaren“ fragt vorher nach („3 verknüpfen, 2 neu übernehmen, 1 nicht übernehmbar“). Aliase, die bisher wegen „Name existiert“ abgelehnt wurden,
lassen sich so übernehmen.
Wartet das vorhandene Objekt gerade auf eine Bestätigung, wird nicht verknüpft; „Alle übernehmbaren“ lässt den Eintrag dann aus. Verknüpfen
macht ein gerade geöffnetes Bearbeiten-Formular des Objekts ungültig, damit die neue Firewall nicht versehentlich wieder aus den Zielen fällt.
- **Tooltips überarbeitet:** Der deaktivierte Button „Oberfläche öffnen“ nennt jetzt den Grund im Klartext samt nächstem Schritt (statt nur einer
Variablen wie `GUI_GATEWAY_TOKEN`); dasselbe gilt für ausgeschaltete Funktion, deaktivierte Firewall, Nicht-HTTPS-Adresse und ausgeschaltete
Zertifikatsprüfung. Texte durchgehend in Du-Form, kürzere VPN-Einleitungen, verständlichere Hinweise bei gesperrten Buttons (Zertifikate, Benutzer,
Dienste). Icon-Buttons (Bearbeiten, Löschen, Schließen, Reihenfolge) zeigen beim Darüberfahren ihren Namen.
## [1.27.2] "Buzzard" — 2026-10-21
- **`opnexus update` prüft den Proxy vor dem Wechsel:** Nach dem Ziehen der Images validiert das Werkzeug die Caddy-Konfiguration des neuen
Proxy-Images mit den Umgebungsvariablen *dieser* Installation. Ist sie ungültig, bricht das Update ab, bevor etwas gewechselt wurde.
Der Fall aus 1.27.0 (Compose-Datei mit leerem `OPNEXUS_GUI_SITES`) wird dabei automatisch repariert: Das Werkzeug trägt den Platzhalter
`OPNEXUS_GUI_SITES=https://localhost:9441` in die `.env` ein und sagt es. Installationen, die noch die Compose-Datei aus 1.27.0 haben,
müssen also nur einmal `opnexus update` ausführen.
- **`opnexus update` erkennt einen abstürzenden Proxy:** Ein Dienst ohne Healthcheck (der Proxy) gilt jetzt erst nach 10 Sekunden
durchgehender Laufzeit als gesund. Bisher meldete das Update „Fertig“, obwohl der Proxy in einer Neustart-Schleife hing, und das
automatische Zurückrollen griff nicht.
## [1.27.1] "Buzzard" — 2026-10-21
- **Fix (1.27.1): Proxy startete mit der neuen Compose-Datei nicht.** Mit der `docker-compose.yml` aus 1.27.0 gab Compose die Variable
`OPNEXUS_GUI_SITES` als leeren Text an den Proxy weiter; Caddy nutzt seinen Standardwert aber nur bei *nicht gesetzter* Variable und
brach mit „server block without any key“ ab, OPNexus war dann nicht mehr erreichbar. Die Compose-Datei setzt jetzt einen gültigen
Platzhalter als Standard. Betroffen waren nur Installationen, die die neue Compose-Datei aus 1.27.0 übernommen hatten (ohne
`OPNEXUS_GUI_SITES` in der `.env`); wer nur die Images aktualisiert hatte, war nicht betroffen. Sofort-Abhilfe ohne Update:
`OPNEXUS_GUI_SITES=https://localhost:9441` in die `.env` eintragen und `docker compose up -d proxy` ausführen.
## [1.27.0] "Buzzard" — 2026-10-21
Vorgesehen als MINOR-Release **1.27.0** „Firewall-Oberfläche über OPNexus“.
- **Firewall-Oberfläche über OPNexus:** Auf der Host-Seite öffnet **Oberfläche öffnen** die Weboberfläche der Firewall
in einem neuen Tab, auch bei Firewalls hinter NAT im Management-Tunnel. Nur für Admins; jede Firewall läuft in einer
eigenen Browser-Origin (Platz = Port), Cookies bleiben auf dem Server, das Ticket gilt einmalig 30 Sekunden,
Inaktivitäts- und Gesamtlimit sind einstellbar, Ticket, Start und Ende stehen im Audit (Art „GUI-Zugriff“).
Standardmäßig **aus**: braucht das Compose-Profil `gui`, `OPNEXUS_GUI_SITES`, `GUI_PORTS`, `GUI_GATEWAY_TOKEN`,
`COOKIE_SECURE=true` und den Schalter unter Einstellungen (Handbuch, „Firewall-Oberfläche über OPNexus“).
Live getestet auf OPNsense 26.7.
- **Einschränkungen:** kein WebSocket, keine automatische Anmeldung, höchstens 4 Plätze gleichzeitig. OPNexus-Adresse
vorher in die Whitelist des Anmeldeschutzes (sshguard) der Firewall eintragen, sonst können Fehlanmeldungen die
API-Zugriffe von OPNexus aussperren. Nach einem Neustart des wg-hub braucht der Tunnel 2 bis 4 Minuten.
- **Hinweis für Betreiber mit eigenen Skripten:** Bei HTTPS heißt das Sitzungs-Cookie jetzt `__Host-opnexus-session`
(bestehende Sitzungen werden ohne Abmelden übernommen), und Cookie-Anfragen an `/api` mit `Sec-Fetch-Site: same-site/cross-site`
werden abgelehnt. Zugriffe mit API-Token sind nicht betroffen.
## [1.26.0] "Kite" — 2026-10-21
Geplant als MINOR-Release **1.26.0** (Regel-Analyse). Backend, MCP und Oberfläche.
- **Regel-Analyse „Welche Regel greift?“ (API):** `GET /api/hosts/{id}/rule-analysis/match` und
`GET /api/rule-analysis/match` (ganze Flotte, Auswahl oder Gerätegruppe) beantworten für Quelle, Ziel, Protokoll
und Port, ob eine Firewall das erste Paket einer neuen Verbindung durchlässt oder blockiert, welche Regel das
entscheidet und warum (Prüfprotokoll Regel für Regel). Grundlage ist der tatsächlich geladene pf-Regelsatz
inklusive automatischer Regeln, Floating- und Gruppenregeln, `quick`/letzter Treffer, Negation, verschachtelter
Aliase (pf-Tabellentest der Firewall), Port-Aliase und Adressfamilien. Jede Antwort trägt eine ehrliche
Konfidenz **sicher / vermutlich / unbekannt** mit Begründungscodes; was nicht belegbar ist (z. B. `tagged`,
nicht auflösbare Aliase, geratenes Interface, NAT in der Nähe), wird nie als „sicher“ gemeldet. Nur lesend,
auch mit `read`-Token nutzbar; es wird nichts gespeichert und nichts an der Firewall geändert.
- **MCP:** neues Werkzeug `rule_match` für KI-Assistenten (gleiche Abfrage, ohne Prüfprotokoll).
- **Seite „Regel-Analyse“ (Regelwerk):** Formular für Hosts oder Gerätegruppe, Quell-/Ziel-IP, Protokoll, Ports,
Eingangs-Interface („automatisch“ oder Liste) und optional die Ausgangsseite. Die Abfrage steht in der Adresszeile
(teilbar, wird beim Öffnen sofort ausgeführt). Je Firewall eine Karte mit Erlaubt/Blockiert/Abgewiesen, Sicherheit
des Urteils, entscheidender Regel und Hinweisen; ein „unbekannt“-Ergebnis erscheint bewusst nicht als Urteil.
Aufklappbares „Warum?“ zeigt jede geprüfte Regel mit ✓/✗/? je Feld, überstimmte Regeln durchgestrichen. Auch mit
Nur-Lesen-Rolle nutzbar.
## [1.25.0] "Goshawk" — 2026-10-21
Geplant als MINOR-Release **1.25.0** (Flotten-Rollout).
- **Rollouts in Wellen (System › Rollouts):** Firmware-Updates für die ganze Flotte in Ringen, z. B. erst
`ring-canary`, dann `ring-pilot`, dann `ring-prod` (Wellen aus Gerätegruppen). Jeder Host läuft über die
bestehende geplante Wartung (Backup vor dem Update, CARP-Übergabe für den MASTER, Audit), immer nur ein Host
gleichzeitig und nur ein Rollout zur selben Zeit; im Cluster zuerst der BACKUP, dann der MASTER. Nach jeder Welle
folgen eine Beobachtungszeit und eine **Gesundheitsprüfung** (erreichbar, Zielversion installiert, CARP je VHID,
Gateways und Dienste wie vorher, keine offenen Sperren, optional Sicherheits-Score). Rot hält den Rollout an
(nach einer Nachprüfung zwei Minuten später) und meldet sich. Anhalten, Fortsetzen (prüft zuerst alles bisher
Aktualisierte erneut), Welle freigeben, Host überspringen und Abbrechen auf der Lauf-Seite; die Seite zeigt
Wellen, Hosts (Version vorher → nachher, aktueller Schritt) und die Prüfmatrix.
- **Sicherheitsnetz:** Weicht die angebotene Version in einem späteren Ring von der des ersten Rings ab, hält der
Rollout an (OPNsense installiert immer die neueste). Ein Host ohne Update wird übersprungen, ein fehlgeschlagener
nie von selbst wiederholt. Angehalten wird auch, wenn die verantwortliche Person das Recht zum Ausrollen verliert,
eine fremde Wartung im Cluster länger als 15 Minuten offen ist, der MASTER ohne Übergabe dran wäre oder ein Lauf
älter als 14 Tage ist. Höchstens 10 Wellen und 100 Hosts je Plan.
- **Wiederkehrende Wartungsfenster:** wöchentlich oder monatlich (n-ter Wochentag, letzter, fester Tag) in einer
Zeitzone, mit Sommerzeit-Regel, Abschneidezeit („neue Hosts nur in den ersten N Minuten“) und einzelnen Terminen
zum Auslassen oder Verschieben. Ein Plan mit Fenster startet neue Hosts nur darin; mit **automatischem Start**
(nur stabile Updates) beginnt er zu jedem Termin von selbst, aber nur, solange Plan, Gruppen und Fenster seit
der Aktivierung unverändert sind. Jede Änderung deaktiviert ihn.
- **Geplante Wartung prüft sich selbst:** Neue Option „Ergebnis automatisch prüfen und abschließen“ (im Dialog
vorausgewählt, bei Rollouts immer an). Nach dem Auftrag prüft OPNexus, ob die Firmware wieder bereit ist, der
erwartete Neustart stattfand und die Version lesbar ist, und schließt die Wartung dann selbst ab. Bisher blieb
sie auf „übergeben“ und sperrte Host und Cluster, bis jemand sie von Hand abschloss.
- **Benachrichtigungen:** „Rollout angehalten“ und „Rollout nicht automatisch gestartet“ (ein Schalter, standardmäßig an).
- **Rechte:** Rollout starten/aktivieren/fortsetzen/freigeben/überspringen/abbrechen und Wartungsfenster ändern
gelten als Ausrollen (Aufgabentrennung); **Anhalten** darf jeder mit Schreibrecht. Update Center: Hosts im
laufenden Rollout sind für manuelle Firmware-Aktionen gesperrt. Ein OPNexus-Update während eines Rollouts ist
sicher, wird aber angekündigt.
- **Getestet:** live auf den Test-Firewalls (OPNsense 26.7): automatischer Start im Fenster, Canary-Update
26.7.1_1 → 26.7.7, absichtlich gestoppter Dienst → Gate rot → angehalten (zweiter Ring unberührt,
Benachrichtigung), Fortsetzen, Pilot-Update 26.7.6 → 26.7.7 auf dem damaligen MASTER mit CARP-Übergabe und einem
Neustart des OPNexus-Backends mitten im Auftrag (genau ein Update-Auftrag), Freigabe, Host ohne Update
übersprungen, Anhalten/Fortsetzen/Abbrechen von Hand, Termin auslassen. **Nur gemockt:** Major-Upgrade als
Rollout, ein Cluster in *einer* Welle, Cluster-Prüfung vor dem zweiten Knoten, abweichende Zielversion,
Zeitüberschreitungen, Rechteverlust, Fenster schließt mitten im Lauf; die Sommerzeit-Regeln sind Rechentests.
## [1.24.0] "Merlin" — 2026-10-21
- **Alarme für Gateways, Ressourcen und Dienste:** Neue Ereignisse in Einstellungen › Benachrichtigungen.
**Gateway gestört** (standardmäßig **an**) meldet ein Gateway, das OPNsense als „down“ führt oder dessen Paketverlust (ab 20 %) bzw.
Latenz (ab 500 ms) über der Schwelle liegt; bewusst abgeschaltete und nicht überwachte Gateways bleiben still. **Ressourcen-Schwelle**
(standardmäßig aus) prüft Festplatte (ab 90 %), Arbeitsspeicher (ab 95 %), Swap (ab 50 %) und CPU-Last (ab 2 pro Thread), **Dienst ausgefallen**
(standardmäßig aus) eine einstellbare Liste (Unbound, Dnsmasq, DHCP, NTP, OpenVPN, WireGuard, IPsec …) und meldet nur Dienste, die zuvor
liefen. Alle drei stützen sich auf die Minutenmessung und lösen erst aus, wenn die letzten 3 (Ressourcen: 5) Messungen übereinstimmen, und
enden erst, wenn alle wieder in Ordnung sind: Einzelspitzen und flackernde Verbindungen schicken nichts. Der Gateway-Status (Verlust, Latenz,
Abweichung) steht außerdem auf der Host-Seite, als Kachel auf dem Dashboard und als Prometheus-Metriken (`opnexus_host_gateway_*`,
`opnexus_host_swap_used_percent`, `opnexus_host_service_running`). Die Messung speichert dafür zusätzlich Swap, CPU-Threads, Gateways
und Dienste in der vorhandenen Messreihe (keine neue Tabelle).
- **Weitere Benachrichtigungskanäle:** ntfy, Gotify, Telegram, Microsoft Teams (Workflows-/Incoming-Webhook) und Matrix, beliebig viele
je Typ, mit „Testen“-Knopf pro Kanal. Zugangsdaten (Tokens, die Teams-Adresse) liegen wie das SMTP-Passwort in der Geheimnis-Ablage, nie
in der Datenbank oder in Antworten, und tauchen nicht in Fehlermeldungen oder Logs auf. Webhook und neue Kanäle prüfen die Zieladresse:
nur http(s), keine Zugangsdaten in der Adresse, keine lokalen/Link-Local-/Metadaten-Adressen; Weiterleitungen werden nicht verfolgt.
Betrifft die bestehende Webhook-Adresse: `localhost`, Loopback, 169.254.x.x und Zugangsdaten in der Adresse (`https://user:pass@…`)
werden jetzt abgelehnt. Eine solche gespeicherte Adresse wird nicht mehr beliefert (nur ein Log-Eintrag), und die
Benachrichtigungs-Einstellungen lassen sich erst wieder speichern, wenn sie korrigiert oder geleert ist (die Meldung nennt den Grund).
Neue Einstellungen kommen mit Migration 0020.
- **Bestand übernehmen für Aliase und NAT:** Auf den Seiten Aliase und NAT (Port Forward, 1:1, Outbound) gibt es jetzt **Von Firewall übernehmen**
wie bei den Regeln. Der Dialog zeigt, was von Hand in OPNsense angelegt wurde, einzeln zum Übernehmen oder mit „Alle übernehmbaren“ in einem Schritt;
verschachtelte Aliase werden in der richtigen Reihenfolge übernommen. Die Übernahme schreibt nichts auf die Firewall: Die Vorschau zeigt direkt
danach „keine Änderung“, der nächste Rollout aktualisiert dasselbe Objekt (kein Duplikat). Nicht übernommen werden OPNsense-eigene Aliase,
Typen, die OPNexus nicht kennt (z. B. URL, GeoIP), Aliase mit Verweis auf nicht übernommene Aliase sowie NAT-Regeln, die das Modell verfälschen würden
(z. B. „Kein Redirect“, verknüpfte Filterregel, mehrere Schnittstellen, „Kein NAT“); die Oberfläche nennt den Grund je Objekt.
Outbound-NAT lässt sich nur übernehmen, wenn der Modus der Firewall Hybrid oder Manuell ist. Einstellungen, die OPNexus nicht verwaltet (Protokollierung,
Pool-Optionen, Tags, Kategorien), bleiben unverändert und werden angezeigt.
- **Images für ARM:** Alle Release-Images (backend, frontend, proxy, updater) gibt es jetzt für `linux/amd64` und `linux/arm64`
(Raspberry Pi 4/5, ARM-Server, Apple-Silicon-Docker).
## [1.23.11] "Harrier" — 2026-10-20
- **Updates per Klick prüfen jetzt ebenfalls die Signatur:** Der Updater-Container enthält `cosign` (feste Version, Prüfsumme beim Bau
geprüft) und verifiziert die neuen Images vor dem Wechsel, wenn `cosign.pub` im Projektverzeichnis liegt. Das Image wird dadurch größer
(rund 266 statt 123 MB).
## [1.23.10] "Harrier" — 2026-10-20
- **Signierte Release-Images:** Die Images (backend, frontend, proxy, updater) werden mit cosign signiert, beginnend mit 1.23.9. Der öffentliche
Schlüssel liegt als `cosign.pub` im Repository und im Release-Bundle. `opnexus update` prüft die Signaturen vor dem Wechsel, wenn `cosign`
installiert ist. Anleitung: Handbuch, „Images prüfen“.
## [1.23.9] "Harrier" — 2026-10-20
- Update-Karte: Der Hinweis für Installationen ohne Release-Images nennt jetzt die tatsächliche Ursache („OPNEXUS_IMAGE_REPO ist nicht gesetzt“)
statt „baut aus dem Quellcode“.
- `cosign.pub`: öffentlicher Schlüssel für die Signaturen der Release-Images (das Release-Skript kann Images jetzt signieren).
## [1.23.8] "Harrier" — 2026-10-20
- **Einstellungen › OPNexus aktualisieren:** Auf einer Installation, die aus dem Quellcode gebaut wird (kein `OPNEXUS_IMAGE_REPO`), erscheint nicht mehr
die Anleitung zum Aktivieren des Updaters – Updates per Klick gibt es dort ohnehin nicht –, sondern nur der Hinweis auf `git pull` und
`docker compose up -d --build`. Das Backend erkennt den Modus jetzt selbst (neue Umgebungsvariable im Compose-File).
## [1.23.7] "Harrier" — 2026-10-20
- Hinweistext der Versionsprüfung verweist auf den Schalter „oben“ statt „unten“.
## [1.23.6] "Harrier" — 2026-10-20
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
`UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
## [1.23.5] "Harrier" — 2026-10-20
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt,
und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die
bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt.
- **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist
darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
## [1.23.4] "Harrier" — 2026-10-20
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
Prüfung weicht ab“.
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
## [1.23.3] "Harrier" — 2026-10-20
- **SSO:** Liefert der Anbieter `preferred_username`/`email` nicht im ID-Token (Authelia), fragt OPNexus sie beim Userinfo-Endpunkt ab; der neue
Benutzer heißt dann `alice` statt wie die kryptische Benutzerkennung des Anbieters. Geprüft gegen Keycloak 26 und Authelia 4.39; S3-Offsite gegen
Garage, MinIO, RustFS und Versity S3 Gateway.
## [1.23.2] "Harrier" — 2026-10-20
- **SSO-Fix (gefunden beim Test gegen ein echtes Keycloak):** Die Standard-Scopes enthalten nicht mehr `groups` – Keycloak und Entra ID
lehnten die Anmeldung damit mit `invalid_scope` ab. Bestehende Einstellungen mit dem alten Standardwert werden umgestellt (Migration
`0018`); Anbieter wie Okta tragen `groups` bei Bedarf selbst ein (Hinweis in den Einstellungen).
## [1.23.1] "Harrier" — 2026-10-20
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):**
- Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`.
- Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls).
- SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt.
- Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens.
- Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt.
- Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife.
- Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt.
- Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
## [1.23.0] "Harrier" — 2026-10-20
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
Befunden (je Firewall und Prüfung, mit „behoben“-Meldung; standardmäßig aus; Migration `0017`); druckbarer **Sicherheitsbericht**
(Knopf „Bericht“, Drucken/PDF über den Browser).
## [1.22.0] "Peregrine" — 2026-10-20
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
(Host-Seite › Standort-Variablen, listet die verwendeten Platzhalter automatisch). Je Host aufgelöst in Vorschau, Ausrollen, Zählern und
Audit; fehlt ein Wert, wird nichts gesendet (Vorschau „Variable fehlt“, Ausrollen wird vor jedem Schreiben abgelehnt, zusätzlich eine
Sperre im API-Client). Neue Tabelle `host_variables` (Migration `0016`).
## [1.21.0] "Osprey" — 2026-10-20
- **Management-Tunnel** (System › Management-Tunnel): Firewalls hinter NAT/CGNAT verwalten. Ein optionaler WireGuard-Hub auf dem
OPNexus-Server (Compose-Profil `overlay`, Container `wg-hub` aus dem Backend-Image mit `NET_ADMIN`) nimmt die Firewalls entgegen, die sich
nach außen einwählen; OPNexus erreicht deren API über einen kleinen CONNECT-Proxy des Hubs, der nur ins Tunnel-Netz verbindet. Je Firewall
eine Tunnel-Adresse und ein eigener Pre-Shared Key, Anleitung mit kopierbaren Werten, Live-Status (Handshake, Datenmenge). Der Hub nimmt
keine neuen Verbindungen aus dem Tunnel an. Ende-zu-Ende gegen eine echte OPNsense 26.7 geprüft (Handshake, API, Zertifikat festsetzen).
Neue Tabellen `overlay_settings`, `overlay_peers` (Migration `0015`); das Backend-Image enthält jetzt `wireguard-tools`, `iproute2`, `iptables`.
## [1.20.0] "Lilli" — 2026-10-20
- **Single Sign-on (OIDC)**: Anmeldung über Authentik, Keycloak, Entra ID, Google u. a. (Authorization Code mit PKCE). Die Rolle
kommt bei jeder Anmeldung aus den Gruppen des Anbieters, ohne zugeordnete Gruppe kommt niemand hinein; SSO-Konten werden nie mit
lokalen Konten verknüpft; lokale Anmeldung bleibt als Notzugang. ID-Token werden streng geprüft (Signatur nur asymmetrisch, Aussteller,
Zielgruppe, Ablauf, Nonce, einmaliger State), gegen einen nachgebauten Anbieter getestet (inkl. Fälschungsversuche). Neue Tabellen
`oidc_settings`, `oidc_login_states`, Spalten `admin_users.auth_source/oidc_subject` (Migration `0013`). Neue Abhängigkeit: PyJWT.
- **Behoben:** `opnexus backup --full` und `opnexus restore` (Vollsicherung, in 1.19.0 beschrieben) fehlten im 1.19.0-Bundle und -Updater-Image,
weil die Änderung am Skript nicht mit eingecheckt war; sie sind erst ab dieser Version enthalten.
- **Aufgabentrennung**: Schalter, mit dem nur Admins ausrollen dürfen, Operatoren aber vorbereiten (Migration `0014`).
## [1.19.0] "Lanner" — 2026-10-20
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende
(sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“.
- **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine
Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit.
Tokens eines Operators gehen bis zum Bereich „Ausrollen“.
- **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens
einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`).
- **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM,
Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen
zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte
`notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen
AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus.
- **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer
verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen).
- **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und
Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle
`rule_hits` (Migration `0012`).
- Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
## [1.18.0] "Kestrel" — 2026-10-19
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.
- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
`api_tokens` (Migration `0008`).
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
Anfrage neu geladen.
## [1.17.0] "Imperial" — 2026-10-19
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
unbeaufsichtigt aktualisieren. Auf einem Test-Cluster live geprüft (Neustart und ein echtes Update
mit Umschaltung), siehe Handbuch „Geplante Wartung“.
- **Rollenprüfung beim Einplanen:** Ein Host, der in einem Cluster gerade CARP-MASTER ist,
lässt sich nicht mehr einplanen (Neustart/Update/Major-Upgrade). Bisher wurde der Plan
angenommen und scheiterte erst zur Uhrzeit. Der Planungsdialog zeigt den Grund an und
sperrt den Button. Ist die Rolle nicht feststellbar (Host gerade nicht erreichbar), wird
weiter angenommen; die volle Prüfung läuft wie bisher zum Zündzeitpunkt.
- **Benachrichtigung „Geplante Wartung nicht ausgeführt“:** Eine fehlgeschlagene geplante
Wartung meldet sich per Webhook/E-Mail (einzeln schaltbar in den Einstellungen, standardmäßig
an). Offen für drei Tage nach dem geplanten Zeitpunkt, dann ohne „behoben“-Meldung geschlossen.
Neue Spalte `notification_settings.scheduled_maintenance_failed_enabled` (Migration `0006`).
- **Automatische CARP-Umschaltung (Option pro Plan, standardmäßig aus):** Ein geplanter Neustart,
Update oder Major-Upgrade auf dem MASTER schaltet vorher auf den Partner um, prüft die
Übernahme, führt die Aktion aus, wartet auf die Rückkehr des Hosts und schaltet zurück. Fehler
vor dem Start der Aktion stellen den Normalbetrieb wieder her; danach bleibt der Host bewusst im
Wartungsmodus (Partner trägt den Verkehr) und es kommt eine Benachrichtigung. Der Ablauf steht in
der Datenbank und überlebt einen Neustart des Backends. Neue Spalten an `scheduled_maintenance`
(Migration `0007`).
- **Backup vor geplantem Update:** Ein geplantes Update oder Major-Upgrade zieht unmittelbar vor
dem Start selbst ein Konfigurationsbackup (nur lesend auf der Firewall). Bisher scheiterte jeder
Plan, der mehr als 24 Stunden in der Zukunft lag, an der Backup-Aktualität. Schlägt das Backup
fehl, wird nicht aktualisiert.
## [1.16.0] "Hobby" — 2026-10-10
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:
- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
(Migration `0005`).
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
(`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
`python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
## [1.15.0] "Gyrfalcon" — 2026-10-10
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
„Update aus dem Webinterface (optional)“:
- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
(mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
**Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
**aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
„zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
## [1.14.0] "Eagle" — 2026-10-09
Diagnose von der Firewall aus sowie VXLAN und GIF. Gegen die OPNsense-Core-Quellen
geprüft und live auf den Test-Firewalls getestet, siehe Handbuch „Erweiterung 1.14.0“:
- **Host-System › Diagnose:** Ping, Traceroute, Port-Prüfung und DNS-Abfrage, ausgeführt von der
ausgewählten Firewall. Nur für Admins, strenge Eingabeprüfung, jeder Lauf im Audit-Log,
Ping-Jobs werden immer wieder aufgeräumt, der Banner der Gegenstelle wird nicht weitergegeben.
Packet-Capture gibt es bewusst nicht (Mitschnitte enthalten fremde Nutzdaten).
- **Netzwerk › Geräte:** VXLAN und GIF-Tunnel (anlegen, ändern, löschen) über dieselbe abgesicherte
Engine wie die übrigen Geräte.
- **Fehlerkorrektur:** Auf den Host-System-Seiten war „Übersicht › Hosts“ markiert und der Seitentitel
falsch, weil `/hostsystem` mit `/hosts` beginnt.
## [1.13.0] "Buzzard" — 2026-10-09
Mehr Verwaltung pro Firewall und ein Abgleich der Cluster-Knoten. Alles über
dieselbe abgesicherte Engine wie in 1.12.0 und live auf drei Test-Firewalls
geprüft, siehe Handbuch „Erweiterung 1.13.0“:
- **Netzwerk › Geräte:** LAGG, Bridge, Loopback und GRE. Dazu Gateway-Gruppen,
DNS-Weiterleitungen (Unbound) und DHCP-Bereiche (dnsmasq).
- **Neue Gruppe „Host-System“:** Tunables (immer mit Bestätigung, Vorgaben nur per
„Überschreiben“), Cron, Syslog-Ziele und ZFS-Snapshots (anlegen und löschen; der
aktive Snapshot ist geschützt, das Aktivieren bleibt OPNsense selbst überlassen).
- **Netzwerk › Abgleich:** zeigt Unterschiede in der Netzwerk-Konfiguration zwischen den
Knoten eines Clusters. Neuer Alarm „Netzwerk-Abweichung im Cluster“ (Einstellungen ›
Benachrichtigungen, Migration 0004).
- **Auf den Partner übertragen:** Nach dem Anlegen eines Objekts lässt es sich mit
einem Klick auch auf dem anderen Cluster-Knoten anlegen (eigene Vorschau, eigenes Backup).
- **Kopfzeile:** Mit acht Gruppen zeigt die Navigation die Namen ab 1150 px, darunter
nur Icons, damit Suche und Abmelden in einer Zeile bleiben.
## [1.12.0] "Condor" — 2026-10-09
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
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.
## [1.11.1] "Merlin" — 2026-10-09
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:
- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
`/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
`cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
`/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
(VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
`SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
(„instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
(„Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
„Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
allen Seiten, hell und dunkel, nichts mehr.
- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
(Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
## [1.11.0] "Merlin" — 2026-10-07
Sicherheitsüberprüfung vom 2026-10-07, umgesetzte Korrekturen.
+29 -10
View File
@@ -5,9 +5,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
[Website](https://opnexus.dev/) · [Changelog](CHANGELOG.md) · [Handbuch](docs/HANDBUCH.md) · [English](README.md)
![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp)
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.11.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
> **Stand:** `1.30.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -16,13 +16,30 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Sichere Änderungen** — jede Änderung läuft *Prüfen → Ausrollen → Bestätigen*; bleibt die Bestätigung im Zeitfenster aus, rollt OPNexus zurück.
- **Hierarchische Policies** — Pre-/Post-Rulebases mit Vererbung über Gruppenbäume.
- **VPN** — IPsec-Site-to-Site, OpenVPN und WireGuard, einmal definiert und ausgerollt.
- **Netzwerk** — Schnittstellen, VLANs, virtuelle IPs, Gateways, Routen, LAGG/Bridge/GRE, DNS und DHCP je Firewall, dazu Tunables, Cron, Syslog, Snapshots und Diagnose (Ping, Traceroute, Port-Prüfung, DNS), ein Abgleich der Cluster-Knoten, mit Diff-Vorschau, Backup vor jeder Änderung und Schutz davor, die eigene Verbindung zu kappen; von Hand angelegte Regeln, Aliase und NAT-Regeln lassen sich übernehmen.
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
- **Alarme** — Gateway-/WAN-Ausfall (down, Paketverlust, Latenz), Schwellen für Platte, Speicher, Swap und CPU sowie ausgefallene Dienste; über mehrere Messungen entprellt, damit Einzelspitzen und flackernde Verbindungen schweigen. Zustellung per E-Mail, Webhook, ntfy, Gotify, Telegram, Microsoft Teams oder Matrix, jedes Ereignis einzeln schaltbar.
- **Firewall-Oberfläche über OPNexus** — die Weboberfläche einer Firewall in einem eigenen Browser-Tab über OPNexus öffnen, auch hinter NAT; nur für Admins, Einmal-Ticket, eigene Origin, Zeitlimits und Audit (standardmäßig aus).
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
- **Update-Center und Rollouts** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus; Rollouts in Wellen (Canary → Pilot → Prod) mit wiederkehrenden Wartungsfenstern, Gesundheitsprüfung zwischen den Wellen und automatischem Halt bei jeder Auffälligkeit.
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
- **Automatisierung** — persönliche API-Tokens (Lesen / Ausrollen / Admin), Prometheus-Metriken, ein nur lesender MCP-Endpunkt für KI-Assistenten und ein OpenAPI-Schema.
- **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
- **Regel-Analyse** — „Welche Regel trifft?“ für Quelle, Ziel, Protokoll und Port: die entscheidende Regel je Firewall (oder Flotte/Gruppe) mit ehrlicher Sicherheit (sicher / vermutlich / unbekannt) und Prüfprotokoll Regel für Regel, nur lesend. Eine Bereinigungsliste nennt ungenutzte, verdeckte, doppelte und lange deaktivierte Regeln (jeweils mit Sicherheit), und jede Regel hat einen Änderungsverlauf – auch für Änderungen außerhalb von OPNexus.
- **Backups & Wiederherstellung** — geplante Konfigurationssicherungen mit Aufbewahrung, verschlüsselte Offsite-Kopie auf S3-kompatiblen Speicher, Änderungsansicht zwischen Sicherungen und eine vollständige verschlüsselte Sicherung von OPNexus selbst (`opnexus backup --full`).
- **Single Sign-on** — Anmeldung über deinen Identitätsanbieter (OIDC); die Rolle kommt bei jeder Anmeldung aus dessen Gruppen.
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Operator / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
Alle Screenshots zeigen fiktive Beispieldaten.
## Ratgeber
- [OPNsense-Firewalls zentral verwalten](https://opnexus.dev/opnsense-zentral-verwalten/) – wann sich zentrale Verwaltung lohnt und wie sie funktioniert
- [OPNsense-HA-Cluster mit CARP verwalten](https://opnexus.dev/opnsense-ha-cluster-carp/) – Rollen, Alarme, geordnete Updates, Abgleich beider Knoten
- [OPNsense-Regeln sicher ausrollen](https://opnexus.dev/opnsense-regeln-sicher-ausrollen/) – Vorschau, Bestätigungsfenster und automatischer Rollback
- [Das Kommandozeilen-Werkzeug `opnexus`](https://opnexus.dev/docs/cli/) – Installation aktualisieren, zurückrollen und sichern
## Schnellstart
Voraussetzung: Docker mit Compose-Plugin.
@@ -34,19 +51,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
docker compose up -d --build
```
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben:
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben. Verloren oder nie gesehen? Dieses Kommando setzt ein neues Zufallspasswort und zeigt es an (im Repo-Checkout `scripts/opnexus`, im Release-Bundle `./opnexus`):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls).
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls).
**Fertige Images** (amd64 und arm64) liegen in der Registry dieses Gitea. In der `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.11.0
OPNEXUS_VERSION=1.30.0
```
```bash
@@ -57,7 +76,7 @@ Aktualisiert wird danach mit `scripts/opnexus update` / `rollback` (erst ziehen,
## Datenschutz
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) ist **aus**, solange du ihn nicht setzt; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
OPNexus wird selbst betrieben und hat keine Telemetrie. Es verbindet sich nur mit deinen Firewalls und mit Benachrichtigungszielen, die du selbst einrichtest. Der optionale Hinweis auf neue Versionen (Schalter unter Einstellungen › OPNexus aktualisieren, oder `UPDATE_CHECK_URL` in der `.env`) ist **aus**, solange du ihn nicht einschaltest; eingeschaltet geht höchstens alle 12 Stunden ein einfaches `GET` mit dem User-Agent `OPNexus/<Version>` hinaus — ohne Instanz- oder Nutzerdaten.
## Lizenz
+32 -11
View File
@@ -5,9 +5,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
[Website](https://opnexus.dev) · [Changelog](CHANGELOG.md) · [Handbook (German)](docs/HANDBUCH.md) · [Deutsch](README.de.md)
![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp)
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.11.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
> **Status:** `1.30.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -16,15 +16,32 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Safe changes** — every change goes *preview → deploy → confirm*; without your confirmation inside the time window OPNexus rolls it back.
- **Hierarchical policies** — pre/post rulebases with inheritance across group trees.
- **VPN** — IPsec site-to-site, OpenVPN and WireGuard, defined once and rolled out.
- **Network** — interfaces, VLANs, virtual IPs, gateways, routes, LAGG/bridge/GRE, DNS and DHCP per firewall, plus tunables, cron, syslog, snapshots and diagnostics (ping, traceroute, port check, DNS), a comparison of cluster nodes, with a diff preview, a backup before every change and a guard against cutting your own connection; rules, aliases and NAT rules created by hand on a firewall can be adopted.
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
- **Alerts** — gateway/WAN outage (down, packet loss, latency), disk/memory/swap/CPU thresholds and stopped services, debounced over several measurements so spikes and flapping links stay quiet; delivered by email, webhook, ntfy, Gotify, Telegram, Microsoft Teams or Matrix, all switchable per event.
- **Firewall interface through OPNexus** — open a firewall's own web interface in a separate browser tab through OPNexus, even behind NAT; admins only, one-time ticket, isolated origin, time limits and audit (off by default).
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
- **Update center & rollouts** — firmware updates for the fleet, aware of HA order and maintenance mode; rollouts in waves (canary → pilot → prod) with recurring maintenance windows, a health gate between the waves and an automatic stop on anything unusual.
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
- **Automation** — personal API tokens (read / deploy / admin), Prometheus metrics, a read-only MCP endpoint for AI assistants and an OpenAPI schema.
- **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
- **Rule analysis** — "Which rule matches?" for a source, destination, protocol and port: the deciding rule per firewall (or fleet/group) with an honest confidence (sure / probable / unknown) and a rule-by-rule trace, read-only. A cleanup list names unused, shadowed, duplicate and long-disabled rules (each with its confidence) and every rule has a change history, including changes made outside OPNexus.
- **Backups & recovery** — scheduled config backups with retention, an encrypted offsite copy to S3-compatible storage, a redacted change view between backups, and a full encrypted backup/restore of OPNexus itself (`opnexus backup --full`).
- **Single sign-on** — sign in with your identity provider (OIDC); roles come from its groups at every login.
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / operator / read-only), keyboard operation, dark mode, German and English UI.
![Cluster view](website/assets/screenshots/en/cluster-light-desktop.webp)
![Cluster view](docs/screenshots/readme-cluster-en.webp)
All screenshots show fictitious sample data.
## Guides
- [Manage OPNsense firewalls centrally](https://opnexus.dev/en/manage-opnsense-centrally/) – when central management pays off and how it works
- [Manage OPNsense HA clusters with CARP](https://opnexus.dev/en/opnsense-ha-cluster-carp/) – roles, alerts, ordered updates, comparing both nodes
- [Roll out OPNsense rules safely](https://opnexus.dev/en/roll-out-opnsense-rules-safely/) – preview, confirmation window and automatic rollback
- [The `opnexus` command-line tool](https://opnexus.dev/en/docs/cli/) – update, roll back and back up an installation
## Quick start
Requires Docker with the Compose plugin.
@@ -36,19 +53,21 @@ cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD)
docker compose up -d --build
```
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log:
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log. Lost it, or never saw it? This sets a new random one and prints it (use `scripts/opnexus` in a repo checkout, `./opnexus` in the release bundle):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German).
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls) (German).
**Prebuilt images** (amd64 and arm64) are published to this Gitea's registry. In `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.11.0
OPNEXUS_VERSION=1.30.0
```
```bash
@@ -57,9 +76,11 @@ docker compose pull && docker compose up -d --no-build
`scripts/opnexus update` / `rollback` then update the installation (pull first, database backup before migrations, automatic rollback if the services do not become healthy).
**Updating from the web interface** is available as an opt-in: set `COMPOSE_PROFILES=updater` in `.env` and an administrator can update (and roll back) under *Settings › Update OPNexus*. It runs as a separate small container that is the only one with Docker access — see the [handbook](docs/HANDBUCH.md#update-aus-dem-webinterface-optional) (German) for the security model.
## Privacy
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (`UPDATE_CHECK_URL=https://updates.opnexus.dev/latest.json`) is **off** unless you set it; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
OPNexus is self-hosted and has no telemetry. It only connects to your firewalls and to notification targets you configure yourself. The optional new-version notice (switch under Settings › Update OPNexus, or `UPDATE_CHECK_URL` in the `.env`) is **off** unless you turn it on; when on, it makes one plain `GET` at most every 12 hours with the user agent `OPNexus/<version>` — no instance or user data.
## License
+4
View File
@@ -8,6 +8,10 @@ WORKDIR /app
COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh
RUN /tmp/install_pg_client.sh 16 && rm /tmp/install_pg_client.sh
# Management-Overlay (nur der wg-hub-Container nutzt das, siehe app/overlay_hub.py).
RUN apt-get update && apt-get install -y --no-install-recommends wireguard-tools iproute2 iptables \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+61 -8
View File
@@ -7,14 +7,20 @@ from starlette.responses import JSONResponse
from app.database import SessionLocal
from app.errors import ErrorCode
from app.routers.auth import COOKIE_NAME
from app.services import auth_service
from app.routers.auth import session_token
from app.routers import governance
from app.routers.auth import client_ip
from app.services import api_token_service, auth_service
_PUBLIC_PATHS = {
"/api/auth/login",
"/api/auth/logout",
"/api/auth/me",
"/api/health",
# SSO: the browser arrives here without a session; every step is checked inside.
"/api/auth/oidc/config",
"/api/auth/oidc/start",
"/api/auth/oidc/callback",
}
# /docs, /redoc and /openapi.json used to be public — fully documenting a
@@ -35,6 +41,8 @@ _PUBLIC_PATHS = {
_READ_ONLY_ALLOWED_WRITES = {
"/api/auth/change-password",
"/api/settings",
# Read-only JSON-RPC tools (see routers/mcp.py).
"/api/mcp",
# Self-scoped: a user managing their own second factor.
"/api/auth/2fa/setup",
"/api/auth/2fa/enable",
@@ -48,8 +56,9 @@ _READ_ONLY_ALLOWED_WRITES = {
_SECURITY_KINDS = {
"ipsec": "ipsec_tunnel", "openvpn": "openvpn_instance", "wireguard": "wireguard_tunnel",
"certificates": "certificate", "acme": "acme", "notifications": "notification", "auth": "auth",
"tokens": "api_token", "rollouts": "rollout",
}
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings"}
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings", "plans", "runs"}
def _is_uuid(value: str) -> bool:
@@ -85,20 +94,42 @@ class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path in _PUBLIC_PATHS:
return await call_next(request)
if request.url.path.startswith("/internal/gui/"):
# GUI gateway only: never routed by the proxy, authenticated by the
# shared gateway token inside routers/gui_access.py.
return await call_next(request)
token = request.cookies.get(COOKIE_NAME, "")
if token:
api_token = None
bearer = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
token = session_token(request)
if bearer is not None:
# An API token wins over a cookie; a bad one never falls back to it.
async with SessionLocal() as db:
resolved = await api_token_service.resolve(db, bearer, client_ip(request))
user, api_token = resolved if resolved is not None else (None, None)
token = bearer
elif token:
async with SessionLocal() as db:
user = await auth_service.resolve_session(db, token)
if token:
if user is not None and api_token is not None and api_token_service.denies(
api_token.scope, request.method, request.url.path
):
return JSONResponse(
status_code=403,
content={"detail": {"code": ErrorCode.API_TOKEN_SCOPE_FORBIDDEN.value, "params": {"scope": api_token.scope}}},
)
if user is not None:
parts = request.url.path.strip("/").split("/")
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user"}
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system", "backups": "backup", "maintenance-windows": "maintenance_window"}
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
kind = event["kind"]
else:
kind = kinds.get(parts[1]) if len(parts) > 1 else None
if len(parts) > 2 and parts[1] == "settings":
kind = "settings" # where OPNexus stores/sends things (own prefs at /api/settings stay unaudited)
action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create")
event = {"kind": kind, "action": action, "entity_id": parts[2] if len(parts) > 2 else None,
"host_id": parts[2] if kind == "host" and len(parts) > 2 else None,
@@ -109,10 +140,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
user.role == "read_only"
and request.method not in ("GET", "HEAD", "OPTIONS")
and request.url.path not in _READ_ONLY_ALLOWED_WRITES
# Own API tokens (the router limits a read_only user to read scope).
and not request.url.path.startswith("/api/tokens")
):
if tracked:
# Refused attempts at security-relevant writes stay attributable.
ctx = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
try:
await audit_service.persist(**event, status="denied", message="Schreibzugriff für Nur-Lese-Konto verweigert")
finally:
@@ -121,8 +154,28 @@ class AuthMiddleware(BaseHTTPMiddleware):
status_code=403,
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
)
if user.role == "operator" and api_token_service.operator_denies(request.method, request.url.path):
if tracked:
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
try:
await audit_service.persist(**event, status="denied", message="Nur für Administratoren")
finally:
audit_service.context.reset(ctx)
return JSONResponse(status_code=403, content={"detail": {"code": ErrorCode.ADMIN_REQUIRED.value, "params": {}}})
if user.role == "operator" and api_token_service.is_rollout(request.method, request.url.path):
async with SessionLocal() as gdb:
allowed = (await governance.get_settings(gdb)).operators_may_deploy
if not allowed:
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
try:
await audit_service.persist(**event, status="denied", message="Ausrollen nur durch Administratoren (Aufgabentrennung)")
finally:
audit_service.context.reset(ctx)
return JSONResponse(status_code=403, content={"detail": {"code": ErrorCode.DEPLOY_NEEDS_ADMIN.value, "params": {}}})
request.state.user = user
token = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
request.state.api_token = api_token
actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150]
token = audit_service.context.set({"actor": actor, "id": str(uuid.uuid4()), "action": event["action"]})
try:
if tracked:
await audit_service.persist(**event, status="requested")
+31
View File
@@ -0,0 +1,31 @@
"""Decrypt an offsite backup (``.opxbak``) back into a config.xml.
docker compose exec -T backend python -m app.backup_decrypt < file.opxbak > config.xml
The passphrase comes from the BACKUP_PASSPHRASE environment variable or, if that is
not set, from the passphrase stored in OPNexus. For a disaster-recovery machine
without OPNexus run it with the passphrase you kept elsewhere:
docker run --rm -i -e BACKUP_PASSPHRASE=... <backend image> python -m app.backup_decrypt < file.opxbak > config.xml
"""
import os
import sys
from app.services import backup_crypto, notification_secret_store
def main() -> int:
passphrase = os.environ.get("BACKUP_PASSPHRASE") or notification_secret_store.read_secret("backup_passphrase")
if not passphrase:
print("Keine Passphrase: BACKUP_PASSPHRASE setzen.", file=sys.stderr)
return 2
try:
sys.stdout.buffer.write(backup_crypto.decrypt(sys.stdin.buffer.read(), passphrase))
except backup_crypto.DecryptError as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+42
View File
@@ -6,6 +6,17 @@ class Settings(BaseSettings):
def allowed_origins(self) -> list[str]:
return [o.strip().rstrip("/") for o in self.frontend_origin.split(",") if o.strip()]
@property
def gui_site_origins(self) -> list[str]:
return [o.strip().rstrip("/") for o in self.opnexus_gui_sites.split(",") if o.strip()]
def gui_origin_conflicts(self) -> list[str]:
"""Slot origins of the GUI gateway that are also listed in FRONTEND_ORIGIN.
Such an origin would get CORS with credentials and pass the Sec-Fetch-Site
exception — a firewall GUI could then read OPNexus' API. Startup is refused."""
allowed = {o.lower() for o in self.allowed_origins}
return [o for o in self.gui_site_origins if o.lower() in allowed]
database_url: str = (
"postgresql+asyncpg://opnexus:opnexus@postgres:5432/opnexus"
)
@@ -27,6 +38,17 @@ class Settings(BaseSettings):
# keine Instanz- oder Nutzerdaten.
update_check_url: str = ""
update_check_interval_hours: int = 12
# Registry der Release-Images (OPNEXUS_IMAGE_REPO). Leer = OPNexus wird lokal aus dem
# Quellcode gebaut; dann gibt es keine Updates per Klick (nur der Hinweis in den Einstellungen).
opnexus_image_repo: str = ""
# ── Updates aus dem Webinterface (app/services/updater_client.py) ─────
# Optionaler Updater-Container (Compose-Profil "updater"). Das Backend hat
# nie Zugriff auf den Docker-Socket; es reicht nur "aktualisieren" /
# "zurückrollen" an diesen Dienst weiter, authentifiziert mit dem Token,
# den der Updater in sein Zustands-Volume schreibt.
updater_url: str = "http://updater:8080"
updater_token_file: str = "/updater/token"
# Cookies now carry the session, so this can no longer be a bare "*"
# (the Fetch spec forbids credentialed requests against a wildcard
@@ -45,6 +67,11 @@ class Settings(BaseSettings):
# reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan".
frontend_origin: str = ""
opnsense_secret_dir: str = "/data/opnsense-secrets"
# Management overlay (WireGuard hub in the optional `wg-hub` container, see services/overlay_service.py).
overlay_dir: str = "/data/overlay"
overlay_proxy_url: str = "http://wg-hub:8888"
# Lab only: allow an http:// endpoint for offsite backups (credentials in the clear).
allow_insecure_offsite: bool = False
opnsense_backup_dir: str = "/data/opnsense-backups"
opnsense_timeout: float = 10.0
# Firewall API credentials are sent as HTTP Basic auth. Plain http://
@@ -76,6 +103,14 @@ class Settings(BaseSettings):
# per-IP lockout entirely.
trust_proxy_headers: bool = False
# ── GUI gateway (1.27, optional container "gui-gateway") ─────────
# Slot origins the gateway is served on, comma-separated, e.g.
# "https://192.0.2.10:9441,https://192.0.2.10:9442". Empty = feature unavailable.
opnexus_gui_sites: str = ""
# Shared secret between backend and gateway for /internal/gui/* (at least
# 32 characters). Empty = the internal endpoints answer 404.
gui_gateway_token: str = ""
# ── Deploy lockout protection ────────────────────────────────────
deploy_confirm_window_seconds: int = 180
@@ -125,6 +160,13 @@ class Settings(BaseSettings):
# wireguard_key_store.py.
wireguard_key_secret_dir: str = "/data/wireguard-key-secrets"
# ── Rule observation (usage counters + change history of the live rules) ─
# Two read-only GETs per enabled firewall per cycle (searchRule, ruleStats). The interval is an operator
# setting (env RULE_OBSERVE_INTERVAL_SECONDS); it can not go below the floor, which keeps a typo from
# polling every firewall in a tight loop.
rule_observe_interval_seconds: int = 900
rule_observe_min_interval_seconds: int = 60
# ── Resource monitoring ──────────────────────────────────────────────
# Fixed interval, no admin-editable settings row/UI for this — v1
# deliberately kept minimal (see resource_monitor_service.py).
+87
View File
@@ -6,6 +6,14 @@ from fastapi import HTTPException
class ErrorCode(str, Enum):
UPDATER_UNAVAILABLE = "UPDATER_UNAVAILABLE"
UPDATER_BUSY = "UPDATER_BUSY"
UPDATER_REJECTED = "UPDATER_REJECTED"
UPDATE_NOT_OFFERED = "UPDATE_NOT_OFFERED"
UPDATE_MAJOR_CHANGE = "UPDATE_MAJOR_CHANGE"
UPDATE_CHECK_MANAGED_BY_SERVER = "UPDATE_CHECK_MANAGED_BY_SERVER"
UPDATE_CHECK_URL_INVALID = "UPDATE_CHECK_URL_INVALID"
ADMIN_REQUIRED = "ADMIN_REQUIRED"
BACKUP_EMPTY = "BACKUP_EMPTY"
BACKUP_INVALID_XML = "BACKUP_INVALID_XML"
BACKUP_TOO_LARGE = "BACKUP_TOO_LARGE"
@@ -43,6 +51,8 @@ class ErrorCode(str, Enum):
FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998"
FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR = "FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR"
GEPLANTE_WARTUNG_NICHT_GEFUNDEN = "GEPLANTE_WARTUNG_NICHT_GEFUNDEN"
GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH = "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER = "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
GROUP_HAS_ASSIGNED_ITEMS = "GROUP_HAS_ASSIGNED_ITEMS"
GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN = "GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN"
GRUPPENNAME_BEREITS_VERGEBEN = "GRUPPENNAME_BEREITS_VERGEBEN"
@@ -77,6 +87,15 @@ class ErrorCode(str, Enum):
MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN = "MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN"
NICHT_AUTHENTIFIZIERT = "NICHT_AUTHENTIFIZIERT"
CROSS_ORIGIN_BLOCKED = "CROSS_ORIGIN_BLOCKED"
CROSS_SITE_REQUEST_BLOCKED = "CROSS_SITE_REQUEST_BLOCKED"
GUI_SESSION_REQUIRED = "GUI_SESSION_REQUIRED"
GUI_GATEWAY_NOT_CONFIGURED = "GUI_GATEWAY_NOT_CONFIGURED"
GUI_REQUIRES_HTTPS = "GUI_REQUIRES_HTTPS"
GUI_ACCESS_DISABLED = "GUI_ACCESS_DISABLED"
GUI_HOST_NOT_ELIGIBLE = "GUI_HOST_NOT_ELIGIBLE"
GUI_NO_FREE_SLOT = "GUI_NO_FREE_SLOT"
GUI_SLOT_COOLING_DOWN = "GUI_SLOT_COOLING_DOWN"
GUI_TOO_MANY_SESSIONS = "GUI_TOO_MANY_SESSIONS"
TLS_PROBE_FAILED = "TLS_PROBE_FAILED"
TLS_PEM_INVALID = "TLS_PEM_INVALID"
TLS_PIN_MISMATCH = "TLS_PIN_MISMATCH"
@@ -99,6 +118,20 @@ class ErrorCode(str, Enum):
REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203 = "REGELLISTE_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_ERNEUT_529D7203"
REGEL_HAT_KEINE_ZIEL_HOSTS = "REGEL_HAT_KEINE_ZIEL_HOSTS"
REGEL_NICHT_GEFUNDEN = "REGEL_NICHT_GEFUNDEN"
NETWORK_ACK_REQUIRED = "NETWORK_ACK_REQUIRED"
NETWORK_APPLY_UNCERTAIN = "NETWORK_APPLY_UNCERTAIN"
NETWORK_BACKUP_FAILED = "NETWORK_BACKUP_FAILED"
NETWORK_CHANGE_BLOCKED = "NETWORK_CHANGE_BLOCKED"
NETWORK_FIELD_INVALID = "NETWORK_FIELD_INVALID"
NETWORK_ITEM_NOT_FOUND = "NETWORK_ITEM_NOT_FOUND"
RULE_NOT_ADOPTABLE = "RULE_NOT_ADOPTABLE"
OBJECT_NOT_ADOPTABLE = "OBJECT_NOT_ADOPTABLE"
ADOPT_OBJECT_NOT_FOUND = "ADOPT_OBJECT_NOT_FOUND"
OUTBOUND_MODE_BLOCKS_ADOPT = "OUTBOUND_MODE_BLOCKS_ADOPT"
ADOPT_LINK_TARGET_NOT_FOUND = "ADOPT_LINK_TARGET_NOT_FOUND"
ADOPT_LINK_TARGET_CHANGED = "ADOPT_LINK_TARGET_CHANGED"
ADOPT_LINK_HOST_HAS_OBJECT = "ADOPT_LINK_HOST_HAS_OBJECT"
ADOPT_LINK_BUSY = "ADOPT_LINK_BUSY"
REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0 = "REGEL_WURDE_INZWISCHEN_GEAENDERT_NEU_LADEN_UND_AENDERUNGEN_BF51B6B0"
RESTORE_VORGANG_NICHT_GEFUNDEN = "RESTORE_VORGANG_NICHT_GEFUNDEN"
ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL = "ROLLBACK_ERNEUT_FEHLGESCHLAGEN_PRUEFE_DIE_ERREICHBARKEIT_DER_FIREWALL"
@@ -114,6 +147,10 @@ class ErrorCode(str, Enum):
UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN = "UEBERGEORDNETE_GRUPPE_NICHT_GEFUNDEN"
UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT = "UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT"
UPSTREAM_REQUEST_FAILED = "UPSTREAM_REQUEST_FAILED"
API_RIGHTS_MISSING = "API_RIGHTS_MISSING"
GATEWAY_NAME_INVALID = "GATEWAY_NAME_INVALID"
GATEWAY_UNKNOWN = "GATEWAY_UNKNOWN"
GATEWAY_MUTE_LIMIT = "GATEWAY_MUTE_LIMIT"
VERKNUEPFTER_ACME_ACCOUNT_NICHT_GEFUNDEN = "VERKNUEPFTER_ACME_ACCOUNT_NICHT_GEFUNDEN"
VORGANG_KANN_NICHT_MEHR_ABGEBROCHEN_WERDEN = "VORGANG_KANN_NICHT_MEHR_ABGEBROCHEN_WERDEN"
VORSCHAU_ABGELAUFEN_BITTE_ERNEUT_OEFFNEN = "VORSCHAU_ABGELAUFEN_BITTE_ERNEUT_OEFFNEN"
@@ -134,7 +171,57 @@ class ErrorCode(str, Enum):
OPEN_DEPLOYMENT_DRAFT_UNCHANGED = "OPEN_DEPLOYMENT_DRAFT_UNCHANGED"
OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY = "OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY"
OPEN_RESTORE_BLOCKS_DEPLOY = "OPEN_RESTORE_BLOCKS_DEPLOY"
UPSTREAM_REJECTED = "UPSTREAM_REJECTED"
INVALID_CHARACTERS = "INVALID_CHARACTERS"
SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
SERVICE_LOCKED = "SERVICE_LOCKED"
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED"
OFFSITE_TEST_FAILED = "OFFSITE_TEST_FAILED"
OIDC_TEST_FAILED = "OIDC_TEST_FAILED"
VARIABLES_MISSING = "VARIABLES_MISSING"
VARIABLE_INVALID = "VARIABLE_INVALID"
OVERLAY_INVALID = "OVERLAY_INVALID"
NOTIFICATION_CHANNEL_INVALID = "NOTIFICATION_CHANNEL_INVALID"
NOTIFICATION_CHANNEL_NOT_FOUND = "NOTIFICATION_CHANNEL_NOT_FOUND"
NOTIFICATION_CHANNEL_NAME_TAKEN = "NOTIFICATION_CHANNEL_NAME_TAKEN"
NOTIFICATION_CHANNEL_TEST_FAILED = "NOTIFICATION_CHANNEL_TEST_FAILED"
NOTIFICATION_WEBHOOK_URL_INVALID = "NOTIFICATION_WEBHOOK_URL_INVALID"
OVERLAY_SUBNET_LOCKED = "OVERLAY_SUBNET_LOCKED"
OVERLAY_NAME_TAKEN = "OVERLAY_NAME_TAKEN"
OVERLAY_PEER_NOT_FOUND = "OVERLAY_PEER_NOT_FOUND"
DEPLOY_NEEDS_ADMIN = "DEPLOY_NEEDS_ADMIN"
OIDC_MANAGED_USER = "OIDC_MANAGED_USER"
BACKUP_UNLESBAR = "BACKUP_UNLESBAR"
API_TOKEN_NOT_FOUND = "API_TOKEN_NOT_FOUND"
API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN"
API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH"
WINDOW_NOT_FOUND = "WINDOW_NOT_FOUND"
WINDOW_INVALID = "WINDOW_INVALID"
WINDOW_NAME_TAKEN = "WINDOW_NAME_TAKEN"
WINDOW_IN_USE = "WINDOW_IN_USE"
WINDOW_EXCEPTION_INVALID = "WINDOW_EXCEPTION_INVALID"
WINDOW_EXCEPTION_EXISTS = "WINDOW_EXCEPTION_EXISTS"
WINDOW_EXCEPTION_NOT_FOUND = "WINDOW_EXCEPTION_NOT_FOUND"
ROLLOUT_PLAN_NOT_FOUND = "ROLLOUT_PLAN_NOT_FOUND"
ROLLOUT_INVALID = "ROLLOUT_INVALID"
ROLLOUT_NAME_TAKEN = "ROLLOUT_NAME_TAKEN"
ROLLOUT_PLAN_INVALID = "ROLLOUT_PLAN_INVALID"
ROLLOUT_PREVIEW_STALE = "ROLLOUT_PREVIEW_STALE"
ROLLOUT_PLAN_BUSY = "ROLLOUT_PLAN_BUSY"
ROLLOUT_GROUP_IN_USE = "ROLLOUT_GROUP_IN_USE"
ROLLOUT_RUN_NOT_FOUND = "ROLLOUT_RUN_NOT_FOUND"
ROLLOUT_ALREADY_ACTIVE = "ROLLOUT_ALREADY_ACTIVE"
ROLLOUT_CONFIRM_NAME = "ROLLOUT_CONFIRM_NAME"
ROLLOUT_ACTION_NOT_ALLOWED = "ROLLOUT_ACTION_NOT_ALLOWED"
ROLLOUT_HOST_NOT_IN_RUN = "ROLLOUT_HOST_NOT_IN_RUN"
ROLLOUT_HOST_RUNNING = "ROLLOUT_HOST_RUNNING"
ROLLOUT_HOST_FAILED = "ROLLOUT_HOST_FAILED"
ANALYSE_ADRESSE_UNGUELTIG = "ANALYSE_ADRESSE_UNGUELTIG"
ANALYSE_FAMILIEN_MISCHUNG = "ANALYSE_FAMILIEN_MISCHUNG"
ANALYSE_INTERFACE_UNBEKANNT = "ANALYSE_INTERFACE_UNBEKANNT"
ANALYSE_PARAMETER_UNGUELTIG = "ANALYSE_PARAMETER_UNGUELTIG"
ANALYSE_FIREWALL_NICHT_LESBAR = "ANALYSE_FIREWALL_NICHT_LESBAR"
def api_error(status_code: int, code: ErrorCode, **params: Any) -> HTTPException:
+208
View File
@@ -0,0 +1,208 @@
"""Full OPNexus backup: the database and every secret store in one encrypted file.
The nightly database dump is not enough to rebuild an OPNexus server: the firewalls' API secrets, TOTP
secrets, VPN/certificate keys and the stored config snapshots live in separate volumes. This packs all of it
(``database.dump`` + the data directories + ``manifest.json``) into a tar.gz and encrypts it with a passphrase
(AES-256-GCM, services/backup_crypto.py). Without the passphrase the file is useless, so keep it elsewhere.
Normally driven by the host CLI:
./opnexus backup --full [FILE] # writes an encrypted .opxfull file
./opnexus restore FILE # onto a fresh or existing installation
or directly (passphrase in FULL_BACKUP_PASSPHRASE):
python -m app.full_backup create > file.opxfull
python -m app.full_backup info < file.opxfull # manifest only
python -m app.full_backup restore < file.opxfull
"""
import io
import json
import os
import subprocess
import sys
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.engine import make_url
from app.config import settings
from app.services import backup_crypto
from app.version import APP_VERSION
FORMAT = 1
MIN_PASSPHRASE = 12
# manifest key -> settings attribute holding the directory
_DATA_DIRS = {
"opnsense_secrets": "opnsense_secret_dir",
"opnsense_backups": "opnsense_backup_dir",
"ipsec_psk_secrets": "ipsec_psk_secret_dir",
"openvpn_key_secrets": "openvpn_key_secret_dir",
"wireguard_key_secrets": "wireguard_key_secret_dir",
"cert_key_secrets": "cert_key_secret_dir",
"acme_secrets": "acme_secret_dir",
"totp_secrets": "totp_secret_dir",
"notification_secrets": "notification_secret_dir",
"overlay": "overlay_dir",
}
class FullBackupError(Exception):
pass
def _dir(name: str) -> Path:
return Path(getattr(settings, _DATA_DIRS[name]))
def _db_args() -> tuple[list[str], dict]:
url = make_url(settings.database_url)
return (
["-h", url.host or "localhost", "-p", str(url.port or 5432), "-U", url.username or "", "-d", url.database or ""],
{**os.environ, "PGPASSWORD": url.password or ""},
)
def _dump_database(target: Path) -> None:
args, env = _db_args()
skip = [f"--exclude-table-data={t}" for t in ("firewall_log_entries", "host_resource_samples")]
subprocess.run(["pg_dump", *args, "-F", "c", *skip, "-f", str(target)], env=env, check=True, capture_output=True, timeout=1800)
def _restore_database(source: Path) -> None:
args, env = _db_args()
result = subprocess.run(
["pg_restore", *args, "--clean", "--if-exists", "--no-owner", "--exit-on-error", str(source)],
env=env, capture_output=True, text=True, timeout=1800,
)
if result.returncode != 0:
raise FullBackupError(f"pg_restore fehlgeschlagen: {result.stderr.strip()[:400]}")
def _head_revision() -> str:
from alembic.script import ScriptDirectory
from app import migrate
return ScriptDirectory.from_config(migrate._config()).get_current_head()
def create(passphrase: str, *, dump=_dump_database, revision=None) -> bytes:
if len(passphrase) < MIN_PASSPHRASE:
raise FullBackupError(f"Die Passphrase braucht mindestens {MIN_PASSPHRASE} Zeichen")
manifest = {
"format": FORMAT, "app_version": APP_VERSION, "db_revision": revision or _head_revision(),
"created_at": datetime.now(timezone.utc).isoformat(), "data": sorted(_DATA_DIRS),
}
plain = io.BytesIO()
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=plain, mode="w:gz") as tar:
raw = json.dumps(manifest, indent=1).encode()
info = tarfile.TarInfo("manifest.json")
info.size = len(raw)
tar.addfile(info, io.BytesIO(raw))
dump_path = Path(tmp) / "database.dump"
dump(dump_path)
tar.add(dump_path, arcname="database.dump")
for name in _DATA_DIRS:
path = _dir(name)
if path.is_dir():
tar.add(path, arcname=f"data/{name}")
return backup_crypto.encrypt(plain.getvalue(), passphrase)
def _open(blob: bytes, passphrase: str) -> tarfile.TarFile:
try:
plain = backup_crypto.decrypt(blob, passphrase)
except backup_crypto.DecryptError as exc:
raise FullBackupError(str(exc)) from None
try:
return tarfile.open(fileobj=io.BytesIO(plain), mode="r:gz")
except tarfile.TarError:
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Archiv unlesbar)") from None
def _manifest(tar: tarfile.TarFile) -> dict:
try:
member = tar.extractfile("manifest.json")
manifest = json.loads(member.read()) if member else None
except (KeyError, ValueError):
manifest = None
if not isinstance(manifest, dict) or manifest.get("format") != FORMAT:
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Manifest fehlt oder ist neuer)")
return manifest
def info(blob: bytes, passphrase: str) -> dict:
with _open(blob, passphrase) as tar:
return _manifest(tar)
def restore(blob: bytes, passphrase: str, *, restore_db=_restore_database, head=None) -> dict:
"""Put database and data directories back. The caller stops the backend first."""
with _open(blob, passphrase) as tar:
manifest = _manifest(tar)
if not _older_or_equal(manifest.get("db_revision"), head):
raise FullBackupError(
f"Die Sicherung stammt aus einer neueren OPNexus-Version ({manifest.get('app_version')}). "
"Erst auf diese Version aktualisieren, dann zurückspielen."
)
members = tar.getmembers()
for member in members: # reject anything that could escape: absolute paths, .., links, devices
if member.name.startswith(("/", "\\")) or ".." in Path(member.name).parts or not (member.isfile() or member.isdir()):
raise FullBackupError(f"Unzulässiger Eintrag im Archiv: {member.name!r}")
known = {f"data/{name}" for name in _DATA_DIRS}
with tempfile.TemporaryDirectory() as tmp:
dump = tar.extractfile("database.dump")
if dump is None:
raise FullBackupError("Die Sicherung enthält keine Datenbank")
dump_path = Path(tmp) / "database.dump"
dump_path.write_bytes(dump.read())
restore_db(dump_path)
restored = []
for member in members:
parts = Path(member.name).parts
if len(parts) < 2 or parts[0] != "data" or f"data/{parts[1]}" not in known:
continue
target_root = _dir(parts[1])
target = target_root.joinpath(*parts[2:])
if member.isdir():
target.mkdir(parents=True, exist_ok=True, mode=0o700)
continue
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
source = tar.extractfile(member)
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as out:
out.write(source.read())
os.utime(target, (member.mtime, member.mtime)) # snapshot ages come from file times
restored.append(parts[1])
return {**manifest, "files_restored": len(restored)}
def _older_or_equal(revision: str | None, head: str | None) -> bool:
"""Revisions are zero-padded numbers ('0009'): a dump from an older schema is fine, the app migrates it."""
try:
return int(revision) <= int(head or _head_revision())
except (TypeError, ValueError):
return False
def _main(argv: list[str]) -> int:
passphrase = os.environ.get("FULL_BACKUP_PASSPHRASE", "")
if len(argv) != 2 or argv[1] not in ("create", "info", "restore"):
print(__doc__, file=sys.stderr)
return 2
try:
if argv[1] == "create":
sys.stdout.buffer.write(create(passphrase))
elif argv[1] == "info":
print(json.dumps(info(sys.stdin.buffer.read(), passphrase), indent=1))
else:
print(json.dumps(restore(sys.stdin.buffer.read(), passphrase), indent=1))
except (FullBackupError, subprocess.SubprocessError, OSError) as exc:
print(f"Fehler: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv))
+609
View File
@@ -0,0 +1,609 @@
"""The ``gui-gateway`` container: firewall web interfaces in their own browser origin (1.27).
Runs from the backend image (``python -m app.gui_gateway``) behind the proxy, which terminates TLS for the
slot ports and sets ``X-Onx-Slot`` (overwriting any client value). It never touches the database or the
secret stores; it only talks to the backend's internal API (``/internal/gui/*``, shared GUI_GATEWAY_TOKEN)
and, per gateway session, to exactly one firewall.
Security model (see the 1.27 design):
* Every slot port is its own origin. The firewall GUI therefore never runs in the OPNexus origin.
* Cookies never cross: the browser's ``Cookie`` header is not forwarded (it carries the OPNexus session),
and the firewall's ``Set-Cookie`` never reaches the browser — a server-side jar per gateway session
(memory only) holds them. The browser only gets ``__Host-onx-gw-<slot>``.
* Each upstream request is built from scratch: method, path and query from the browser, a small header
allow-list, the jar's cookies; target origin, pinned certificate and overlay proxy from the backend.
Absolute-form targets, CONNECT/TRACE, ``Upgrade`` and service-worker script requests are refused.
* ``Sec-Fetch-Site`` must be same-origin/none (same-site only for the redeem step), fail-closed;
non-GET/HEAD requests also need the slot's own ``Origin``.
* ``Location`` pointing anywhere but the firewall is dropped; responses get CSP, X-Frame-Options DENY,
``Cache-Control: no-store``.
* At most 3 failed GUI logins per firewall in 5 minutes (protects OPNexus' API access from the firewall's
login lockout, which would see all attempts coming from OPNexus).
* Idle and absolute limits; every 20 s the backend re-checks the session (OPNexus session, admin role,
switch, host, and that the firewall's address, pinned certificate and overlay route are still the ones the
session started with).
* At most MAX_SESSIONS sessions at once: expired ones are cleared first, a new session is refused otherwise —
a running session is never evicted by a newer one. Logs carry method, path (no query), status and duration only.
OPNsense behaviour this relies on (opnsense/core, src/www/authgui.inc, master as of 2026-10-21):
* session_auth(): a login is ``isset($_POST['login']) && !empty($_POST['usernamefld']) &&
!empty($_POST['passwordfld'])`` posted to the current URL (login form has no action). Success sends a
``Location:`` header and exits (302); a failure re-renders the login form (200) with "Wrong username or
password." and an auth_log entry.
* check_security_http_referer_enforcement() only runs when HTTP_REFERER is set, and wants the firewall's
own host name/IP — so the gateway sends no Referer (and no Origin) upstream at all.
* check_security_dns_rebind() accepts an IP address or the configured host name as Host — the gateway
sends the Host of the host record's base URL (httpx derives it from the URL).
"""
import asyncio
import hashlib
import logging
import os
import secrets
import ssl
import time
from collections import deque
from dataclasses import dataclass, field
from http.cookies import CookieError, SimpleCookie
from typing import Awaitable, Callable
from urllib.parse import parse_qs, urlsplit
import httpx
logger = logging.getLogger("gui_gateway")
# httpx/httpcore log full request URLs (query strings) at INFO/DEBUG: never in this process.
for _noisy in ("httpx", "httpcore"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
LISTEN_PORT = 8080
COOKIE_PREFIX = "__Host-onx-gw-"
OPNEXUS_SESSION_COOKIE = "__Host-opnexus-session"
BODY_LIMIT = 64 * 1024 * 1024 # config restore uploads
LOGIN_FAILURE_LIMIT = 3
LOGIN_FAILURE_WINDOW = 300.0
UPSTREAM_TIMEOUT = 60.0
MAX_SESSIONS = 64
# Request headers copied from the browser; everything else (Cookie, Authorization, Origin, Referer,
# X-Forwarded-*, hop-by-hop, Sec-*, ...) is dropped.
_FORWARD_REQUEST_HEADERS = {
"accept", "accept-language", "content-type", "user-agent", "x-requested-with", "x-csrftoken", "range",
}
# Response headers copied from the firewall. Set-Cookie goes to the jar, Location is rewritten.
_FORWARD_RESPONSE_HEADERS = {
"content-type", "content-length", "content-encoding", "content-disposition", "content-language",
"content-range", "accept-ranges", "content-security-policy",
}
_GATEWAY_CSP = ("connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; "
"object-src 'none'; worker-src 'none'")
_SECURITY_HEADERS = [
(b"content-security-policy", _GATEWAY_CSP.encode()),
(b"x-frame-options", b"DENY"),
(b"x-content-type-options", b"nosniff"),
(b"cache-control", b"no-store"),
(b"pragma", b"no-cache"),
(b"referrer-policy", b"same-origin"),
(b"cross-origin-opener-policy", b"same-origin"),
(b"cross-origin-resource-policy", b"same-origin"),
(b"permissions-policy", b"camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
]
def _sha(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def _origin_of(url: str) -> str | None:
try:
parts = urlsplit(url)
port = parts.port
except ValueError:
return None
if parts.scheme not in ("http", "https") or not parts.hostname:
return None
default = 443 if parts.scheme == "https" else 80
host = parts.hostname.lower()
if ":" in host:
host = f"[{host}]"
return f"{parts.scheme}://{host}" + (f":{port}" if port and port != default else "")
def slot_origins_from(sites: str) -> dict[int, set[str]]:
"""OPNEXUS_GUI_SITES -> {port: {normalised origins}} (https only, explicit port)."""
slots: dict[int, set[str]] = {}
for raw in sites.split(","):
raw = raw.strip().rstrip("/")
try:
parts = urlsplit(raw)
port = parts.port
except ValueError:
continue
if parts.scheme == "https" and parts.hostname and port:
slots.setdefault(port, set()).add(_origin_of(raw))
return slots
@dataclass
class GatewaySession:
gui_session_id: str
session_hash: str
slot: int
upstream_origin: str
ca_pem: str | None
proxy_url: str | None
idle_timeout: float
absolute_deadline: float
revalidate_every: float
target_fp: str | None = None
last_activity: float = field(default_factory=time.monotonic)
next_check: float = 0.0
jar: dict[str, str] = field(default_factory=dict)
client: httpx.AsyncClient | None = None
class BackendClient:
"""The backend's internal GUI API. Raises httpx.HTTPError when the backend is unreachable."""
def __init__(self, base_url: str, token: str, transport: httpx.AsyncBaseTransport | None = None):
self._client = httpx.AsyncClient(base_url=base_url, headers={"X-Onx-Gateway-Token": token},
timeout=10.0, transport=transport)
async def redeem(self, ticket_hash: str, session_hash: str, slot: int, client_ip: str | None) -> dict:
resp = await self._client.post("/internal/gui/redeem", json={
"ticket_hash": ticket_hash, "session_hash": session_hash, "slot": slot, "client_ip": client_ip})
if resp.status_code == 200:
return resp.json()
if resp.status_code == 403:
return {"ok": False, "reason": (resp.json().get("detail") or {}).get("reason", "denied")}
resp.raise_for_status()
return {"ok": False, "reason": f"http_{resp.status_code}"}
async def revalidate(self, gs: GatewaySession) -> dict:
resp = await self._client.post("/internal/gui/revalidate",
json={"gui_session_id": gs.gui_session_id, "session_hash": gs.session_hash,
"target_fp": gs.target_fp})
resp.raise_for_status()
return resp.json()
async def end(self, gs: GatewaySession, reason: str) -> None:
try:
await self._client.post("/internal/gui/end", json={
"gui_session_id": gs.gui_session_id, "session_hash": gs.session_hash, "reason": reason})
except httpx.HTTPError as exc:
logger.warning("Could not report the end of a GUI session (%s)", type(exc).__name__)
async def aclose(self) -> None:
await self._client.aclose()
def build_upstream_client(gs: GatewaySession) -> httpx.AsyncClient:
"""Same transport rules as opnsense_client._client: pinned certificate (or system CAs), overlay proxy."""
verify: ssl.SSLContext | bool
if gs.ca_pem:
from app.services.tls_pin_service import pinned_context # no database layer behind this import
verify = pinned_context(gs.ca_pem)
else:
verify = True
return httpx.AsyncClient(verify=verify, proxy=gs.proxy_url, timeout=UPSTREAM_TIMEOUT,
follow_redirects=False, trust_env=False)
class LoginLimiter:
"""At most LOGIN_FAILURE_LIMIT failed GUI logins per firewall within LOGIN_FAILURE_WINDOW seconds."""
def __init__(self, clock: Callable[[], float] = time.monotonic):
self._failures: dict[str, deque] = {}
self._clock = clock
def _recent(self, key: str) -> deque:
now = self._clock()
q = self._failures.setdefault(key, deque())
while q and now - q[0] > LOGIN_FAILURE_WINDOW:
q.popleft()
return q
def blocked(self, key: str) -> bool:
return len(self._recent(key)) >= LOGIN_FAILURE_LIMIT
def record_failure(self, key: str) -> None:
self._recent(key).append(self._clock())
def is_login_attempt(method: str, content_type: str, body: bytes) -> bool:
if method != "POST":
return False
if content_type.lower().startswith("multipart/form-data"):
# PHP fills $_POST from multipart bodies too, so authgui.inc accepts a login posted this way.
return all(f'name="{field}"'.encode() in body for field in ("login", "usernamefld", "passwordfld"))
if not content_type.lower().startswith("application/x-www-form-urlencoded"):
return False
try:
form = parse_qs(body.decode("latin-1"), keep_blank_values=True)
except ValueError:
return False
return "login" in form and bool(form.get("usernamefld", [""])[0]) and bool(form.get("passwordfld", [""])[0])
def rewrite_location(location: str, upstream_origin: str) -> str | None:
"""Relative target for the browser, or None when the redirect leaves the firewall."""
location = location.strip()
if not location or "\\" in location or any(ord(c) < 0x20 for c in location):
return None
if location.startswith("//"):
location = "https:" + location
parts = urlsplit(location)
if parts.scheme or parts.netloc:
if _origin_of(location) != _origin_of(upstream_origin):
return None
path = parts.path or "/"
return path + (f"?{parts.query}" if parts.query else "") + (f"#{parts.fragment}" if parts.fragment else "")
return location
def _page(title: str, text_de: str, text_en: str) -> bytes:
return (f"<!doctype html><html lang=\"de\"><head><meta charset=\"utf-8\"><title>{title}</title></head>"
f"<body style=\"font-family:sans-serif;max-width:40rem;margin:3rem auto\"><h1>{title}</h1>"
f"<p>{text_de}</p><p lang=\"en\">{text_en}</p></body></html>").encode()
_SESSION_GONE = _page("OPNexus", "Diese Sitzung ist abgelaufen oder ungültig. Öffne die Oberfläche erneut aus OPNexus.",
"This session has expired or is invalid. Open the interface again from OPNexus.")
_FORBIDDEN = _page("OPNexus", "Diese Anfrage wurde vom GUI-Gateway abgelehnt.", "This request was refused by the GUI gateway.")
_BAD_REDIRECT = _page("OPNexus", "Die Firewall wollte auf eine fremde Adresse umleiten; die Umleitung wurde verworfen.",
"The firewall tried to redirect to a foreign address; the redirect was dropped.")
_LOGIN_LOCKED = _page("OPNexus", "Zu viele fehlgeschlagene Anmeldungen an dieser Firewall. Bitte warte 5 Minuten.",
"Too many failed sign-ins at this firewall. Please wait 5 minutes.")
_UNAVAILABLE = _page("OPNexus", "Die Firewall oder OPNexus ist gerade nicht erreichbar.",
"The firewall or OPNexus is not reachable right now.")
_TOO_MANY = _page("OPNexus", "Es laufen schon zu viele Firewall-Oberflächen gleichzeitig. Versuche es später erneut.",
"Too many firewall interfaces are open at the same time. Try again later.")
_REDEEMED = (b"<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"0;url=/\">"
b"<title>OPNexus</title></head><body><a href=\"/\">&rarr;</a></body></html>")
class GuiGateway:
def __init__(self, *, backend: BackendClient, slot_origins: dict[int, set[str]],
upstream_factory: Callable[[GatewaySession], httpx.AsyncClient] = build_upstream_client,
clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], float] = time.time):
self.backend = backend
self.slot_origins = slot_origins
self.upstream_factory = upstream_factory
self.clock = clock
self.wall_clock = wall_clock
self.sessions: dict[str, GatewaySession] = {} # sha256(gateway cookie) -> session
self.limiter = LoginLimiter(clock)
self._loop_task: asyncio.Task | None = None
# ── ASGI ──────────────────────────────────────────────────────────────
async def __call__(self, scope, receive, send):
if scope["type"] == "lifespan":
await self._lifespan(receive, send)
return
if scope["type"] != "http":
# WebSocket and anything else: not supported (design: no WebSocket).
if scope["type"] == "websocket":
await send({"type": "websocket.close", "code": 1008})
return
started = time.monotonic()
status = await self._handle(scope, receive, send)
# Method, path and status only: never the query string, headers or bodies.
logger.info("%s %s %d %.0fms", scope.get("method", "?"), scope.get("path", "?")[:200], status,
(time.monotonic() - started) * 1000)
async def _lifespan(self, receive, send):
while True:
message = await receive()
if message["type"] == "lifespan.startup":
self._loop_task = asyncio.create_task(self._revalidate_loop())
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
if self._loop_task:
self._loop_task.cancel()
for key in list(self.sessions):
await self._drop(key, "shutdown")
await self.backend.aclose()
await send({"type": "lifespan.shutdown.complete"})
return
async def _revalidate_loop(self):
while True:
await asyncio.sleep(10)
try:
await self.sweep()
except Exception: # never let the loop die
logger.exception("GUI session sweep failed")
async def sweep(self) -> None:
"""Idle/absolute limits and the periodic backend re-check for every gateway session."""
for key, gs in list(self.sessions.items()):
reason = self._expired(gs)
if reason:
await self._drop(key, reason)
elif self.clock() >= gs.next_check:
await self._recheck(key, gs)
# ── helpers ───────────────────────────────────────────────────────────
@staticmethod
def _headers(scope) -> dict[str, str]:
result: dict[str, str] = {}
for k, v in scope.get("headers", []):
name = k.decode("latin-1").lower()
value = v.decode("latin-1")
if name in result:
value = f"{result[name]}{'; ' if name == 'cookie' else ', '}{value}"
result[name] = value
return result
@staticmethod
def _cookies(header: str) -> dict[str, str]:
cookies: dict[str, str] = {}
for part in header.split(";"):
name, sep, value = part.strip().partition("=")
if sep and name and name not in cookies:
cookies[name] = value
return cookies
async def _respond(self, send, status: int, body: bytes = b"", headers: list | None = None,
content_type: bytes = b"text/html; charset=utf-8") -> int:
out = [(b"content-type", content_type), (b"content-length", str(len(body)).encode())]
out += list(headers or []) + _SECURITY_HEADERS
await send({"type": "http.response.start", "status": status, "headers": out})
await send({"type": "http.response.body", "body": body})
return status
def _expired(self, gs: GatewaySession) -> str | None:
if self.wall_clock() >= gs.absolute_deadline:
return "absolute"
if self.clock() - gs.last_activity >= gs.idle_timeout:
return "idle"
return None
async def _drop(self, key: str, reason: str, *, report: bool = True) -> None:
gs = self.sessions.pop(key, None)
if gs is None:
return
gs.jar.clear()
if gs.client is not None:
await gs.client.aclose()
if report:
await self.backend.end(gs, reason)
async def _recheck(self, key: str, gs: GatewaySession) -> bool | None:
"""True = still valid, False = ended (dropped), None = backend unreachable (fail closed, keep for retry)."""
try:
result = await self.backend.revalidate(gs)
except httpx.HTTPError as exc:
logger.warning("GUI session re-check failed (%s)", type(exc).__name__)
return None
if not result.get("ok"):
await self._drop(key, str(result.get("reason", "revoked")), report=False) # backend already ended it
return False
gs.absolute_deadline = min(gs.absolute_deadline, float(result.get("absolute_deadline", gs.absolute_deadline)))
gs.idle_timeout = float(result.get("idle_timeout_seconds", gs.idle_timeout))
gs.next_check = self.clock() + gs.revalidate_every
return True
async def _clear_expired(self) -> None:
for key, gs in list(self.sessions.items()):
reason = self._expired(gs)
if reason:
await self._drop(key, reason)
def _has_room(self, browser_hash: str, slot: int) -> bool:
"""Room for one more session; the one this redeem would replace (same browser session and slot) does not count."""
replaced = sum(1 for g in self.sessions.values() if g.slot == slot and g.session_hash == browser_hash)
return len(self.sessions) - replaced < MAX_SESSIONS
# ── request handling ──────────────────────────────────────────────────
async def _handle(self, scope, receive, send) -> int:
method = scope.get("method", "")
path = scope.get("path", "")
raw_path = scope.get("raw_path") or path.encode()
headers = self._headers(scope)
if method in ("CONNECT", "TRACE"):
return await self._respond(send, 501, _FORBIDDEN)
# Origin-form only: "GET http://elsewhere/ HTTP/1.1" (absolute-form) or "//host" never reach upstream.
if not path.startswith("/") or path.startswith("//") or not raw_path.startswith(b"/") \
or raw_path.startswith(b"//") or "\\" in path:
return await self._respond(send, 400, _FORBIDDEN)
if "upgrade" in headers or "upgrade" in headers.get("connection", "").lower():
return await self._respond(send, 400, _FORBIDDEN)
if headers.get("service-worker", "").lower() == "script":
return await self._respond(send, 403, _FORBIDDEN)
try:
slot = int(headers.get("x-onx-slot", ""))
except ValueError:
slot = 0
own_origins = self.slot_origins.get(slot)
if not own_origins:
return await self._respond(send, 400, _FORBIDDEN) # not coming through the proxy's slot block
site = headers.get("sec-fetch-site", "").strip().lower()
if path == "/__onx/redeem":
if method != "GET" or site not in ("same-site", "same-origin", "none"):
return await self._respond(send, 403, _FORBIDDEN)
return await self._redeem(scope, headers, slot, send)
if site not in ("same-origin", "none"):
return await self._respond(send, 403, _FORBIDDEN) # missing header = old/odd client: fail closed
if method not in ("GET", "HEAD"):
origin = headers.get("origin", "")
if _origin_of(origin) not in own_origins:
return await self._respond(send, 403, _FORBIDDEN)
if path.startswith("/__onx/"):
return await self._respond(send, 404, _FORBIDDEN)
cookies = self._cookies(headers.get("cookie", ""))
token = cookies.get(f"{COOKIE_PREFIX}{slot}", "")
key = _sha(token) if token else ""
gs = self.sessions.get(key) if key else None
if gs is None or gs.slot != slot:
return await self._respond(send, 401, _SESSION_GONE)
reason = self._expired(gs)
if reason:
await self._drop(key, reason)
return await self._respond(send, 401, _SESSION_GONE)
if self.clock() >= gs.next_check:
state = await self._recheck(key, gs)
if state is False:
return await self._respond(send, 401, _SESSION_GONE)
if state is None:
return await self._respond(send, 503, _UNAVAILABLE)
gs.last_activity = self.clock()
return await self._proxy(scope, receive, send, headers, gs)
async def _redeem(self, scope, headers, slot: int, send) -> int:
query = parse_qs(scope.get("query_string", b"").decode("latin-1"))
ticket = (query.get("t") or [""])[0]
browser_session = self._cookies(headers.get("cookie", "")).get(OPNEXUS_SESSION_COOKIE, "")
if not ticket or len(ticket) > 128 or not browser_session:
return await self._respond(send, 403, _SESSION_GONE)
client_ip = (headers.get("x-forwarded-for", "").split(",")[0].strip() or None)
# Room first, before the ticket is burned: expired sessions go, running ones are never evicted.
await self._clear_expired()
if not self._has_room(_sha(browser_session), slot):
return await self._respond(send, 503, _TOO_MANY)
try:
result = await self.backend.redeem(_sha(ticket), _sha(browser_session), slot, client_ip and client_ip[:45])
except httpx.HTTPError as exc:
logger.warning("Ticket redeem failed (%s)", type(exc).__name__)
return await self._respond(send, 503, _UNAVAILABLE)
if not result.get("ok"):
return await self._respond(send, 403, _SESSION_GONE)
# One browser session, one gateway session per slot: an older one in this slot is replaced.
for old_key, old in list(self.sessions.items()):
if old.slot == slot and old.session_hash == _sha(browser_session):
await self._drop(old_key, "superseded", report=False)
value = secrets.token_urlsafe(32)
gs = GatewaySession(
gui_session_id=result["gui_session_id"], session_hash=_sha(browser_session), slot=slot,
upstream_origin=result["upstream_origin"], ca_pem=result.get("ca_pem"), proxy_url=result.get("proxy_url"),
idle_timeout=float(result["idle_timeout_seconds"]), absolute_deadline=float(result["absolute_deadline"]),
revalidate_every=float(result.get("revalidate_seconds", 20)), target_fp=result.get("target_fp"),
)
if len(self.sessions) >= MAX_SESSIONS: # filled up while the backend was answering
await self.backend.end(gs, "revoked")
return await self._respond(send, 503, _TOO_MANY)
gs.last_activity = self.clock()
gs.next_check = gs.last_activity + gs.revalidate_every
self.sessions[_sha(value)] = gs
cookie = f"{COOKIE_PREFIX}{slot}={value}; Path=/; Secure; HttpOnly; SameSite=Strict"
# 200 + meta refresh instead of a 303: the follow-up request then starts in this origin
# (Sec-Fetch-Site: same-origin) and the ticket leaves the address bar.
return await self._respond(send, 200, _REDEEMED, headers=[
(b"set-cookie", cookie.encode()), (b"clear-site-data", b'"cache", "storage"'),
])
async def _read_body(self, receive) -> bytes | None:
chunks, size = [], 0
while True:
message = await receive()
if message["type"] == "http.disconnect":
return None
chunk = message.get("body", b"")
size += len(chunk)
if size > BODY_LIMIT:
return None
chunks.append(chunk)
if not message.get("more_body"):
return b"".join(chunks)
def _store_cookies(self, gs: GatewaySession, response: httpx.Response) -> None:
for header in response.headers.get_list("set-cookie"):
jar = SimpleCookie()
try:
jar.load(header)
except CookieError:
continue
for name, morsel in jar.items():
expired = morsel["max-age"] in ("0",) or morsel.value in ("", "deleted")
if not expired and morsel["expires"]:
try:
from email.utils import parsedate_to_datetime
expired = parsedate_to_datetime(morsel["expires"]).timestamp() <= self.wall_clock()
except (TypeError, ValueError):
pass
if expired:
gs.jar.pop(name, None)
else:
gs.jar[name] = morsel.value
async def _proxy(self, scope, receive, send, headers: dict, gs: GatewaySession) -> int:
length = headers.get("content-length", "")
if length.isdigit() and int(length) > BODY_LIMIT:
return await self._respond(send, 413, _FORBIDDEN)
body = await self._read_body(receive)
if body is None:
return await self._respond(send, 413, _FORBIDDEN)
method = scope["method"]
login = is_login_attempt(method, headers.get("content-type", ""), body)
if login and self.limiter.blocked(gs.upstream_origin):
return await self._respond(send, 429, _LOGIN_LOCKED)
out_headers = {k: v for k, v in headers.items() if k in _FORWARD_REQUEST_HEADERS}
out_headers["accept-encoding"] = "identity"
if gs.jar:
out_headers["cookie"] = "; ".join(f"{k}={v}" for k, v in gs.jar.items())
query = scope.get("query_string", b"").decode("latin-1")
# The path exactly as the browser sent it (still percent-encoded): the decoded scope["path"] would
# turn %3F/%23 into query/fragment, %2F into a separator and decode %25xx a second time upstream.
raw_path = (scope.get("raw_path") or scope["path"].encode()).split(b"?", 1)[0].decode("latin-1")
url = gs.upstream_origin + raw_path + (f"?{query}" if query else "")
if gs.client is None:
gs.client = self.upstream_factory(gs)
try:
request = gs.client.build_request(method, url, headers=out_headers, content=body)
except httpx.InvalidURL:
return await self._respond(send, 400, _FORBIDDEN) # e.g. control characters in the path
try:
response = await gs.client.send(request, stream=True)
except (httpx.HTTPError, ssl.SSLError, OSError) as exc:
logger.warning("Upstream request failed (%s)", type(exc).__name__)
return await self._respond(send, 502, _UNAVAILABLE)
try:
self._store_cookies(gs, response)
if login and not (300 <= response.status_code < 400):
self.limiter.record_failure(gs.upstream_origin)
out = []
for name, value in response.headers.multi_items():
lname = name.lower()
if lname in _FORWARD_RESPONSE_HEADERS:
out.append((lname.encode("latin-1"), value.encode("latin-1")))
if "location" in response.headers:
target = rewrite_location(response.headers["location"], gs.upstream_origin)
if target is None:
await response.aclose()
return await self._respond(send, 502, _BAD_REDIRECT)
out.append((b"location", target.encode("latin-1")))
await send({"type": "http.response.start", "status": response.status_code, "headers": out + _SECURITY_HEADERS})
async for chunk in response.aiter_raw():
await send({"type": "http.response.body", "body": chunk, "more_body": True})
await send({"type": "http.response.body", "body": b""})
return response.status_code
finally:
await response.aclose()
def create_app(transport: httpx.AsyncBaseTransport | None = None) -> GuiGateway:
token = os.environ.get("GUI_GATEWAY_TOKEN", "")
if len(token) < 32:
raise SystemExit("GUI_GATEWAY_TOKEN (at least 32 characters) is required")
slots = slot_origins_from(os.environ.get("OPNEXUS_GUI_SITES", ""))
if not slots:
raise SystemExit("OPNEXUS_GUI_SITES must list the https slot origins")
backend = BackendClient(os.environ.get("GUI_BACKEND_URL", "http://backend:8000"), token, transport)
return GuiGateway(backend=backend, slot_origins=slots)
def main() -> None:
import uvicorn
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
# h11: an absolute-form target stays visible in scope["path"] (and is refused); access_log off because
# uvicorn's access log would contain query strings (the redeem ticket).
uvicorn.run(create_app(), host="0.0.0.0", port=LISTEN_PORT, http="h11", ws="none", access_log=False,
server_header=False, date_header=False, proxy_headers=False, lifespan="on", log_config=None)
if __name__ == "__main__":
main()
+191 -4
View File
@@ -5,8 +5,12 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.openapi.utils import get_openapi
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import DBAPIError
from app.errors import ErrorCode
from app import models # noqa: F401 - registers models with Base.metadata
from app.auth_middleware import AuthMiddleware
@@ -14,7 +18,7 @@ from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddle
from app.config import settings
from app.database import SessionLocal
from app import migrate
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router, audit_export as audit_export_router, backups as backups_router, oidc as oidc_router, governance as governance_router, overlay as overlay_router, variables as variables_router
from app.routers import one_to_one
from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments
from app.routers import outbound_nat
@@ -33,16 +37,31 @@ from app.routers import ipsec as ipsec_router
from app.routers import openvpn as openvpn_router
from app.routers import wireguard as wireguard_router
from app.routers import certificates as certificates_router
from app.routers import network as network_router
from app.routers import snapshots as snapshots_router
from app.routers import network_drift as network_drift_router
from app.routers import diagnostics as diagnostics_router
from app.routers import restore as restore_router
from app.version import APP_VERSION
from app.routers import dashboard as dashboard_router
from app.routers import cluster as cluster_router
from app.routers import services as services_router
from app.routers import gateways as gateways_router
from app.routers import resource_samples as resource_samples_router
from app.services import resource_monitor_service
from app.routers import scheduled_maintenance as scheduled_maintenance_router
from app.routers import maintenance_windows as maintenance_windows_router
from app.routers import rollouts as rollouts_router
from app.routers import rule_analysis as rule_analysis_router
from app.routers import gui_access as gui_access_router
from app.services import scheduled_maintenance_service
from app.services import rollout_engine
from app.services import compliance_service
from app.services import audit_export_service
from app.services import overlay_service, variables_service
from app.services import backup_schedule_service
from app.services import carp_event_service, carp_maintenance_window_service
from app.services import rule_history_service
# Without this, Python's root logger defaults to WARNING with no handler,
# so every logger.info() call in the 5 background loops below (CARP role
@@ -216,12 +235,16 @@ async def _resource_poll_loop():
deleted = await resource_monitor_service.prune_old_samples(db, settings.resource_sample_retention_days)
if deleted:
logger.info("Resource-Sample-Retention: %d alte Einträge gelöscht", deleted)
pruned = await rule_history_service.prune(db)
if pruned:
logger.info("Regelverlauf-Retention: %d alte Einträge gelöscht", pruned)
except Exception:
logger.exception("Resource-Poll fehlgeschlagen")
await asyncio.sleep(settings.resource_poll_interval_seconds)
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
_RUNNING_MAINTENANCE_POLL_SECONDS = 15
async def _scheduled_maintenance_poll_loop():
@@ -237,16 +260,104 @@ async def _scheduled_maintenance_poll_loop():
"Geplante Wartung: %d fällig, %d ausgeführt, %d blockiert/fehlgeschlagen",
result["due"], result["fired"], result["blocked_or_failed"],
)
in_progress = await scheduled_maintenance_service.advance_running(db)
except Exception:
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
await asyncio.sleep(_SCHEDULED_MAINTENANCE_POLL_SECONDS)
in_progress = 0
# Firmware rollouts (1.25) only ever create schedules; the next tick of the lines above fires them.
try:
async with SessionLocal() as db:
in_progress += await rollout_engine.advance_runs(db)
except Exception:
logger.exception("Rollout-Poll fehlgeschlagen")
# A handover run is stepped quickly (the partner should take over within a minute or two);
# otherwise the relaxed cadence is plenty.
await asyncio.sleep(_RUNNING_MAINTENANCE_POLL_SECONDS if in_progress else _SCHEDULED_MAINTENANCE_POLL_SECONDS)
_COMPLIANCE_FIRST_RUN_SECONDS = 120
_COMPLIANCE_INTERVAL_SECONDS = 6 * 3600
async def _compliance_poll_loop():
"""Sixth background loop: refreshes every enabled host's security-posture result.
Each check is one read-only config download per firewall, so every six hours
is plenty; the first run waits a little so a restart is not a thundering herd."""
await asyncio.sleep(_COMPLIANCE_FIRST_RUN_SECONDS)
while True:
try:
async with SessionLocal() as db:
await compliance_service.check_all(db)
except Exception:
logger.exception("Sicherheitslage-Prüfung fehlgeschlagen")
await asyncio.sleep(_COMPLIANCE_INTERVAL_SECONDS)
_AUDIT_EXPORT_POLL_SECONDS = 10
async def _audit_export_poll_loop():
"""Seventh background loop: forwards new audit events (syslog / webhook) when configured.
Cheap when off (one settings read); keeps draining while batches come back full."""
while True:
shipped = 0
try:
async with SessionLocal() as db:
shipped = await audit_export_service.run_once(db)
except Exception:
logger.exception("Audit-Export fehlgeschlagen")
await asyncio.sleep(0 if shipped >= audit_export_service.BATCH else _AUDIT_EXPORT_POLL_SECONDS)
_BACKUP_POLL_SECONDS = 15 * 60
async def _backup_poll_loop():
"""Eighth background loop: takes due scheduled config backups, prunes, copies offsite."""
await asyncio.sleep(90)
while True:
try:
async with SessionLocal() as db:
result = await backup_schedule_service.run_due(db)
if result["taken"] or result["failed"] or result["uploaded"]:
logger.info("Geplante Backups: %s", result)
except Exception:
logger.exception("Geplante Backups fehlgeschlagen")
await asyncio.sleep(_BACKUP_POLL_SECONDS)
_RULE_OBSERVE_FIRST_RUN_SECONDS = 150
async def _rule_observe_loop():
"""Ninth background loop: reads the rules and their counters of every enabled firewall (two GETs each),
keeps the usage sampling current and records what changed since the last look (services/rule_history_service.py).
Writes only to the database. The first look at a firewall only stores the starting point."""
await asyncio.sleep(_RULE_OBSERVE_FIRST_RUN_SECONDS)
while True:
try:
async with SessionLocal() as db:
result = await rule_history_service.observe_all(db)
if result["events"] or result["failed"]:
logger.info("Regelverlauf: %s", result)
except Exception:
logger.exception("Regelverlauf fehlgeschlagen")
await asyncio.sleep(max(settings.rule_observe_interval_seconds, settings.rule_observe_min_interval_seconds))
@asynccontextmanager
async def lifespan(app: FastAPI):
conflicts = settings.gui_origin_conflicts()
if conflicts:
# A GUI-gateway slot must never be a trusted OPNexus origin (CORS with credentials).
raise RuntimeError(f"OPNEXUS_GUI_SITES overlaps FRONTEND_ORIGIN: {', '.join(conflicts)} — refusing to start")
await migrate.run()
async with SessionLocal() as db:
await auth_service.bootstrap_admin(db)
try:
await overlay_service.sync(db) # hub config + proxy rule follow the database after every start
except Exception:
logger.exception("Overlay-Konfiguration konnte nicht geschrieben werden")
if any(o.startswith("https://") for o in settings.allowed_origins) and not settings.cookie_secure:
logger.warning(
"FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie "
@@ -260,6 +371,10 @@ async def lifespan(app: FastAPI):
log_ingest_task = asyncio.create_task(_log_ingest_poll_loop())
resource_task = asyncio.create_task(_resource_poll_loop())
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
compliance_task = asyncio.create_task(_compliance_poll_loop())
audit_export_task = asyncio.create_task(_audit_export_poll_loop())
backup_task = asyncio.create_task(_backup_poll_loop())
rule_observe_task = asyncio.create_task(_rule_observe_loop())
try:
yield
finally:
@@ -268,14 +383,47 @@ async def lifespan(app: FastAPI):
log_ingest_task.cancel()
resource_task.cancel()
scheduled_maintenance_task.cancel()
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task):
compliance_task.cancel()
audit_export_task.cancel()
backup_task.cancel()
rule_observe_task.cancel()
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task, audit_export_task, backup_task, rule_observe_task):
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="OPNexus", version=APP_VERSION, lifespan=lifespan)
API_DESCRIPTION = (
"REST API of OPNexus, the central management for OPNsense firewall fleets.\n\n"
"**Authentication:** send a personal API token as `Authorization: Bearer opx_...` "
"(create one under *System > API tokens*). Scopes: `read` (GET only), `deploy` "
"(+ roll out rules, NAT, VPN, maintenance), `admin` (+ users, settings, hosts). "
"A token never exceeds its owner's role. Token and account management is session-only.\n\n"
"Errors are `{\"detail\": {\"code\": \"...\", \"params\": {...}}}` with a stable `code`."
)
# The interactive Swagger/ReDoc pages load scripts from a CDN, which the proxy's
# Content-Security-Policy forbids; the schema itself is served (behind auth) for
# any client that renders it (Postman, openapi-generator, an AI assistant).
app = FastAPI(
title="OPNexus", version=APP_VERSION, lifespan=lifespan, description=API_DESCRIPTION,
docs_url=None, redoc_url=None, openapi_url="/api/openapi.json",
)
def _custom_openapi():
if app.openapi_schema is None:
schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes)
schema.setdefault("components", {}).setdefault("securitySchemes", {})["bearerAuth"] = {
"type": "http", "scheme": "bearer", "description": "Personal API token (opx_...)",
}
schema["security"] = [{"bearerAuth": []}]
app.openapi_schema = schema
return app.openapi_schema
app.openapi = _custom_openapi
@app.exception_handler(RequestValidationError)
async def _safe_validation_error(request: Request, exc: RequestValidationError):
@@ -289,6 +437,25 @@ async def _safe_validation_error(request: Request, exc: RequestValidationError):
)
@app.exception_handler(variables_service.MissingVariables)
async def _missing_variables(request: Request, exc: variables_service.MissingVariables):
"""A deploy that needs a value no target host has defined is refused before anything is written."""
return JSONResponse(status_code=422, content={"detail": {"code": ErrorCode.VARIABLES_MISSING.value, "params": {"details": str(exc)}}})
@app.exception_handler(DBAPIError)
async def _database_text_errors(request: Request, exc: DBAPIError):
"""A NUL byte in any text field cannot be stored by Postgres and used to
surface as a 500 plus a dropped connection (found by fuzzing). That is bad
input, not a server fault. Anything else stays a normal 500."""
if type(exc.orig).__name__.endswith("CharacterNotInRepertoireError") or "0x00" in str(exc.orig):
return JSONResponse(
status_code=422,
content={"detail": {"code": ErrorCode.INVALID_CHARACTERS.value, "params": {}}},
)
raise exc
app.add_middleware(AuthMiddleware)
# Inside CORS so preflights are answered first; outside Auth so a forged
# cross-site write is rejected before any session lookup happens.
@@ -322,6 +489,16 @@ app.include_router(hosts.router, prefix="/api")
app.include_router(rules.router, prefix="/api")
app.include_router(search_router.router, prefix="/api")
app.include_router(users_router.router, prefix="/api")
app.include_router(tokens_router.router, prefix="/api")
app.include_router(metrics_router.router, prefix="/api")
app.include_router(mcp_router.router, prefix="/api")
app.include_router(compliance_router.router, prefix="/api")
app.include_router(audit_export_router.router, prefix="/api")
app.include_router(backups_router.router, prefix="/api")
app.include_router(oidc_router.router, prefix="/api")
app.include_router(governance_router.router, prefix="/api")
app.include_router(overlay_router.router, prefix="/api")
app.include_router(variables_router.router, prefix="/api")
app.include_router(logs.router, prefix="/api")
app.include_router(groups.router, prefix="/api")
app.include_router(aliases.router, prefix="/api")
@@ -374,5 +551,15 @@ app.include_router(restore_router.router, prefix="/api")
app.include_router(dashboard_router.router, prefix="/api")
app.include_router(cluster_router.router, prefix="/api")
app.include_router(services_router.router, prefix="/api")
app.include_router(gateways_router.router, prefix="/api")
app.include_router(network_router.router, prefix="/api")
app.include_router(snapshots_router.router, prefix="/api")
app.include_router(network_drift_router.router, prefix="/api")
app.include_router(diagnostics_router.router, prefix="/api")
app.include_router(resource_samples_router.router, prefix="/api")
app.include_router(scheduled_maintenance_router.router, prefix="/api")
app.include_router(maintenance_windows_router.router, prefix="/api")
app.include_router(rollouts_router.router, prefix="/api")
app.include_router(rule_analysis_router.router, prefix="/api")
app.include_router(gui_access_router.router, prefix="/api")
app.include_router(gui_access_router.internal_router)
+12
View File
@@ -93,3 +93,15 @@ __all__ += ["HostResourceSample"]
from app.models.scheduled_maintenance import ScheduledMaintenance
__all__ += ["ScheduledMaintenance"]
from app.models.maintenance_window import MaintenanceWindow, MaintenanceWindowException
__all__ += ["MaintenanceWindow", "MaintenanceWindowException"]
from app.models.rollout import RolloutPlan, RolloutPlanWave, RolloutRun, RolloutRunHost, RolloutRunWave, rollout_wave_groups
__all__ += ["RolloutPlan", "RolloutPlanWave", "RolloutRun", "RolloutRunHost", "RolloutRunWave", "rollout_wave_groups"]
from app.models.gui_access import GuiAccessSettings, GuiSession, GuiTicket
__all__ += ["GuiAccessSettings", "GuiSession", "GuiTicket"]
from app.models.rule_history import LiveRuleState, RuleChangeEvent
__all__ += ["LiveRuleState", "RuleChangeEvent"]
+38
View File
@@ -0,0 +1,38 @@
"""Personal API tokens for scripts, CI jobs, Prometheus and MCP clients.
Only the SHA-256 of the token is stored (the token is 256 random bits, so a
fast hash is enough and lets a request be resolved with one indexed lookup);
the raw value is shown exactly once, at creation. A token belongs to a user and
never exceeds that user's role: see services/api_token_service.py for the
scopes and AuthMiddleware for where they are enforced.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ApiToken(Base):
__tablename__ = "api_tokens"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False)
# "read" | "deploy" | "admin"
scope: Mapped[str] = mapped_column(String(10), nullable=False)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
# First characters of the token, so a list can tell tokens apart. Not secret.
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user: Mapped["AdminUser"] = relationship("AdminUser")
+33
View File
@@ -0,0 +1,33 @@
"""Forwarding of the audit trail to a syslog server and/or a webhook (singleton row).
``cursor_*`` is the position of the last shipped event, so delivery resumes where
it stopped (at-least-once). Enabling a channel starts at "now": the existing history
is not replayed. The webhook signing secret lives on disk, like every other secret
(services/notification_secret_store.py).
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AuditExportSettings(Base):
__tablename__ = "audit_export_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
syslog_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
syslog_host: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
syslog_port: Mapped[int] = mapped_column(Integer, nullable=False, default=514)
# "udp" | "tcp" | "tls"
syslog_protocol: Mapped[str] = mapped_column(String(3), nullable=False, default="udp")
webhook_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
webhook_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
webhook_signing_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
cursor_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
cursor_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
last_success_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
last_error_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+48
View File
@@ -0,0 +1,48 @@
"""Scheduled config backups and their offsite copy (singleton row + upload ledger).
The S3 secret key and the encryption passphrase live on disk (notification_secret_store,
names ``backup_s3_secret`` / ``backup_passphrase``), never here.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class BackupSettings(Base):
__tablename__ = "backup_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
scheduled_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
interval_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=24)
keep_count: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
offsite_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
s3_endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
s3_region: Mapped[str] = mapped_column(String(64), nullable=False, default="us-east-1")
s3_bucket: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
s3_prefix: Mapped[str] = mapped_column(String(255), nullable=False, default="opnexus")
s3_access_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
s3_path_style: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
s3_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
passphrase_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_offsite_ok_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_offsite_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
class BackupUpload(Base):
"""Which local snapshot already exists offsite (so nothing is uploaded twice)."""
__tablename__ = "backup_uploads"
__table_args__ = (UniqueConstraint("host_id", "snapshot_id", name="uq_backup_upload"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
snapshot_id: Mapped[str] = mapped_column(String(64), nullable=False)
remote_key: Mapped[str] = mapped_column(String(400), nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+31
View File
@@ -0,0 +1,31 @@
"""Latest security-posture result per host (see services/compliance_service.py).
One row per host, replaced on every check. The findings never contain
configuration values beyond rule descriptions and the protocol names they
report on; secrets from config.xml are never read into them.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ComplianceResult(Base):
__tablename__ = "compliance_results"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
host_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
)
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# None when the check could not run (see error).
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
findings: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# None | "unreachable" | "invalid_config"
error: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
error_detail: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
+13
View File
@@ -0,0 +1,13 @@
"""Separation of duties (singleton row): may operators roll changes out themselves?"""
from sqlalchemy import Boolean, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class GovernanceSettings(Base):
__tablename__ = "governance_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# False: an operator prepares changes (edit, preview) but only an admin may deploy them.
operators_may_deploy: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+65
View File
@@ -0,0 +1,65 @@
"""GUI access to managed firewalls through the separate gui-gateway container (1.27).
Settings are a singleton row (id fixed at 1), off by default. Tickets and gateway
sessions only ever store hashes: the raw ticket travels once through the browser,
the OPNexus session is referenced by the same SHA-256 that `sessions.token_hash` holds.
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class GuiAccessSettings(Base):
__tablename__ = "gui_access_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
idle_timeout_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15, server_default="15")
absolute_timeout_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=60, server_default="60")
slot_count: Mapped[int] = mapped_column(Integer, nullable=False, default=4, server_default="4")
updated_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class GuiTicket(Base):
"""One-time, 30-second ticket, bound to the OPNexus session, host and slot."""
__tablename__ = "gui_tickets"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
session_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
)
host_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, index=True
)
slot: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
redeemed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class GuiSession(Base):
"""A redeemed ticket: one firewall GUI open in one slot of one browser session."""
__tablename__ = "gui_sessions"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
)
host_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, index=True
)
slot: Mapped[int] = mapped_column(Integer, nullable=False)
client_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
end_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
+4
View File
@@ -44,3 +44,7 @@ class OpnsenseHost(Base):
policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship(
"PolicyHierarchyGroup", back_populates="hosts"
)
# Values for {{NAME}} placeholders (services/variables_service.py); always loaded with the host.
variables: Mapped[list["HostVariable"]] = relationship(
"HostVariable", lazy="selectin", cascade="all, delete-orphan", order_by="HostVariable.name"
)
+18
View File
@@ -0,0 +1,18 @@
"""Per-host values for ``{{NAME}}`` placeholders in centrally authored objects (see services/variables_service.py)."""
import uuid
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class HostVariable(Base):
__tablename__ = "host_variables"
__table_args__ = (UniqueConstraint("host_id", "name", name="uq_host_variable"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(41), nullable=False)
value: Mapped[str] = mapped_column(String(255), nullable=False)
+65
View File
@@ -0,0 +1,65 @@
"""Recurring maintenance windows (1.25): "every Sunday 02:00-05:00 Europe/Berlin",
"first Tuesday of the month 22:00 for 3 h". A window on its own changes nothing on any
firewall; rollout plans (models/rollout.py) use it to decide WHEN they may start work on a host.
Times are wall-clock times in the window's own IANA time zone, so a window stays at 02:00
local time across daylight-saving changes (see services/window_service.py for the rules).
"""
import uuid
from datetime import date, datetime, time, timezone
from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, String, Time, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class MaintenanceWindow(Base):
__tablename__ = "maintenance_windows"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(120), unique=True)
timezone: Mapped[str] = mapped_column(String(64))
recurrence: Mapped[str] = mapped_column(String(16)) # weekly | monthly
# weekly: weekdays 0 (Monday) .. 6 (Sunday). JSON columns get a Python default only (Alembic gotcha, see 0007).
weekdays: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# monthly: "nth_weekday" (month_nth 1-4 or -1 = last, month_weekday 0-6) or "day" (month_day 1-28)
month_mode: Mapped[str | None] = mapped_column(String(16), nullable=True)
month_nth: Mapped[int | None] = mapped_column(Integer, nullable=True)
month_weekday: Mapped[int | None] = mapped_column(Integer, nullable=True)
month_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
start_time: Mapped[time] = mapped_column(Time)
duration_minutes: Mapped[int] = mapped_column(Integer)
# After this many minutes into the window no NEW host action starts (running ones finish).
start_cutoff_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=60, server_default="60")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_by: Mapped[str] = mapped_column(String(150))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
exceptions: Mapped[list["MaintenanceWindowException"]] = relationship(
"MaintenanceWindowException", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin",
order_by="MaintenanceWindowException.occurrence_date",
)
class MaintenanceWindowException(Base):
"""One occurrence skipped or moved. Keyed by the occurrence's LOCAL date."""
__tablename__ = "maintenance_window_exceptions"
__table_args__ = (UniqueConstraint("window_id", "occurrence_date", name="uq_window_exception_date"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
window_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("maintenance_windows.id", ondelete="CASCADE"), index=True
)
occurrence_date: Mapped[date] = mapped_column(Date)
kind: Mapped[str] = mapped_column(String(8)) # skip | move
moved_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reason: Mapped[str] = mapped_column(String(300), default="")
actor: Mapped[str] = mapped_column(String(150))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+60 -2
View File
@@ -6,7 +6,7 @@ OPNsense API secrets stay off the database entirely.
import uuid
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, UniqueConstraint
from sqlalchemy import JSON, Boolean, DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -17,6 +17,13 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
DEFAULT_SERVICE_WATCHLIST = (
"unbound", "dnsmasq", "dhcpd", "dhcpd6", "kea-dhcp4", "kea-dhcp6", "ntpd", "openvpn", "wireguard", "strongswan",
)
CHANNEL_TYPES = ("ntfy", "gotify", "telegram", "teams", "matrix")
class NotificationSettings(Base):
"""Single row, id fixed at 1 — see notification_settings_service.get_settings
for the get-or-create helper. The only admin-editable operational config
@@ -48,7 +55,40 @@ class NotificationSettings(Base):
maintenance_unknown_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
carp_health_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
nat_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
backup_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
# Off by default: switching it on for an existing fleet reports every long-standing finding at once.
compliance_alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Monitoring alerts (1.24). The gateway alert is on by default (a WAN outage is the one thing every
# operator wants to hear about); resource thresholds and service checks are off, like compliance alerts:
# switching them on for an existing fleet may report long-standing conditions all at once.
gateway_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
resource_threshold_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
service_down_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# An API key that cannot read gateway / service state makes those alerts silently blind (1.29): say so.
api_rights_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
# Gateways whose alert is switched off, "<host_id>:<gateway name>" (1.29), e.g. an idle second WAN.
gateway_alert_muted: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# Rollouts (1.25): a run paused (gate, failure, window...) or an automatic start did not happen.
rollout_problems_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
# Gateway: OPNsense' own verdict (down / loss / delay) decides "offline"; these two add an
# OPNexus threshold on the measured values (0 = off).
gateway_loss_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=20, server_default="20")
gateway_delay_threshold_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=500, server_default="500")
# Resources (0 = that threshold is off). Load is per CPU thread.
disk_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=90, server_default="90")
memory_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=95, server_default="95")
swap_threshold_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=50, server_default="50")
load_per_cpu_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=2.0, server_default="2.0")
# Debouncing: consecutive 60 s resource samples that must agree before a problem opens or closes.
alert_confirm_samples: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3")
resource_confirm_samples: Mapped[int] = mapped_column(Integer, nullable=False, default=5, server_default="5")
# OPNsense service names that are reported when they stop (the API cannot tell a crash from a stop on purpose).
# NULL = the default list (an old row never got one).
service_watchlist: Mapped[list | None] = mapped_column(JSON, nullable=True, default=lambda: list(DEFAULT_SERVICE_WATCHLIST))
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@@ -70,7 +110,7 @@ class NotificationProblem(Base):
__table_args__ = (UniqueConstraint("problem_type", "dedup_key", name="uq_notification_problem_key"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown | ... (see ProblemType)
problem_type: Mapped[str] = mapped_column(String(32))
dedup_key: Mapped[str] = mapped_column(String(300))
status: Mapped[str] = mapped_column(String(16), default="open") # open | resolved
@@ -80,3 +120,21 @@ class NotificationProblem(Base):
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_notified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class NotificationChannel(Base):
"""An additional delivery channel (ntfy, Gotify, Telegram, Microsoft Teams, Matrix) next to the SMTP
and generic-webhook settings above. Several channels of one type are allowed. ``config`` holds only
non-secret parameters (server URL, topic, chat id, room id); the credential — token, or the whole
webhook URL for Teams — lives in notification_secret_store under ``channel_<id>``, never here."""
__tablename__ = "notification_channels"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), unique=True)
channel_type: Mapped[str] = mapped_column(String(16))
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
+42
View File
@@ -0,0 +1,42 @@
"""Single sign-on via OpenID Connect: settings (singleton) and in-flight login attempts.
The client secret lives on disk (notification_secret_store, name ``oidc_client_secret``), not here.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class OidcSettings(Base):
__tablename__ = "oidc_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
display_name: Mapped[str] = mapped_column(String(60), nullable=False, default="SSO")
issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
client_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="openid profile email")
groups_claim: Mapped[str] = mapped_column(String(64), nullable=False, default="groups")
admin_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
operator_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
read_only_group: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
class OidcLoginState(Base):
"""One row per started login; consumed (deleted) by the callback, expires after minutes."""
__tablename__ = "oidc_login_states"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
state_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+39
View File
@@ -0,0 +1,39 @@
"""Management overlay: a WireGuard hub so OPNexus can reach firewalls behind NAT/CGNAT.
The firewall dials OUT to the hub; OPNexus then talks to its API at the firewall's overlay address. Keys live
on disk (services/overlay_service.py), never in the database.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class OverlaySettings(Base):
__tablename__ = "overlay_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# host or host:port that firewalls dial (public name/IP of this server and the published UDP port)
endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
subnet: Mapped[str] = mapped_column(String(18), nullable=False, default="10.99.0.0/24")
listen_port: Mapped[int] = mapped_column(Integer, nullable=False, default=51820)
class OverlayPeer(Base):
__tablename__ = "overlay_peers"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
ip: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
# the firewall's WireGuard public key, entered once the firewall side exists
public_key: Mapped[Optional[str]] = mapped_column(String(44), nullable=True, unique=True)
host_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+4 -1
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -9,3 +9,6 @@ class UserPreferences(Base):
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
theme: Mapped[str] = mapped_column(String(10), default='system')
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
# Set once the user finished or dismissed the first-run setup assistant
# (frontend /setup); it is then no longer offered automatically.
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
+152
View File
@@ -0,0 +1,152 @@
"""Firmware rollouts in waves (1.25): plans (what, in which order, when) and runs (what happened).
A plan's waves point at device groups ("rings": canary -> pilot -> prod). A run freezes the plan
(``plan_snapshot``) and the resolved hosts (``RolloutRunHost``) when it starts, so later edits of
the plan or of a group never change a run that is under way. The run never fires firmware actions
itself: every host step is an ordinary ScheduledMaintenance row (origin="rollout",
auto_verify=True) linked from ``RolloutRunHost.scheduled_maintenance_id`` — see
services/rollout_service.py.
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import (JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text,
UniqueConstraint)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
rollout_wave_groups = Table(
"rollout_wave_groups", Base.metadata,
Column("wave_id", PGUUID(as_uuid=True), ForeignKey("rollout_plan_waves.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", PGUUID(as_uuid=True), ForeignKey("device_groups.id", ondelete="CASCADE"), primary_key=True),
)
class RolloutPlan(Base):
__tablename__ = "rollout_plans"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(120), unique=True)
description: Mapped[str] = mapped_column(Text, default="")
action: Mapped[str] = mapped_column(String(16)) # update | major_update
window_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("maintenance_windows.id", ondelete="SET NULL"), nullable=True, index=True)
# Start a run by itself at every occurrence of the window. Only for "update" and only while the plan
# is activated AND unchanged since (activated_fingerprint).
auto_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
gate_checks: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
compliance_mode: Mapped[str] = mapped_column(String(8), nullable=False, default="warn", server_default="warn")
activated_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
# Stable id of that account (1.29, no foreign key on purpose: a deleted account stays gone, a new one with the same name is someone else).
activated_by_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
activated_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Why the last automatic start did not happen: {"occurrence", "code", "params", "at"} (notification source).
auto_start_error: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[str] = mapped_column(String(150))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
waves: Mapped[list["RolloutPlanWave"]] = relationship(
"RolloutPlanWave", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin",
order_by="RolloutPlanWave.position")
class RolloutPlanWave(Base):
__tablename__ = "rollout_plan_waves"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("rollout_plans.id", ondelete="CASCADE"), index=True)
position: Mapped[int] = mapped_column(Integer)
label: Mapped[str] = mapped_column(String(60))
soak_minutes: Mapped[int] = mapped_column(Integer, default=30)
carp_handover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Wait for a human "release" before this wave starts (not used for the first wave: starting is the release).
require_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", secondary=rollout_wave_groups, lazy="selectin") # noqa: F821
class RolloutRun(Base):
__tablename__ = "rollout_runs"
__table_args__ = (UniqueConstraint("plan_id", "window_occurrence", name="uq_rollout_run_occurrence"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("rollout_plans.id", ondelete="SET NULL"), nullable=True, index=True)
plan_name: Mapped[str] = mapped_column(String(120))
plan_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
action: Mapped[str] = mapped_column(String(16))
# waiting_window | preflight | wave_running | soaking | gate | awaiting_approval | resuming | paused
# | succeeded | cancelled
status: Mapped[str] = mapped_column(String(20), index=True)
paused_from: Mapped[str | None] = mapped_column(String(20), nullable=True)
current_wave: Mapped[int] = mapped_column(Integer, default=0)
target_version: Mapped[str | None] = mapped_column(String(40), nullable=True)
pause_reason: Mapped[str | None] = mapped_column(String(40), nullable=True)
pause_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
started_by: Mapped[str] = mapped_column(String(150))
started_by_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True) # see activated_by_id
trigger: Mapped[str] = mapped_column(String(8)) # manual | window
window_occurrence: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Optimistic lock: every state change bumps it; an engine step computed on an older revision is dropped.
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
progress: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
step_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
waves: Mapped[list["RolloutRunWave"]] = relationship(
"RolloutRunWave", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin", order_by="RolloutRunWave.index")
hosts: Mapped[list["RolloutRunHost"]] = relationship(
"RolloutRunHost", cascade="all, delete-orphan", passive_deletes=True, lazy="selectin",
order_by=lambda: [RolloutRunHost.wave_index, RolloutRunHost.order])
class RolloutRunWave(Base):
__tablename__ = "rollout_run_waves"
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
run_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("rollout_runs.id", ondelete="CASCADE"), index=True)
index: Mapped[int] = mapped_column(Integer)
label: Mapped[str] = mapped_column(String(60))
# pending | awaiting_approval | preflight | running | soaking | gate | passed | skipped | cancelled
status: Mapped[str] = mapped_column(String(20), default="pending")
baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True)
gate_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
soak_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
gate_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
approved_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class RolloutRunHost(Base):
__tablename__ = "rollout_run_hosts"
__table_args__ = (UniqueConstraint("run_id", "host_id", name="uq_rollout_run_host"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
run_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("rollout_runs.id", ondelete="CASCADE"), index=True)
wave_index: Mapped[int] = mapped_column(Integer)
host_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="SET NULL"), nullable=True, index=True)
host_name: Mapped[str] = mapped_column(String(120))
cluster_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
order: Mapped[int] = mapped_column(Integer, default=0)
# queued | running | verifying | succeeded | skipped | failed | blocked | cancelled
status: Mapped[str] = mapped_column(String(16), default="queued")
scheduled_maintenance_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("scheduled_maintenance.id", ondelete="SET NULL"), nullable=True, index=True)
version_before: Mapped[str | None] = mapped_column(String(40), nullable=True)
version_after: Mapped[str | None] = mapped_column(String(40), nullable=True)
# {"code": ..., "params": {...}} — translated in the UI (rollouts.hostNote.*)
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy import Boolean, DateTime, String, false
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -29,6 +29,7 @@ class FirewallRule(Base):
destination_net: Mapped[str] = mapped_column(String(128), default="any")
destination_port: Mapped[str | None] = mapped_column(String(32), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
log: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+59
View File
@@ -0,0 +1,59 @@
"""Change history of the rules that are live on a firewall (all rules, also hand-made ones).
``live_rule_states`` is the last observed state per rule (a fingerprint plus the row itself), maintained by the
``rule_observe`` loop; ``rule_change_events`` is what changed between two observations. See
services/rule_history_service.py. Neither table holds anything the firewall itself would not show on its rule page.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class LiveRuleState(Base):
__tablename__ = "live_rule_states"
host_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), primary_key=True)
# The rule's UUID/label on the firewall; BASELINE_KEY marks "this host has been observed once".
rule_uuid: Mapped[str] = mapped_column(String(64), primary_key=True)
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
row: Mapped[dict] = mapped_column(JSON, nullable=False)
# False = the rule is gone (kept as a marker so a returning UUID continues the numbering).
present: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Counts the observed changes of this rule; the event of a change gets the new number, which makes the
# observation idempotent when two processes see the same change.
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class RuleChangeEvent(Base):
__tablename__ = "rule_change_events"
__table_args__ = (
UniqueConstraint("host_id", "rule_uuid", "seq", name="uq_rule_change_event"),
Index("ix_rule_change_events_rule", "host_id", "rule_uuid", "observed_at"),
)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
host_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False)
rule_uuid: Mapped[str] = mapped_column(String(64), nullable=False)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False) # added | changed | removed | enabled | disabled
before: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
after: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
origin: Mapped[str] = mapped_column(String(16), nullable=False) # opnexus | external
actor: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
# The audit entry that explains an OPNexus-made change; no foreign key, the audit trail outlives everything.
audit_id: Mapped[Optional[uuid.UUID]] = mapped_column(PGUUID(as_uuid=True), nullable=True)
# Start of the time since the previous observation of the host: only audit entries from then on can explain
# this change (a later reconcile must not reach back to an older deployment).
window_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+32
View File
@@ -0,0 +1,32 @@
"""When did a firewall rule last match something? (per host and rule UUID)
pf's own counters restart whenever the ruleset is reloaded (every apply, every reboot), so
"packets = 0" alone proves nothing. OPNexus samples them periodically and keeps the time of the
last observed hit, which survives those resets. See services/rule_hit_service.py.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class RuleHit(Base):
__tablename__ = "rule_hits"
__table_args__ = (UniqueConstraint("host_id", "rule_uuid", name="uq_rule_hit"),)
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
rule_uuid: Mapped[str] = mapped_column(String(36), nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=False, default="")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_sampled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# None = never seen a hit since OPNexus started watching.
last_hit_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_packets: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
last_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+13 -1
View File
@@ -13,7 +13,7 @@ table only tracks the *schedule* itself, not the resulting operation.
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -40,3 +40,15 @@ class ScheduledMaintenance(Base):
# Set to the resulting HostOperation's id once fired, so the UI can
# link from the schedule to the operation it produced.
host_operation_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
# Opt-in: hand the traffic over to the CARP partner first, run the action, then switch back.
carp_handover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# 1.25: check the result automatically (job_wait -> verify) and close the operation, instead of
# leaving it "submitted" for a human. Every rollout step sets it; off for existing plans.
auto_verify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# manual | rollout (created by a rollout run, see models/rollout.py RolloutRunHost.scheduled_maintenance_id)
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="manual", server_default="manual")
# Only while status == "running" with a handover or auto_verify: handover_wait | job_wait | verify | leave | leave_wait.
# State lives here, not in memory, so a backend restart in the middle of a run resumes cleanly.
phase: Mapped[str | None] = mapped_column(String(16), nullable=True)
phase_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
progress: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
@@ -0,0 +1,18 @@
"""Version check configured in the web UI (singleton row, id fixed at 1).
Off by default: OPNexus makes no outgoing request until an admin switches it on. An
UPDATE_CHECK_URL in the server's .env still wins over this row (server-managed)."""
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
DEFAULT_FEED_URL = "https://updates.opnexus.dev/latest.json"
class UpdateCheckSettings(Base):
__tablename__ = "update_check_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
feed_url: Mapped[str] = mapped_column(String(500), nullable=False, default=DEFAULT_FEED_URL, server_default=DEFAULT_FEED_URL)
+4
View File
@@ -32,6 +32,10 @@ class AdminUser(Base):
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
totp_last_step: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
totp_recovery_hashes: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
# "local" (password) or "oidc" (single sign-on: role follows the identity provider's groups at every
# login, the password hash is an unusable placeholder). oidc_subject is "<issuer>|<sub>".
auth_source: Mapped[str] = mapped_column(String(10), nullable=False, default="local", server_default="local")
oidc_subject: Mapped[Optional[str]] = mapped_column(String(600), nullable=True, unique=True, index=True)
sessions: Mapped[list["Session"]] = relationship(
"Session", back_populates="user", cascade="all, delete-orphan"
+197
View File
@@ -0,0 +1,197 @@
"""The ``wg-hub`` container: WireGuard interface + a CONNECT proxy for the management overlay.
Runs from the backend image (``python -m app.overlay_hub``) with CAP_NET_ADMIN and the WireGuard UDP port
published. Everything it needs comes from ``<overlay_dir>/hub.json`` (written by the backend, see
services/overlay_service.py); it reports back through ``status.json``. It never talks to the database.
* WireGuard: wg0 with the hub address, peers synced with ``wg syncconf`` whenever hub.json changes. Inbound
NEW connections from the tunnel are dropped (iptables), so a compromised firewall cannot reach anything on this
container; OPNexus' own requests to the firewalls and their replies are unaffected.
* Proxy: an HTTP CONNECT proxy (port 8888, compose network only). It is not an open proxy: it only connects to
IPv4 addresses inside the overlay subnet from hub.json.
"""
import asyncio
import ipaddress
import json
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from app.config import settings
logger = logging.getLogger("overlay_hub")
INTERFACE = "wg0"
PROXY_PORT = 8888
APPLY_EVERY = 5
def run(*cmd: str, stdin: str | None = None) -> tuple[int, str]:
try:
result = subprocess.run(list(cmd), input=stdin, capture_output=True, text=True, timeout=20)
except (OSError, subprocess.SubprocessError) as exc:
return 127, str(exc)
return result.returncode, (result.stdout + result.stderr).strip()
def wg_conf(config: dict) -> str:
lines = ["[Interface]", f"PrivateKey = {config['private_key']}", f"ListenPort = {int(config['listen_port'])}"]
for peer in config.get("peers", []):
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}", f"PresharedKey = {peer['psk']}", f"AllowedIPs = {peer['ip']}/32"]
return "\n".join(lines) + "\n"
def parse_dump(text: str) -> list[dict]:
"""`wg show <if> dump`: first line is the interface, then one tab-separated line per peer."""
peers = []
for line in text.splitlines()[1:]:
f = line.split("\t")
if len(f) >= 7:
peers.append({"public_key": f[0], "endpoint": None if f[2] == "(none)" else f[2], "handshake_at": int(f[4] or 0),
"rx": int(f[5] or 0), "tx": int(f[6] or 0)})
return peers
class Hub:
def __init__(self, directory: Path, runner=run):
self.dir, self.run = directory, runner
self.subnet: ipaddress.IPv4Network | None = None
self._applied_mtime = 0.0
def read_config(self) -> dict | None:
try:
config = json.loads((self.dir / "hub.json").read_text())
return config if isinstance(config, dict) else None
except (OSError, ValueError):
return None
def apply(self, config: dict) -> str | None:
"""Bring the interface in line with the config. Returns an error text or None."""
self.subnet = ipaddress.ip_network(config["subnet"]) if config.get("enabled") and config.get("subnet") else None
exists = self.run("ip", "link", "show", INTERFACE)[0] == 0
if not config.get("enabled"):
if exists:
self.run("ip", "link", "del", INTERFACE)
return None
steps = []
if not exists:
steps.append(("ip", "link", "add", INTERFACE, "type", "wireguard"))
steps += [("ip", "address", "replace", config["address"], "dev", INTERFACE), ("ip", "link", "set", INTERFACE, "up")]
for step in steps:
code, out = self.run(*step)
if code != 0:
return f"{' '.join(step[:3])}: {out}"[:300]
conf = self.dir / ".wg0.conf"
fd = os.open(conf, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(wg_conf(config))
try:
code, out = self.run("wg", "syncconf", INTERFACE, str(conf))
finally:
conf.unlink(missing_ok=True)
if code != 0:
return f"wg syncconf: {out}"[:300]
# Nothing may start a connection INTO this container from the tunnel; replies to ours still pass.
rule = ("INPUT", "-i", INTERFACE, "-m", "conntrack", "--ctstate", "NEW", "-j", "DROP")
if self.run("iptables", "-C", *rule)[0] != 0:
code, out = self.run("iptables", "-I", *rule)
if code != 0:
return f"iptables: {out}"[:300]
# The hub only needs its own proxy: never route between peers or into the compose network.
fwd = ("FORWARD", "-j", "DROP")
if self.run("iptables", "-C", *fwd)[0] != 0:
code, out = self.run("iptables", "-I", *fwd)
if code != 0:
return f"iptables: {out}"[:300]
return None
def status(self, error: str | None) -> dict:
code, out = self.run("wg", "show", INTERFACE, "dump")
return {"updated_at": time.time(), "up": code == 0, "peers": parse_dump(out) if code == 0 else [], "error": error}
def write_status(self, error: str | None) -> None:
tmp = self.dir / "status.json.tmp"
tmp.write_text(json.dumps(self.status(error)))
os.replace(tmp, self.dir / "status.json")
def tick(self, last_error: str | None) -> str | None:
"""One round: re-apply if hub.json changed, then report. Returns the current error."""
config = self.read_config()
if config is not None:
mtime = (self.dir / "hub.json").stat().st_mtime
if mtime != self._applied_mtime or last_error:
last_error = self.apply(config)
self._applied_mtime = mtime
self.write_status(last_error)
return last_error
# ── CONNECT proxy ────────────────────────────────────────────────────
def allowed(self, host: str) -> bool:
try:
return self.subnet is not None and ipaddress.ip_address(host) in self.subnet
except ValueError:
return False
async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 10)
method, target, _ = head.split(b"\r\n", 1)[0].decode("latin-1").split(" ", 2)
host, _, port_text = target.rpartition(":")
if method != "CONNECT" or not port_text.isdigit() or not 0 < int(port_text) < 65536 or not self.allowed(host.strip("[]")):
writer.write(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
await writer.drain()
return
try:
up_reader, up_writer = await asyncio.wait_for(asyncio.open_connection(host, int(port_text)), 8)
except (OSError, asyncio.TimeoutError):
writer.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
await writer.drain()
return
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
await writer.drain()
await asyncio.gather(self._pipe(reader, up_writer), self._pipe(up_reader, writer))
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError, ValueError, OSError):
pass
finally:
writer.close()
@staticmethod
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
try:
while data := await src.read(65536):
dst.write(data)
await dst.drain()
except (OSError, asyncio.CancelledError):
pass
finally:
dst.close()
async def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
directory = Path(settings.overlay_dir)
directory.mkdir(parents=True, exist_ok=True)
hub = Hub(directory)
server = await asyncio.start_server(hub.handle_client, "0.0.0.0", PROXY_PORT)
logger.info("Overlay-Hub gestartet (Proxy auf :%d)", PROXY_PORT)
error = None
async with server:
while True:
try:
new_error = await asyncio.to_thread(hub.tick, error)
except Exception:
logger.exception("Overlay-Hub-Runde fehlgeschlagen")
new_error = "interner Fehler"
if new_error and new_error != error:
logger.warning("Overlay: %s", new_error)
error = new_error
await asyncio.sleep(APPLY_EVERY)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)
+67
View File
@@ -0,0 +1,67 @@
"""Recovery: give an account a new random password and print it once.
For a fresh install whose generated first-start password scrolled out of the
log, and for an admin who forgot theirs. Needs shell access to the server,
which is the trust boundary for this tool anyway (same as app.reset_2fa).
All sessions and API tokens of the account are ended. Normally called through the host
CLI:
./opnexus password [username]
or directly:
docker compose exec backend python -m app.reset_password [username]
Without a username the configured ADMIN_USERNAME is used; if that account does
not exist and there is exactly one admin, that one.
"""
import asyncio
import secrets
import sys
from sqlalchemy import select
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser
from app.services import api_token_service, auth_service
async def main(username: str | None) -> int:
async with SessionLocal() as db:
if username is None:
username = settings.admin_username
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
admins = (await db.execute(select(AdminUser).where(AdminUser.role == "admin"))).scalars().all()
if len(admins) != 1:
names = ", ".join(sorted(a.username for a in admins)) or "-"
print(f"No account named '{username}'. Pass the username (admins: {names}).", file=sys.stderr)
return 1
user = admins[0]
else:
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
print(f"No such user: {username}", file=sys.stderr)
return 1
password = secrets.token_urlsafe(18)
user.password_hash = await auth_service.hash_password_async(password)
await auth_service.revoke_sessions(db, user.id, commit=False)
await api_token_service.revoke_all(db, user.id, commit=False)
await db.commit()
name = user.username
has_totp = user.totp_enabled
print(f"Username: {name}")
print(f"Password: {password}")
if has_totp:
print("Two-factor sign-in is still on (lost the code? python -m app.reset_2fa).", file=sys.stderr)
return 0
if __name__ == "__main__":
if len(sys.argv) > 2:
print("usage: python -m app.reset_password [username]", file=sys.stderr)
sys.exit(2)
sys.exit(asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else None)))
+79
View File
@@ -0,0 +1,79 @@
"""The three "take over what exists on the firewall" endpoints that aliases, port forwards, 1:1 NAT and
outbound NAT share (services/object_adopt_service.py has the logic and the evidence):
GET <prefix>/unmanaged?host_id=… candidates found live on the host, with reasons for the refused ones
POST <prefix>/adopt take over one object {host_id, uuid}; returns the new central object
POST <prefix>/adopt-all take over everything adoptable {host_id}; returns adopted + skipped
Like rule adoption this only creates central records; nothing is written to the firewall."""
import json
import uuid
from typing import Callable
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.schemas.adopt import AdoptAllRequest, AdoptAllResult, AdoptObjectRequest, UnmanagedObject
from app.services import object_adopt_service
from app.services.adopt_common import LINK_ERRORS, link_api_error
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
from app.services.policy_service import policy_lock
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
def add_adopt_routes(router: APIRouter, *, kind: str, model, load_options: Callable, to_read: Callable, read_model) -> None:
@router.get("/unmanaged", response_model=list[UnmanagedObject])
async def list_unmanaged(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
try:
return await object_adopt_service.list_unmanaged(db, kind, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
@router.post("/adopt", response_model=read_model, status_code=201)
async def adopt(payload: AdoptObjectRequest, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, payload.host_id)
try:
created = await object_adopt_service.adopt(db, kind, host, payload.uuid, link_to=payload.link_to)
except object_adopt_service.NotFoundOnHost as exc:
raise api_error(404, ErrorCode.ADOPT_OBJECT_NOT_FOUND) from exc
except object_adopt_service.NotAdoptable as exc:
raise api_error(409, ErrorCode.OBJECT_NOT_ADOPTABLE, reasons=", ".join(exc.reasons),
reason_details=json.dumps(exc.details, ensure_ascii=False)) from exc
except LINK_ERRORS as exc:
raise link_api_error(exc) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
# The new object sits in the session without its relationships loaded; db.get() would return it as-is.
created = (await db.execute(
select(model).where(model.id == created.id).options(*load_options())
.execution_options(populate_existing=True)
)).scalar_one()
await hydrate_hierarchy_targets(db, [created])
return to_read(created, created.targets, created.deployments)
@router.post("/adopt-all", response_model=AdoptAllResult)
async def adopt_all(payload: AdoptAllRequest, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, payload.host_id)
try:
result = await object_adopt_service.adopt_all(db, kind, host, link_existing=payload.link_existing)
except LINK_ERRORS as exc:
raise link_api_error(exc) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
return result
+5 -1
View File
@@ -17,6 +17,7 @@ from app.schemas.alias import DeploymentResult, AliasCreate, AliasRead, AliasEdi
from app.services.alias_deploy_service import confirm_alias, deploy_alias, undeploy_alias, revert_alias_deployment
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/aliases", tags=["aliases"])
@@ -70,6 +71,9 @@ def _to_read(alias: FirewallAlias, hosts: list[OpnsenseHost], deployments: list[
)
add_adopt_routes(router, kind="alias", model=FirewallAlias, load_options=_load_options, to_read=_to_read, read_model=AliasRead)
@router.get("", response_model=list[AliasRead])
async def list_aliases(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(FirewallAlias).options(*_load_options()))
@@ -146,7 +150,7 @@ async def delete_alias(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_alias(db, alias)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(alias, attribute_names=["deployments"])
await db.delete(alias)
await db.commit()
+59
View File
@@ -0,0 +1,59 @@
"""Audit export settings (admin only): where the audit trail is forwarded to."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.schemas.audit_export import AuditExportRead, AuditExportUpdate
from app.services import audit_export_service as svc
from app.services import notification_secret_store
router = APIRouter(prefix="/settings/audit-export", tags=["audit-export"])
def _require_admin(user: AdminUser) -> None:
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
@router.get("", response_model=AuditExportRead)
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
return await svc.get_settings(db)
@router.put("", response_model=AuditExportRead)
async def update(payload: AuditExportUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
row = await svc.get_settings(db)
was_active = svc.active(row)
for field in ("syslog_enabled", "syslog_host", "syslog_port", "syslog_protocol", "webhook_enabled", "webhook_url"):
value = getattr(payload, field)
if value is not None:
setattr(row, field, value.strip() if isinstance(value, str) else value)
if payload.clear_webhook_signing_secret:
notification_secret_store.delete_secret(svc.SECRET_NAME)
row.webhook_signing_secret_set = False
elif payload.webhook_signing_secret is not None:
notification_secret_store.save_secret(svc.SECRET_NAME, payload.webhook_signing_secret)
row.webhook_signing_secret_set = True
if not svc.active(row):
row.cursor_created_at = row.cursor_id = None # next enabling starts from "now" again
elif not was_active:
row.cursor_created_at, row.cursor_id = datetime.now(timezone.utc), None
await db.commit()
return row
@router.post("/test")
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
try:
await svc.send_test(await svc.get_settings(db))
except svc.ExportError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
return {"success": True}
+60 -11
View File
@@ -9,13 +9,37 @@ from app.errors import ErrorCode, api_error
from app.config import settings
from app.database import get_db
from app.models.user import AdminUser
from app.services import auth_service, totp_service
from app.services import api_token_service, auth_service, totp_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Plain name: the session cookie of HTTP-only (lab) deployments. On HTTPS deployments it is NOT accepted any
# more (1.29): it is only still deleted (login, logout) so that a pre-1.27 cookie does not linger.
COOKIE_NAME = "opnexus-session"
# HTTPS deployments: the __Host- prefix makes the browser refuse any cookie of
# this name that is not Secure, has a Domain attribute or a Path other than
# "/". A page on another port of the same host (the GUI gateway slots, any
# other service on that machine) therefore cannot toss a narrower-path cookie
# that shadows the real session, and cannot overwrite it from script (HttpOnly).
SECURE_COOKIE_NAME = "__Host-opnexus-session"
def session_cookie_name() -> str:
return SECURE_COOKIE_NAME if settings.cookie_secure else COOKIE_NAME
def session_token(request: Request) -> str:
"""The session token the browser sent: the __Host- cookie on HTTPS deployments, the plain one otherwise.
Never the other name (1.29): a plain-name cookie can be planted by any plain-HTTP or sibling-subdomain page and
must not be able to carry a session on an HTTPS deployment (the __Host- prefix does not separate PORTS, see
BACKLOG S8)."""
return request.cookies.get(session_cookie_name(), "")
def has_session_cookie(request: Request) -> bool:
return bool(request.cookies.get(SECURE_COOKIE_NAME) or request.cookies.get(COOKIE_NAME))
def client_ip(request: Request) -> str:
@@ -30,20 +54,25 @@ def client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _set_session_cookie(response: Response, token: str) -> None:
def _set_session_cookie(response: Response, token: str, max_age: int | None = None) -> None:
response.set_cookie(
key=COOKIE_NAME,
key=session_cookie_name(),
value=token,
max_age=settings.session_lifetime,
max_age=max_age or settings.session_lifetime,
httponly=True,
samesite="lax",
secure=settings.cookie_secure,
path="/",
)
if settings.cookie_secure:
# A pre-1.27 plain-name cookie must not linger next to the new one.
response.delete_cookie(key=COOKIE_NAME, path="/", secure=True, httponly=True, samesite="lax")
def _clear_session_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, path="/")
response.delete_cookie(key=COOKIE_NAME, path="/", secure=settings.cookie_secure, httponly=True, samesite="lax")
if settings.cookie_secure:
response.delete_cookie(key=SECURE_COOKIE_NAME, path="/", secure=True, httponly=True, samesite="lax")
def _check_account_budget(user: AdminUser, action: str) -> None:
@@ -63,7 +92,15 @@ def _account_success(user: AdminUser, action: str) -> None:
async def resolve_user(request: Request, db: AsyncSession) -> AdminUser:
token = request.cookies.get(COOKIE_NAME, "")
# AuthMiddleware already resolved the caller (session cookie or API token), but
# in its own, closed DB session: re-load into this request's session so that
# changes made to the user (2FA, password...) are actually persisted.
resolved = getattr(request.state, "user", None)
if resolved is not None:
user = await db.get(AdminUser, resolved.id)
if user is not None:
return user
token = session_token(request)
user = await auth_service.resolve_session(db, token)
if user is None:
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.NICHT_AUTHENTIFIZIERT)
@@ -221,7 +258,7 @@ async def totp_enable(
user.totp_last_step = step
user.totp_recovery_hashes = hashes
# Other sessions were established without the second factor — end them.
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
await auth_service.revoke_sessions(db, user.id, keep_token=session_token(request), commit=False)
await db.commit()
logger.info("Two-factor enabled for '%s'", user.username)
return {"enabled": True, "recovery_codes": plain}
@@ -246,7 +283,7 @@ async def totp_disable(
user.totp_enabled = False
user.totp_recovery_hashes = None
user.totp_last_step = 0
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
await auth_service.revoke_sessions(db, user.id, keep_token=session_token(request), commit=False)
await db.commit()
logger.info("Two-factor disabled for '%s'", user.username)
return {"enabled": False}
@@ -254,15 +291,26 @@ async def totp_disable(
@router.post("/logout")
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
token = request.cookies.get(COOKIE_NAME, "")
token = session_token(request)
await auth_service.revoke_session(db, token)
legacy = request.cookies.get(COOKIE_NAME, "")
if settings.cookie_secure and legacy and legacy != token:
await auth_service.revoke_session(db, legacy) # an old plain-name cookie ends with the logout too
_clear_session_cookie(response)
return {"authenticated": False}
@router.get("/me")
async def me(request: Request, db: AsyncSession = Depends(get_db)):
token = request.cookies.get(COOKIE_NAME, "")
async def me(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
raw = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
if raw is not None:
resolved = await api_token_service.resolve(db, raw, client_ip(request))
if resolved is None:
return {"authenticated": False}
user, api_token = resolved
return {"authenticated": True, "username": user.username, "role": user.role,
"via": "token", "scope": api_token.scope, "token_name": api_token.name}
token = session_token(request)
user = await auth_service.resolve_session(db, token)
if user is None:
return {"authenticated": False}
@@ -289,6 +337,7 @@ async def change_password(
# an attacker may hold) and the replacement session commit as one unit.
user.password_hash = await auth_service.hash_password_async(payload.new_password)
await auth_service.revoke_sessions(db, user.id, commit=False)
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
token, _ = await auth_service.create_session(
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
)
+104
View File
@@ -0,0 +1,104 @@
"""Scheduled backups: status for everybody, settings and offsite test for admins, "run now" for operators."""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.schemas.backup_settings import BackupSettingsRead, BackupSettingsUpdate, BackupStatusRead
from app.services import backup_diff_service, backup_store
from app.services import backup_schedule_service as svc
from app.services import notification_secret_store
from app.services.s3_client import S3Error
router = APIRouter(tags=["backups"])
def _require_admin(user: AdminUser) -> None:
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
@router.get("/backups/status", response_model=BackupStatusRead)
async def status(db: AsyncSession = Depends(get_db)):
return await svc.get_settings(db)
@router.post("/backups/run")
async def run_now(db: AsyncSession = Depends(get_db)):
"""Back up every enabled host now (and copy offsite if configured)."""
return await svc.run_due(db, force=True)
@router.get("/settings/backups", response_model=BackupSettingsRead)
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
return await svc.get_settings(db)
@router.put("/settings/backups", response_model=BackupSettingsRead)
async def update(payload: BackupSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
row = await svc.get_settings(db)
for field in ("scheduled_enabled", "interval_hours", "keep_count", "offsite_enabled", "s3_region", "s3_path_style"):
value = getattr(payload, field)
if value is not None:
setattr(row, field, value)
for field in ("s3_endpoint", "s3_bucket", "s3_access_key", "s3_prefix"):
value = getattr(payload, field)
if value is not None:
setattr(row, field, value.strip().rstrip("/") if field == "s3_endpoint" else value.strip())
if payload.clear_s3_secret:
notification_secret_store.delete_secret(svc.S3_SECRET)
row.s3_secret_set = False
elif payload.s3_secret_key is not None:
notification_secret_store.save_secret(svc.S3_SECRET, payload.s3_secret_key)
row.s3_secret_set = True
if payload.passphrase is not None:
notification_secret_store.save_secret(svc.PASSPHRASE, payload.passphrase)
row.passphrase_set = True
await db.commit()
return row
@router.post("/settings/backups/test-offsite")
async def test_offsite(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
"""Write, read back and delete a probe object in the bucket."""
_require_admin(user)
cfg = await svc.get_settings(db)
try:
key = "/".join(p for p in (cfg.s3_prefix.strip("/"), f".opnexus-test-{int(datetime.now(timezone.utc).timestamp())}") if p)
await svc.s3_for(cfg).test(key)
except S3Error as exc:
raise api_error(502, ErrorCode.OFFSITE_TEST_FAILED, reason=str(exc)) from exc
return {"success": True}
@router.get("/hosts/{host_id}/backup-diff")
async def backup_diff(
host_id: uuid.UUID,
a: str | None = Query(default=None, description="Older backup id (default: the one before b)"),
b: str | None = Query(default=None, description="Newer backup id (default: the newest)"),
db: AsyncSession = Depends(get_db),
):
"""What changed between two stored backups of this host. Secrets are replaced by hashes."""
if await db.get(OpnsenseHost, host_id) is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
snapshots = [s["id"] for s in backup_store.list_snapshots(host_id)] # newest first
new_id = b or (snapshots[0] if snapshots else None)
old_id = a or (snapshots[snapshots.index(new_id) + 1] if new_id in snapshots and snapshots.index(new_id) + 1 < len(snapshots) else None)
if not new_id or not old_id:
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
old, new = backup_store.read_snapshot(host_id, old_id), backup_store.read_snapshot(host_id, new_id)
if old is None or new is None:
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
try:
result = backup_diff_service.diff(old, new)
except backup_diff_service.InvalidConfig as exc:
raise api_error(422, ErrorCode.BACKUP_UNLESBAR, reason=str(exc)) from exc
return {"a": old_id, "b": new_id, **result}
+3 -3
View File
@@ -112,7 +112,7 @@ async def delete_managed_certificate(cert_id: uuid.UUID, db: AsyncSession = Depe
try:
await managed_certificate_service.delete_certificate(db, cert, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
# ── ACME accounts ──────────────────────────────────────────────────────
@@ -197,7 +197,7 @@ async def delete_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(
try:
await acme_service.delete_account(db, account, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
# ── ACME certificate requests ─────────────────────────────────────────
@@ -298,4 +298,4 @@ async def delete_acme_certificate_request(request_id: uuid.UUID, db: AsyncSessio
try:
await acme_service.delete_certificate_request(db, req, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+88
View File
@@ -0,0 +1,88 @@
"""Security posture ("Sicherheitslage"): stored results and on-demand checks.
Reads (GET) only return what the last check stored, so they are cheap and safe
for dashboards, Prometheus and MCP. A check (POST) downloads the host's config
read-only, evaluates it and stores the result; see services/compliance_service.py.
"""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost
from app.models.rule_hit import RuleHit
from app.services import compliance_service, rule_hit_service
router = APIRouter(tags=["compliance"])
def _read(host: OpnsenseHost, row: ComplianceResult | None, *, detail: bool) -> dict:
result = {
"host_id": host.id, "host_name": host.name, "cluster_name": host.cluster_name, "enabled": host.enabled,
"checked_at": row.checked_at if row else None,
"score": row.score if row else None,
"grade": compliance_service.grade(row.score) if row and row.score is not None else None,
"error": row.error if row else None,
"counts": compliance_service.counts(row.findings) if row else None,
}
if detail:
result["findings"] = row.findings if row else []
return result
@router.get("/compliance")
async def fleet_compliance(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
rows = {r.host_id: r for r in (await db.scalars(select(ComplianceResult))).all()}
return [_read(host, rows.get(host.id), detail=False) for host in hosts]
@router.post("/compliance/check")
async def check_fleet(db: AsyncSession = Depends(get_db)):
await compliance_service.check_all(db)
return await fleet_compliance(db)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.get("/hosts/{host_id}/compliance")
async def host_compliance(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host_id))
return _read(host, row, detail=True)
@router.post("/hosts/{host_id}/compliance/check")
async def check_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
row = await compliance_service.check_host(db, host)
return _read(host, row, detail=True)
@router.get("/hosts/{host_id}/rule-hits")
async def rule_hits(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""When each rule last matched something, as observed by OPNexus (see services/rule_hit_service.py)."""
await _host(db, host_id)
now = datetime.now(timezone.utc)
rows = (await db.scalars(select(RuleHit).where(RuleHit.host_id == host_id))).all()
return sorted(
(
{
"rule_uuid": r.rule_uuid, "description": r.description, "enabled": r.enabled,
"state": rule_hit_service.classify(r, now), "packets": r.last_packets, "bytes": r.last_bytes,
"last_hit_at": r.last_hit_at, "first_seen_at": r.first_seen_at, "last_sampled_at": r.last_sampled_at,
}
for r in rows
),
key=lambda item: (item["state"] != "unused", item["last_hit_at"] or datetime.min.replace(tzinfo=timezone.utc), item["description"]),
)
+52 -12
View File
@@ -18,6 +18,8 @@ locks are deliberately NOT included here (no fleet-wide list endpoint
exists for those, only per-host, and it's a rarely-triggered flow) —
a documented v1 gap, not an oversight.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -46,7 +48,10 @@ from app.models.rule_deployment import RuleDeployment
from app.models.wireguard_tunnel import WireguardTunnel
from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment
from app.schemas.dashboard import ActivityItem, DashboardHost, DashboardSummary, PendingAction
from app.models.resource_sample import HostResourceSample
from app.services import gateway_health_service, notification_settings_service
from app.services.host_status_service import get_statuses
from app.services.monitor_checks import STALE_AFTER
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -184,18 +189,8 @@ async def _carp_auto_revert_actions(db: AsyncSession) -> list[PendingAction]:
return actions
@router.get("", response_model=DashboardSummary)
async def summary(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
statuses = await get_statuses(hosts)
host_summaries = [
DashboardHost(
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
)
for host in hosts
]
async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
"""Everything that needs a human, newest first (shared with the metrics endpoint)."""
pending_actions: list[PendingAction] = []
pending_actions += await _rule_actions(db)
pending_actions += await _nat_actions(db, PortForwardDeployment, PortForwardRule, "port_forward_id", "port_forward", "port-forward")
@@ -209,6 +204,51 @@ async def summary(db: AsyncSession = Depends(get_db)):
pending_actions += await _stuck_host_operations(db)
pending_actions += await _carp_auto_revert_actions(db)
pending_actions.sort(key=lambda item: item.created_at, reverse=True)
return pending_actions
async def _sample_readings_by_host(db: AsyncSession, hosts, muted: set[str]) -> tuple[dict, dict]:
"""(gateways, missing API rights) from each host's newest resource sample; hosts without a fresh sample are left out."""
gateways: dict = {}
denied: dict = {}
now = datetime.now(timezone.utc)
for host in hosts:
row = (
await db.execute(
select(HostResourceSample.sampled_at, HostResourceSample.raw["gateways"].as_json(), HostResourceSample.raw["denied"].as_json())
.where(HostResourceSample.host_id == host.id)
.order_by(HostResourceSample.sampled_at.desc())
.limit(1)
)
).first()
if row is None or now - row[0] > STALE_AFTER:
continue
if row[1] is not None:
gateways[host.id] = [
g.model_copy(update={"alert_enabled": f"{host.id}:{g.name}" not in muted})
for g in gateway_health_service.from_compact(row[1])
]
if isinstance(row[2], list):
denied[host.id] = [str(x) for x in row[2]]
return gateways, denied
@router.get("", response_model=DashboardSummary)
async def summary(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
statuses = await get_statuses(hosts)
muted = set((await notification_settings_service.get_settings(db)).gateway_alert_muted or [])
gateways, denied = await _sample_readings_by_host(db, hosts, muted)
host_summaries = [
DashboardHost(
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
gateways=gateways.get(host.id), api_rights_missing=denied.get(host.id),
)
for host in hosts
]
pending_actions = await collect_pending_actions(db)
activity_rows = (
await db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(15))
+68
View File
@@ -0,0 +1,68 @@
import uuid
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.services import diagnostics_service
router = APIRouter(prefix="/hosts/{host_id}/diagnostics", tags=["diagnostics"])
IpProto = Literal["inet", "inet6"]
class PingRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
count: int = Field(default=4, ge=1, le=10)
packetsize: int | None = Field(default=None, ge=1, le=9000)
source: str | None = Field(default=None, max_length=64)
class TracerouteRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
ipproto: IpProto = "inet"
protocol: Literal["udp", "icmp"] = "icmp"
source: str | None = Field(default=None, max_length=64)
class PortprobeRequest(BaseModel):
target: str = Field(min_length=1, max_length=253)
port: int = Field(ge=1, le=65535)
ipproto: IpProto = "inet"
source: str | None = Field(default=None, max_length=64)
class DnsRequest(BaseModel):
name: str = Field(min_length=1, max_length=253)
server: str | None = Field(default=None, max_length=64)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.post("/ping")
async def ping(host_id: uuid.UUID, p: PingRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.ping(await _host(db, host_id), p.target, p.ipproto, p.count, p.packetsize, p.source)
@router.post("/traceroute")
async def traceroute(host_id: uuid.UUID, p: TracerouteRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.traceroute(await _host(db, host_id), p.target, p.ipproto, p.protocol, p.source)
@router.post("/portprobe")
async def portprobe(host_id: uuid.UUID, p: PortprobeRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.portprobe(await _host(db, host_id), p.target, p.port, p.ipproto, p.source)
@router.post("/dns")
async def dns(host_id: uuid.UUID, p: DnsRequest, db: AsyncSession = Depends(get_db)):
return await diagnostics_service.dns_lookup(await _host(db, host_id), p.name, p.server)
+99
View File
@@ -0,0 +1,99 @@
import re
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.models.resource_sample import HostResourceSample
from app.schemas.gateway import ApiRightsState, GatewayAlertState, GatewayAlertUpdate, GatewayHealth
from app.services import audit_service, gateway_health_service, notification_settings_service
from app.services.monitor_checks import STALE_AFTER
from app.services.opnsense_client import OpnsenseAPIError, OpnsenseForbiddenError
router = APIRouter(prefix="/hosts/{host_id}/gateways", tags=["host-gateways"])
# OPNsense gateway names are letters, digits and underscores (WAN_GW, WAN_DHCP6); a little more is allowed.
_GATEWAY_NAME = re.compile(r"[A-Za-z0-9_.-]{1,64}")
MAX_MUTED_GATEWAYS = 200
@router.get("", response_model=list[GatewayHealth])
async def list_gateways(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Live gateway state (loss, delay, deviation, OPNsense' verdict) of one firewall."""
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
gateways = await gateway_health_service.get_gateway_health(host)
except OpnsenseForbiddenError as exc:
# The API key may not read the gateway state: say so instead of a generic upstream failure.
raise api_error(403, ErrorCode.API_RIGHTS_MISSING) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
muted = set((await notification_settings_service.get_settings(db)).gateway_alert_muted or [])
return [g.model_copy(update={"alert_enabled": f"{host.id}:{g.name}" not in muted}) for g in gateways]
@router.get("/api-rights", response_model=ApiRightsState)
async def api_rights(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Side readings the newest FRESH resource sample could not take because the API key lacks the right
(null = no fresh sample, so nothing is known). Stored data only: no call to the firewall."""
if await db.get(OpnsenseHost, host_id) is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
row = (
await db.execute(
select(HostResourceSample.sampled_at, HostResourceSample.raw["denied"].as_json())
.where(HostResourceSample.host_id == host_id)
.order_by(HostResourceSample.sampled_at.desc())
.limit(1)
)
).first()
if row is None or datetime.now(timezone.utc) - row[0] > STALE_AFTER or not isinstance(row[1], list):
return ApiRightsState(missing=None)
return ApiRightsState(missing=[str(x) for x in row[1]])
@router.put("/{name}/alert", response_model=GatewayAlertState)
async def set_gateway_alert(host_id: uuid.UUID, name: str, payload: GatewayAlertUpdate, db: AsyncSession = Depends(get_db)):
"""Switch the gateway-down alert on or off for ONE gateway (admins only, see api_token_service)."""
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
if not _GATEWAY_NAME.fullmatch(name):
raise api_error(422, ErrorCode.GATEWAY_NAME_INVALID)
if not payload.enabled:
# Only a gateway the firewall really reported can be muted ("wan_gw" vs "WAN_GW" would silently do nothing).
# Without a usable sample nothing is known, so the request goes through.
known = (
await db.execute(
select(HostResourceSample.raw["gateways"].as_json())
.where(HostResourceSample.host_id == host_id)
.order_by(HostResourceSample.sampled_at.desc())
.limit(1)
)
).scalar()
if isinstance(known, list) and name not in {g.get("name") for g in known if isinstance(g, dict)}:
raise api_error(404, ErrorCode.GATEWAY_UNKNOWN)
row = await notification_settings_service.get_settings(db)
await db.refresh(row, with_for_update=True)
muted = list(row.gateway_alert_muted or [])
key = f"{host.id}:{name}"
if payload.enabled:
muted = [m for m in muted if m != key]
elif key not in muted:
if len(muted) >= MAX_MUTED_GATEWAYS:
raise api_error(422, ErrorCode.GATEWAY_MUTE_LIMIT, limit=str(MAX_MUTED_GATEWAYS))
muted.append(key)
row.gateway_alert_muted = muted # a new list, so the JSON column notices the change
await db.commit()
# The request middleware logs the call itself; this entry says what it did (only the validated name goes in).
await audit_service.persist(
kind="host", action="gateway_alert_on" if payload.enabled else "gateway_alert_off", entity_id=str(host.id),
host_id=str(host.id), host_name=host.name, status="accepted", message=f"Gateway {name}",
)
return GatewayAlertState(name=name, alert_enabled=payload.enabled)
+41
View File
@@ -0,0 +1,41 @@
"""Separation of duties: with ``operators_may_deploy`` off, only admins roll changes out (AuthMiddleware enforces it)."""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.governance import GovernanceSettings
from app.models.user import AdminUser
from app.routers.auth import current_user
router = APIRouter(prefix="/settings/governance", tags=["governance"])
class GovernanceRead(BaseModel):
operators_may_deploy: bool
async def get_settings(db: AsyncSession) -> GovernanceSettings:
row = await db.get(GovernanceSettings, 1)
if row is None:
row = GovernanceSettings(id=1, operators_may_deploy=True)
db.add(row)
await db.flush()
return row
@router.get("", response_model=GovernanceRead)
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
"""Readable by everybody signed in (the UI explains why a deploy was refused)."""
return await get_settings(db)
@router.put("", response_model=GovernanceRead)
async def update(payload: GovernanceRead, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
row = await get_settings(db)
row.operators_may_deploy = payload.operators_may_deploy
await db.commit()
return row
+3
View File
@@ -99,6 +99,9 @@ async def delete_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
for assignment in (port_forward_groups, one_to_one_groups, outbound_nat_groups):
if await db.scalar(select(assignment.c.group_id).where(assignment.c.group_id == group_id).limit(1)):
raise api_error(409, ErrorCode.GRUPPE_WIRD_NOCH_VON_NAT_REGELN_VERWENDET)
from app.models.rollout import rollout_wave_groups
if await db.scalar(select(rollout_wave_groups.c.group_id).where(rollout_wave_groups.c.group_id == group_id).limit(1)):
raise api_error(409, ErrorCode.ROLLOUT_GROUP_IN_USE)
await db.delete(group)
await db.commit()
+190
View File
@@ -0,0 +1,190 @@
"""Firewall GUI access through the gui-gateway container (1.27).
Public API (session cookie, admin only, never API tokens):
* POST /api/hosts/{id}/gui-ticket — one-time ticket + the slot URL the browser opens (noopener).
* GET/PUT /api/settings/gui-access — switch, idle/absolute timeout, number of slots.
Internal API for the gateway only (NOT under /api, so the proxy never forwards it; shared secret
GUI_GATEWAY_TOKEN in the X-Onx-Gateway-Token header, compared in constant time):
* POST /internal/gui/redeem, /internal/gui/revalidate, /internal/gui/end.
The answers never contain firewall credentials: only the firewall origin, its pinned (public) CA
certificate, the overlay proxy address and the timeouts.
"""
import hashlib
import hmac
import uuid
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.models.user import AdminUser
from app.routers.auth import SECURE_COOKIE_NAME, current_user
from app.services import api_token_service, gui_ticket_service as svc
router = APIRouter(tags=["gui-access"])
internal_router = APIRouter(prefix="/internal/gui", include_in_schema=False)
def _require_admin_session(request: Request, user: AdminUser) -> None:
if getattr(request.state, "api_token", None) is not None or api_token_service.bearer_from_header(
request.headers.get("authorization", "")
) is not None:
raise api_error(status.HTTP_403_FORBIDDEN, ErrorCode.GUI_SESSION_REQUIRED)
if user.role != "admin":
raise api_error(status.HTTP_403_FORBIDDEN, ErrorCode.ADMIN_REQUIRED)
class GuiTicketRead(BaseModel):
url: str
slot: int
expires_in: int
@router.post("/hosts/{host_id}/gui-ticket", response_model=GuiTicketRead)
async def create_ticket(host_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db),
user: AdminUser = Depends(current_user)):
_require_admin_session(request, user)
if not svc.gateway_configured():
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_GATEWAY_NOT_CONFIGURED)
session_cookie = request.cookies.get(SECURE_COOKIE_NAME, "")
if not settings.cookie_secure or not session_cookie:
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_REQUIRES_HTTPS)
row = await svc.get_settings(db)
if not row.enabled:
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_ACCESS_DISABLED)
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(status.HTTP_404_NOT_FOUND, ErrorCode.HOST_NICHT_GEFUNDEN)
refusal = svc.host_refusal(host)
if refusal:
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_HOST_NOT_ELIGIBLE, reason=refusal)
try:
return await svc.issue_ticket(db, user=user, session_token=session_cookie, host=host,
request_host=request.headers.get("host", ""))
except svc.GuiAccessError as exc:
raise api_error(exc.status, exc.code, **exc.params)
class GuiAccessSettingsRead(BaseModel):
enabled: bool
idle_timeout_minutes: int
absolute_timeout_minutes: int
slot_count: int
available: bool
unavailable_reasons: list[str]
slots: list[int]
updated_by: str | None
updated_at: datetime | None
class GuiAccessSettingsUpdate(BaseModel):
enabled: bool
idle_timeout_minutes: int = Field(ge=1, le=240)
absolute_timeout_minutes: int = Field(ge=5, le=720)
slot_count: int = Field(ge=1, le=svc.MAX_SLOTS)
@model_validator(mode="after")
def _idle_within_absolute(self):
if self.idle_timeout_minutes > self.absolute_timeout_minutes:
raise ValueError("idle_timeout_minutes must not exceed absolute_timeout_minutes")
return self
def _settings_read(row) -> dict:
reasons = svc.unavailable_reasons()
return {"enabled": row.enabled, "idle_timeout_minutes": row.idle_timeout_minutes,
"absolute_timeout_minutes": row.absolute_timeout_minutes, "slot_count": row.slot_count,
"available": not reasons, "unavailable_reasons": reasons, "slots": svc.active_slots(row),
"updated_by": row.updated_by, "updated_at": row.updated_at}
@router.get("/settings/gui-access", response_model=GuiAccessSettingsRead)
async def read_settings(request: Request, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin_session(request, user)
row = await svc.get_settings(db)
await db.commit()
return _settings_read(row)
@router.put("/settings/gui-access", response_model=GuiAccessSettingsRead)
async def update_settings(payload: GuiAccessSettingsUpdate, request: Request, db: AsyncSession = Depends(get_db),
user: AdminUser = Depends(current_user)):
_require_admin_session(request, user)
if payload.enabled and svc.unavailable_reasons():
# Never switchable on without HTTPS cookies and a configured gateway.
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.GUI_GATEWAY_NOT_CONFIGURED,
reasons=", ".join(svc.unavailable_reasons()))
row = await svc.get_settings(db)
row.enabled = payload.enabled
row.idle_timeout_minutes = payload.idle_timeout_minutes
row.absolute_timeout_minutes = payload.absolute_timeout_minutes
row.slot_count = payload.slot_count
row.updated_by = user.username
row.updated_at = datetime.now(timezone.utc)
if not payload.enabled:
await svc.end_all(db, "disabled")
await db.commit()
return _settings_read(row)
# ── internal: gateway only ────────────────────────────────────────────────
def _gateway_auth(x_onx_gateway_token: str = Header(default="")) -> None:
expected = settings.gui_gateway_token
if len(expected) < svc.MIN_GATEWAY_TOKEN_LENGTH:
raise HTTPException(status_code=404)
# Fixed-length digests: the comparison time does not depend on the presented value.
if not hmac.compare_digest(hashlib.sha256(x_onx_gateway_token.encode()).digest(),
hashlib.sha256(expected.encode()).digest()):
raise HTTPException(status_code=401)
_HEX64 = r"^[0-9a-f]{64}$"
class RedeemRequest(BaseModel):
ticket_hash: str = Field(pattern=_HEX64)
session_hash: str = Field(pattern=_HEX64)
slot: int = Field(ge=1, le=65535)
client_ip: str | None = Field(default=None, max_length=45)
class RevalidateRequest(BaseModel):
gui_session_id: uuid.UUID
session_hash: str = Field(pattern=_HEX64)
# Fingerprint of the target as handed out at redeem time (optional: an older gateway image omits it).
target_fp: str | None = Field(default=None, pattern=_HEX64)
class EndRequest(BaseModel):
gui_session_id: uuid.UUID
session_hash: str = Field(pattern=_HEX64)
reason: Literal["idle", "absolute", "logout", "revoked", "superseded", "shutdown"]
@internal_router.post("/redeem", dependencies=[Depends(_gateway_auth)])
async def internal_redeem(payload: RedeemRequest, db: AsyncSession = Depends(get_db)):
result = await svc.redeem(db, ticket_hash=payload.ticket_hash, session_hash=payload.session_hash,
slot=payload.slot, client_ip=payload.client_ip)
if not result["ok"]:
raise HTTPException(status_code=403, detail={"ok": False, "reason": result["reason"]})
return result
@internal_router.post("/revalidate", dependencies=[Depends(_gateway_auth)])
async def internal_revalidate(payload: RevalidateRequest, db: AsyncSession = Depends(get_db)):
return await svc.revalidate(db, gui_session_id=payload.gui_session_id, session_hash=payload.session_hash,
target_fp=payload.target_fp)
@internal_router.post("/end", dependencies=[Depends(_gateway_auth)])
async def internal_end(payload: EndRequest, db: AsyncSession = Depends(get_db)):
return {"ok": await svc.end(db, gui_session_id=payload.gui_session_id, session_hash=payload.session_hash,
reason=payload.reason)}
+8
View File
@@ -155,6 +155,8 @@ async def update_host(host_id: uuid.UUID, payload: HostUpdate, db: AsyncSession
if existing:
raise api_error(409, ErrorCode.HOST_MIT_DIESEM_NAMEN_EXISTIERT_BEREITS)
host.name = payload.name
if payload.base_url is not None:
host.base_url = payload.base_url
if payload.verify_tls is not None:
host.verify_tls = payload.verify_tls
if payload.clear_tls_pin:
@@ -224,6 +226,12 @@ async def delete_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
draft_certs = (await db.scalars(select(ManagedCertificate.id).where(ManagedCertificate.host_id == host_id))).all()
draft_accounts = (await db.scalars(select(AcmeAccount.id).where(AcmeAccount.host_id == host_id))).all()
await db.delete(host)
# Gateway alert switches of this host would otherwise stay in the settings forever (and count against the limit).
from app.services import notification_settings_service
settings_row = await notification_settings_service.get_settings(db)
prefix = f"{host_id}:"
if any(str(m).startswith(prefix) for m in settings_row.gateway_alert_muted or []):
settings_row.gateway_alert_muted = [m for m in settings_row.gateway_alert_muted if not str(m).startswith(prefix)]
await db.commit()
opnsense_secret_store.delete_secret(host_id)
for cert_id in draft_certs:
+1 -1
View File
@@ -174,7 +174,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
try:
await undeploy_tunnel(db, tunnel)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(tunnel, attribute_names=["deployments"])
ipsec_psk_store.delete_secret(tunnel.id)
await db.delete(tunnel)
+244
View File
@@ -0,0 +1,244 @@
"""Recurring maintenance windows and their exceptions (1.25).
Every write here counts as a rollout action (api_token_service.is_rollout): a window decides WHEN
unattended firmware actions may run, so an operator needs the same right as for deploying.
"""
import re
import uuid
from datetime import date, datetime, time, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.maintenance_window import MaintenanceWindow, MaintenanceWindowException
from app.services import audit_service, window_service
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/maintenance-windows", tags=["maintenance-windows"])
_TIME = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
class WindowIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
timezone: str = Field(min_length=1, max_length=64)
recurrence: Literal["weekly", "monthly"]
weekdays: list[int] = Field(default_factory=list, max_length=7)
month_mode: Literal["nth_weekday", "day"] | None = None
month_nth: int | None = None
month_weekday: int | None = None
month_day: int | None = None
start_time: str
duration_minutes: int = Field(ge=15, le=window_service.MAX_DURATION_MINUTES)
start_cutoff_minutes: int = Field(default=60, ge=5, le=window_service.MAX_DURATION_MINUTES)
enabled: bool = True
class ExceptionIn(BaseModel):
occurrence_date: date
kind: Literal["skip", "move"]
moved_start: datetime | None = None
reason: str = Field(default="", max_length=300)
def _invalid(field: str):
return api_error(422, ErrorCode.WINDOW_INVALID, field=field)
def _validated(payload: WindowIn) -> dict:
name = payload.name.strip()
if not name:
raise _invalid("name")
if not window_service.valid_timezone(payload.timezone):
raise _invalid("timezone")
match = _TIME.match(payload.start_time)
if not match:
raise _invalid("start_time")
values = {
"name": name, "timezone": payload.timezone, "recurrence": payload.recurrence,
"weekdays": [], "month_mode": None, "month_nth": None, "month_weekday": None, "month_day": None,
"start_time": time(int(match.group(1)), int(match.group(2))),
"duration_minutes": payload.duration_minutes,
"start_cutoff_minutes": min(payload.start_cutoff_minutes, payload.duration_minutes),
"enabled": payload.enabled,
}
if payload.recurrence == "weekly":
days = sorted(set(payload.weekdays))
if not days or any(d < 0 or d > 6 for d in days):
raise _invalid("weekdays")
values["weekdays"] = days
elif payload.month_mode == "day":
if payload.month_day is None or not 1 <= payload.month_day <= 28:
raise _invalid("month_day")
values.update(month_mode="day", month_day=payload.month_day)
elif payload.month_mode == "nth_weekday":
if payload.month_nth not in (1, 2, 3, 4, -1):
raise _invalid("month_nth")
if payload.month_weekday is None or not 0 <= payload.month_weekday <= 6:
raise _invalid("month_weekday")
values.update(month_mode="nth_weekday", month_nth=payload.month_nth, month_weekday=payload.month_weekday)
else:
raise _invalid("month_mode")
return values
async def _plans_using(db: AsyncSession, window_id) -> list[dict]:
from app.models.rollout import RolloutPlan
rows = (await db.scalars(select(RolloutPlan).where(RolloutPlan.window_id == window_id).order_by(RolloutPlan.name))).all()
return [{"id": str(p.id), "name": p.name, "auto_start": p.auto_start, "active": p.activated_at is not None} for p in rows]
def read_window(window: MaintenanceWindow, *, now: datetime | None = None, count: int = 5) -> dict:
now = now or datetime.now(timezone.utc)
return {
"id": str(window.id), "name": window.name, "timezone": window.timezone, "recurrence": window.recurrence,
"weekdays": window.weekdays or [], "month_mode": window.month_mode, "month_nth": window.month_nth,
"month_weekday": window.month_weekday, "month_day": window.month_day,
"start_time": window.start_time.strftime("%H:%M"), "duration_minutes": window.duration_minutes,
"start_cutoff_minutes": window.start_cutoff_minutes, "enabled": window.enabled,
"created_by": window.created_by, "created_at": window.created_at,
"open_now": window_service.current(window, now) is not None,
"next": [o.as_dict() for o in window_service.occurrences(window, now, count, with_skipped=True)] if window.enabled else [],
"exceptions": [
{"id": str(e.id), "occurrence_date": e.occurrence_date.isoformat(), "kind": e.kind,
"moved_start": e.moved_start, "reason": e.reason, "actor": e.actor, "created_at": e.created_at}
for e in window.exceptions
],
}
async def _window_or_404(db: AsyncSession, window_id: uuid.UUID) -> MaintenanceWindow:
window = await db.get(MaintenanceWindow, window_id)
if window is None:
raise api_error(404, ErrorCode.WINDOW_NOT_FOUND)
return window
async def _name_taken(db: AsyncSession, name: str, own_id=None) -> bool:
other = await db.scalar(select(MaintenanceWindow.id).where(MaintenanceWindow.name == name))
return other is not None and other != own_id
@router.get("")
async def list_windows(db: AsyncSession = Depends(get_db)):
rows = (await db.scalars(select(MaintenanceWindow).order_by(MaintenanceWindow.name))).all()
result = []
for w in rows:
item = read_window(w)
item["used_by"] = await _plans_using(db, w.id)
result.append(item)
return result
@router.post("", status_code=201)
async def create_window(payload: WindowIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
values = _validated(payload)
if await _name_taken(db, values["name"]):
raise api_error(409, ErrorCode.WINDOW_NAME_TAKEN)
window = MaintenanceWindow(**values, created_by=request.state.user.username, created_at=datetime.now(timezone.utc))
window.exceptions = []
db.add(window)
await db.commit()
item = read_window(await _window_or_404(db, window.id))
item["used_by"] = []
return item
@router.get("/{window_id}")
async def get_window(window_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
item = read_window(await _window_or_404(db, window_id))
item["used_by"] = await _plans_using(db, window_id)
return item
@router.put("/{window_id}")
async def update_window(window_id: uuid.UUID, payload: WindowIn, db: AsyncSession = Depends(get_db)):
"""Changing a window changes the fingerprint of every plan that uses it, so an activated
automatic start stops until the plan is activated again (see rollout_service.fingerprint)."""
await policy_lock(db)
window = await _window_or_404(db, window_id)
values = _validated(payload)
if await _name_taken(db, values["name"], window.id):
raise api_error(409, ErrorCode.WINDOW_NAME_TAKEN)
for key, value in values.items():
setattr(window, key, value)
await db.commit()
item = read_window(await _window_or_404(db, window_id))
item["used_by"] = await _plans_using(db, window_id)
return item
@router.delete("/{window_id}", status_code=204)
async def delete_window(window_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
window = await _window_or_404(db, window_id)
# A plan without a window means "no time limit": deleting its window must never turn it into one.
if await _plans_using(db, window_id):
raise api_error(409, ErrorCode.WINDOW_IN_USE)
await db.delete(window)
await db.commit()
@router.get("/{window_id}/occurrences")
async def window_occurrences(window_id: uuid.UUID, count: int = Query(10, ge=1, le=50), db: AsyncSession = Depends(get_db)):
window = await _window_or_404(db, window_id)
return [o.as_dict() for o in window_service.occurrences(window, datetime.now(timezone.utc), count, with_skipped=True)]
@router.post("/{window_id}/exceptions", status_code=201)
async def add_exception(window_id: uuid.UUID, payload: ExceptionIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
window = await _window_or_404(db, window_id)
now = datetime.now(timezone.utc)
if not window_service.is_occurrence_date(window, payload.occurrence_date):
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="occurrence_date")
# Only future occurrences (or the one open now) can be changed; history stays as it was.
if window_service.local_start(window, payload.occurrence_date) + timedelta(minutes=window.duration_minutes) <= now:
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="occurrence_date")
moved = None
if payload.kind == "move":
if payload.moved_start is None:
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="moved_start")
moved = payload.moved_start if payload.moved_start.tzinfo else payload.moved_start.replace(tzinfo=timezone.utc)
if moved <= now:
raise api_error(422, ErrorCode.WINDOW_EXCEPTION_INVALID, field="moved_start")
if any(e.occurrence_date == payload.occurrence_date for e in window.exceptions):
raise api_error(409, ErrorCode.WINDOW_EXCEPTION_EXISTS)
exc = MaintenanceWindowException(
window_id=window.id, occurrence_date=payload.occurrence_date, kind=payload.kind, moved_start=moved,
reason=payload.reason.strip(), actor=request.state.user.username, created_at=now,
)
db.add(exc)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise api_error(409, ErrorCode.WINDOW_EXCEPTION_EXISTS)
await audit_service.persist(
kind="maintenance_window", action=f"exception_{payload.kind}", status="saved", entity_id=str(window.id),
entity_name=window.name, details={"occurrence_date": payload.occurrence_date.isoformat(),
"moved_start": moved.isoformat() if moved else None, "reason": exc.reason},
)
db.expire_all()
return read_window(await _window_or_404(db, window_id))
@router.delete("/{window_id}/exceptions/{exception_id}")
async def delete_exception(window_id: uuid.UUID, exception_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
window = await _window_or_404(db, window_id)
exc = await db.get(MaintenanceWindowException, exception_id)
if exc is None or exc.window_id != window.id:
raise api_error(404, ErrorCode.WINDOW_EXCEPTION_NOT_FOUND)
await db.delete(exc)
await db.commit()
db.expire_all()
return read_window(await _window_or_404(db, window_id))
+244
View File
@@ -0,0 +1,244 @@
"""Read-only MCP server (Model Context Protocol, streamable HTTP transport).
Lets an AI assistant (Claude Code, Claude Desktop, ...) look at the fleet:
claude mcp add --transport http opnexus https://<opnexus>/api/mcp \
--header "Authorization: Bearer opx_..."
Stateless JSON-RPC over POST (the spec allows plain ``application/json``
replies; there is no server-initiated stream, GET answers 405). Every tool is a
GET against OPNexus' own API, sent through the app itself with the caller's own
credentials, so exactly the caller's permissions apply: a ``read`` token sees
what a ``read`` token sees, secret-bearing endpoints stay closed. Nothing here
can change state.
"""
import json
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
from app.version import APP_VERSION
router = APIRouter(tags=["mcp"])
SUPPORTED_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
MAX_RESULT_CHARS = 60_000
# Not reachable through the generic reader (credentials management, the MCP endpoint itself).
_BLOCKED_PREFIXES = ("/api/tokens", "/api/auth", "/api/mcp")
TOOLS = [
{
"name": "fleet_overview",
"description": "Fleet status at a glance: every firewall with online state and CARP role, items that need "
"attention (failed or unconfirmed deployments, stuck maintenance) and the latest activity.",
"inputSchema": {"type": "object", "properties": {}},
"path": "/api/dashboard",
},
{
"name": "list_hosts",
"description": "All managed firewalls with name, URL, cluster and whether monitoring is enabled.",
"inputSchema": {"type": "object", "properties": {}},
"path": "/api/hosts",
},
{
"name": "host_resources",
"description": "Recent CPU load, memory and disk usage samples of one firewall (a sample per minute).",
"inputSchema": {
"type": "object", "required": ["host_id"],
"properties": {
"host_id": {"type": "string", "description": "Firewall id from list_hosts"},
"since_hours": {"type": "integer", "minimum": 1, "maximum": 720, "default": 1},
},
},
"path": "/api/hosts/{host_id}/resource-samples",
"query": ("since_hours",),
# The raw per-sample payload (every interface counter) would drown the useful numbers.
"drop": ("raw",),
},
{
"name": "carp_events",
"description": "History of CARP role changes (MASTER/BACKUP) across the clusters.",
"inputSchema": {"type": "object", "properties": {}},
"path": "/api/cluster/carp-events",
},
{
"name": "recent_audit",
"description": "The audit trail: who changed or deployed what, newest first.",
"inputSchema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25},
"kind": {"type": "string", "description": "e.g. rule, alias, host, user"},
"status": {"type": "string", "description": "e.g. accepted, rejected, denied"},
"q": {"type": "string", "description": "free-text filter"},
},
},
"path": "/api/audit",
"query": ("limit", "kind", "status", "q"),
},
{
"name": "search",
"description": "Search rules, aliases, NAT, VPN, certificates, groups and hosts by text.",
"inputSchema": {
"type": "object", "required": ["q"],
"properties": {"q": {"type": "string", "description": "at least 2 characters"}},
},
"path": "/api/search",
"query": ("q",),
},
{
"name": "rule_match",
"description": "Which firewall rule decides a packet (first packet of a new connection), per firewall, "
"with the verdict (pass/block/reject), the deciding rule and an honest confidence "
"(sure/probable/unknown plus reason codes). Reads only; nothing is changed.",
"inputSchema": {
"type": "object", "required": ["src", "dst"],
"properties": {
"src": {"type": "string", "description": "source IP address"},
"dst": {"type": "string", "description": "destination IP address"},
"proto": {"type": "string", "description": "tcp, udp, icmp, ipv6-icmp or a protocol number", "default": "tcp"},
"dport": {"type": "integer", "minimum": 0, "maximum": 65535},
"sport": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "default: any ephemeral port"},
"icmptype": {"type": "string", "description": "e.g. echoreq or 8"},
"iface": {"type": "string", "description": "ingress interface identifier (e.g. lan); default: guessed per firewall"},
"egress": {"type": "string", "description": "also check the outgoing side: auto or an interface identifier"},
"host_ids": {"type": "string", "description": "comma separated firewall ids (default: all)"},
"group_id": {"type": "string", "description": "device group id instead of host_ids"},
},
},
"path": "/api/rule-analysis/match",
"query": ("src", "dst", "proto", "dport", "sport", "icmptype", "iface", "egress", "host_ids", "group_id"),
# The per-rule trace is for the UI; the verdict, deciding rule and reasons are what matters here.
"drop": ("trace",),
},
{
"name": "rule_cleanup_candidates",
"description": "Cleanup candidates of one firewall: rules that are unused (no hit for N days), shadowed or "
"duplicate (an earlier quick rule already decides everything they match), or disabled for a "
"long time. Each with its confidence (sure/probable/unknown), the last observed hit and "
"whether it could be removed (not for automatic, legacy or OPNexus-managed rules). Reads only.",
"inputSchema": {
"type": "object", "required": ["host_id"],
"properties": {
"host_id": {"type": "string", "description": "Firewall id from list_hosts"},
"unused_days": {"type": "integer", "minimum": 1, "maximum": 3650, "default": 30,
"description": "observation window for unused / disabled rules"},
},
},
"path": "/api/hosts/{host_id}/rule-analysis/cleanup",
"query": ("unused_days",),
},
{
"name": "api_get",
"description": "Read any other OPNexus API endpoint (GET only), e.g. /api/rules, /api/aliases, /api/logs. "
"The schema of all endpoints is at /api/openapi.json.",
"inputSchema": {
"type": "object", "required": ["path"],
"properties": {
"path": {"type": "string", "description": "Starts with /api/"},
"query": {"type": "object", "description": "Query parameters", "additionalProperties": {"type": "string"}},
},
},
},
]
def _tool_list() -> list[dict]:
return [{k: v for k, v in tool.items() if k in ("name", "description", "inputSchema")} for tool in TOOLS]
def _text(text: str, is_error: bool = False) -> dict:
if len(text) > MAX_RESULT_CHARS:
text = text[:MAX_RESULT_CHARS] + f"\n… truncated ({len(text)} characters in total); narrow the request."
return {"content": [{"type": "text", "text": text}], "isError": is_error}
async def _get(request: Request, path: str, query: dict, drop: tuple = ()) -> dict:
if not path.startswith("/api/") or ".." in path or "?" in path or "#" in path or path.startswith(_BLOCKED_PREFIXES):
return _text("This path cannot be read through MCP.", True)
headers = {k: v for k in ("authorization", "cookie") if (v := request.headers.get(k))}
url = path + (("?" + urlencode(query)) if query else "")
transport = httpx.ASGITransport(app=request.app)
async with httpx.AsyncClient(transport=transport, base_url="http://opnexus", headers=headers, timeout=30) as client:
response = await client.get(url)
body = response.text
try:
data = response.json()
if drop and isinstance(data, list):
data = [{k: v for k, v in item.items() if k not in drop} if isinstance(item, dict) else item for item in data]
body = json.dumps(data, ensure_ascii=False, indent=1)
except ValueError:
pass
return _text(body, response.status_code >= 400)
async def _call_tool(request: Request, name: str, arguments: dict) -> dict:
tool = next((t for t in TOOLS if t["name"] == name), None)
if tool is None:
return _text(f"Unknown tool: {name}", True)
if name == "api_get":
query = arguments.get("query") or {}
if not isinstance(arguments.get("path"), str) or not isinstance(query, dict):
return _text("path (string) is required.", True)
return await _get(request, arguments["path"], {str(k): str(v) for k, v in query.items()})
path = tool["path"]
if "{host_id}" in path:
host_id = str(arguments.get("host_id", ""))
if not host_id.replace("-", "").isalnum():
return _text("host_id is required (see list_hosts).", True)
path = path.format(host_id=host_id)
query = {key: arguments[key] for key in tool.get("query", ()) if arguments.get(key) not in (None, "")}
return await _get(request, path, query, tool.get("drop", ()))
def _error(request_id, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
async def _handle(request: Request, message: dict) -> dict | None:
"""The JSON-RPC response for one message, None for a notification."""
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or "method" not in message:
return _error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request")
method, request_id = message["method"], message.get("id")
params = message.get("params") or {}
if "id" not in message: # notification (e.g. notifications/initialized): nothing to answer
return None
if method == "initialize":
requested = params.get("protocolVersion")
return {"jsonrpc": "2.0", "id": request_id, "result": {
"protocolVersion": requested if requested in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0],
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "opnexus", "version": APP_VERSION},
"instructions": "Read-only access to an OPNsense fleet managed by OPNexus.",
}}
if method == "ping":
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
if method == "tools/list":
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_list()}}
if method == "tools/call":
arguments = params.get("arguments") or {}
if not isinstance(params.get("name"), str) or not isinstance(arguments, dict):
return _error(request_id, -32602, "name (string) and arguments (object) are required")
return {"jsonrpc": "2.0", "id": request_id, "result": await _call_tool(request, params["name"], arguments)}
return _error(request_id, -32601, f"Method not found: {method}")
@router.post("/mcp")
async def mcp_endpoint(request: Request):
try:
payload = await request.json()
except ValueError:
return JSONResponse(_error(None, -32700, "Parse error"), status_code=400)
if isinstance(payload, list):
replies = [reply for message in payload if (reply := await _handle(request, message)) is not None]
return JSONResponse(replies) if replies else Response(status_code=202)
reply = await _handle(request, payload)
return JSONResponse(reply) if reply is not None else Response(status_code=202)
@router.get("/mcp")
async def mcp_no_stream():
# No server-initiated messages: tell clients not to expect an SSE stream.
return Response(status_code=405, headers={"Allow": "POST"})
+18
View File
@@ -0,0 +1,18 @@
"""GET /api/metrics — Prometheus text exposition (see services/metrics_service.py).
Authenticated like everything else; the intended client is a Prometheus job
with ``authorization: {credentials: <read token>}``.
"""
from fastapi import APIRouter, Depends
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services import metrics_service
router = APIRouter(tags=["metrics"])
@router.get("/metrics", response_class=PlainTextResponse)
async def metrics(db: AsyncSession = Depends(get_db)):
return PlainTextResponse(await metrics_service.render(db), media_type=metrics_service.CONTENT_TYPE)
+90
View File
@@ -0,0 +1,90 @@
import re
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.routers.hosts import _upstream_error
from app.schemas.network import (
HostNetwork, NetworkChangeExecute, NetworkChangePreview, NetworkChangeRequest, NetworkChangeResult,
)
from app.services import network_change_service, network_service
from app.services.policy_service import policy_lock
from app.services.opnsense_client import OpnsenseAPIError
router = APIRouter(prefix="/hosts/{host_id}/network", tags=["network"])
@router.get("", response_model=HostNetwork)
async def host_network(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await network_service.get_host_network(host)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
async def _host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.post("/changes/preview", response_model=NetworkChangePreview)
async def preview_change(host_id: uuid.UUID, payload: NetworkChangeRequest, db: AsyncSession = Depends(get_db)):
host = await _host_or_404(db, host_id)
try:
plan = await network_change_service.preview(db, host, payload.kind, payload.action, payload.uuid, payload.fields)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
return {k: v for k, v in plan.items() if not k.startswith("_")}
@router.post("/changes", response_model=NetworkChangeResult)
async def execute_change(host_id: uuid.UUID, payload: NetworkChangeExecute, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host_or_404(db, host_id)
return await network_change_service.execute(
db, host, payload.kind, payload.action, payload.uuid, payload.fields, payload.preview_token, payload.acknowledge)
@router.get("/template/{kind}")
async def change_template(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
"""Blank form for one kind, with the choices this firewall accepts for its select fields."""
if kind not in network_change_service.FIELDS:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.template(host, kind)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.get("/item/{kind}/{item_uuid}")
async def current_item(host_id: uuid.UUID, kind: str, item_uuid: str, db: AsyncSession = Depends(get_db)):
"""The object's current values on the firewall, for pre-filling an edit form."""
if kind not in network_change_service.FIELDS or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", item_uuid):
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.item(host, kind, item_uuid)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.get("/objects/{kind}")
async def list_objects(host_id: uuid.UUID, kind: str, db: AsyncSession = Depends(get_db)):
"""Rows of a generic object kind (tunables, cron jobs, LAGG, ...), reduced to their display columns."""
if kind not in network_change_service.OBJECT_COLUMNS:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
host = await _host_or_404(db, host_id)
try:
return await network_change_service.list_objects(host, kind)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
+29
View File
@@ -0,0 +1,29 @@
from collections import defaultdict
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.host import OpnsenseHost
from app.services import network_drift_service
router = APIRouter(prefix="/network", tags=["network"])
@router.get("/drift")
async def network_drift(db: AsyncSession = Depends(get_db)):
"""Per cluster: differences in the network configuration between its nodes (live read of every node)."""
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
clusters = []
for name, members in sorted(by_cluster.items()):
if len(members) < 2:
continue
differences, compared, unreachable = await network_drift_service.compute_cluster(members)
clusters.append({"name": name, "hosts": [h.name for h in members], "compared": compared,
"unreachable": unreachable, "differences": differences})
return {"clusters": clusters}
+112 -2
View File
@@ -1,21 +1,26 @@
import uuid
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.database import get_db
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.models.notification import NotificationProblem
from app.models.notification import NotificationChannel, NotificationProblem
from app.schemas.notification import (
NotificationChannelCreate,
NotificationChannelRead,
NotificationChannelUpdate,
NotificationProblemRead,
NotificationSettingsRead,
NotificationSettingsUpdate,
TestNotificationRequest,
)
from app.services import notification_delivery, notification_settings_service
from app.services import notification_channels, notification_delivery, notification_secret_store, notification_settings_service, url_guard
router = APIRouter(prefix="/notifications", tags=["notifications"])
@@ -43,6 +48,11 @@ async def read_settings(db: AsyncSession = Depends(get_db), user: AdminUser = De
@router.put("/settings", response_model=NotificationSettingsRead)
async def update_settings(payload: NotificationSettingsUpdate, db: AsyncSession = Depends(get_db)):
if payload.webhook_url:
try:
payload.webhook_url = url_guard.validate_url(payload.webhook_url)
except ValueError as exc:
raise api_error(422, ErrorCode.NOTIFICATION_WEBHOOK_URL_INVALID, reason=str(exc)) from None
return await notification_settings_service.update_settings(db, payload)
@@ -79,3 +89,103 @@ async def test_notification(payload: TestNotificationRequest, db: AsyncSession =
except notification_delivery.NotificationDeliveryError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
return {"success": True}
# ── Additional channels (ntfy, Gotify, Telegram, Teams, Matrix) ────────────────
def _channel_read(row: NotificationChannel, user: AdminUser) -> NotificationChannelRead:
read = NotificationChannelRead.model_validate(row)
# An ntfy topic or a chat id is as good as a password for whoever knows it: only admins see the settings.
return read if user.role == "admin" else read.model_copy(update={"config": {}})
async def _get_channel(db: AsyncSession, channel_id: uuid.UUID) -> NotificationChannel:
row = await db.get(NotificationChannel, channel_id)
if row is None:
raise api_error(404, ErrorCode.NOTIFICATION_CHANNEL_NOT_FOUND)
return row
def _invalid(exc: ValueError):
return api_error(422, ErrorCode.NOTIFICATION_CHANNEL_INVALID, reason=str(exc))
@router.get("/channels", response_model=list[NotificationChannelRead])
async def list_channels(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
rows = await db.scalars(select(NotificationChannel).order_by(NotificationChannel.name))
return [_channel_read(r, user) for r in rows.all()]
@router.post("/channels", response_model=NotificationChannelRead, status_code=201)
async def create_channel(payload: NotificationChannelCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
try:
config = notification_channels.clean_config(payload.channel_type, payload.config)
secret = notification_channels.clean_secret(payload.channel_type, payload.secret) if payload.secret else None
except ValueError as exc:
raise _invalid(exc) from None
row = NotificationChannel(
name=payload.name.strip(), channel_type=payload.channel_type, enabled=payload.enabled, config=config,
secret_set=secret is not None,
)
row.id = uuid.uuid4()
if secret is None and notification_channels.missing_requirements(row) == "Zugangsdaten fehlen":
raise _invalid(ValueError("Zugangsdaten fehlen"))
db.add(row)
try:
await db.flush()
except IntegrityError:
await db.rollback()
raise api_error(409, ErrorCode.NOTIFICATION_CHANNEL_NAME_TAKEN) from None
if secret is not None:
notification_secret_store.save_secret(notification_channels.secret_name(row.id), secret)
await db.commit()
return _channel_read(row, user)
@router.put("/channels/{channel_id}", response_model=NotificationChannelRead)
async def update_channel(
channel_id: uuid.UUID, payload: NotificationChannelUpdate,
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
):
row = await _get_channel(db, channel_id)
try:
if payload.config is not None:
row.config = notification_channels.clean_config(row.channel_type, payload.config)
new_secret = notification_channels.clean_secret(row.channel_type, payload.secret) if payload.secret else None
except ValueError as exc:
raise _invalid(exc) from None
if payload.name is not None:
row.name = payload.name.strip()
if payload.enabled is not None:
row.enabled = payload.enabled
if payload.clear_secret:
notification_secret_store.delete_secret(notification_channels.secret_name(row.id))
row.secret_set = False
elif new_secret is not None:
notification_secret_store.save_secret(notification_channels.secret_name(row.id), new_secret)
row.secret_set = True
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise api_error(409, ErrorCode.NOTIFICATION_CHANNEL_NAME_TAKEN) from None
return _channel_read(row, user)
@router.delete("/channels/{channel_id}", status_code=204)
async def delete_channel(channel_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
row = await _get_channel(db, channel_id)
notification_secret_store.delete_secret(notification_channels.secret_name(row.id))
await db.delete(row)
await db.commit()
@router.post("/channels/{channel_id}/test")
async def test_channel(channel_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
row = await _get_channel(db, channel_id)
try:
await notification_channels.send_test(row)
except notification_delivery.NotificationDeliveryError as exc:
# The text names the channel and the HTTP status; it never contains a URL or a token.
raise api_error(502, ErrorCode.NOTIFICATION_CHANNEL_TEST_FAILED, reason=str(exc)) from None
return {"success": True}
+168
View File
@@ -0,0 +1,168 @@
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
import logging
import re
import secrets
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import _set_session_cookie, client_ip, current_user
from app.config import settings
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
from app.security_middleware import _origin_allowed
from app.services import auth_service, notification_secret_store, oidc_service
logger = logging.getLogger(__name__)
router = APIRouter(tags=["oidc"])
CALLBACK_PATH = "/api/auth/oidc/callback"
STATE_COOKIE = "opnexus-oidc-state"
# HTTPS deployments (1.29): __Host- (Secure, no Domain, Path=/) so that no sibling SUBDOMAIN or plain-HTTP page can
# plant a state cookie of its own (login CSRF with the attacker's own authorisation). Known limit: cookies are not
# separated by PORT, so script in a firewall GUI shown on a gateway slot port of the same host could still set it
# (BACKLOG S8, accepted: needs a compromised firewall GUI and a mapped IdP account; the real fix is one hostname
# per slot). The browser only accepts the prefix with Path=/, so the cookie travels with every request for its
# 10 minutes; plain-HTTP labs keep the narrow path.
SECURE_STATE_COOKIE = "__Host-opnexus-oidc-state"
STATE_COOKIE_PATH = "/api/auth/oidc"
def state_cookie_name() -> str:
return SECURE_STATE_COOKIE if settings.cookie_secure else STATE_COOKIE
def _state_cookie_path() -> str:
return "/" if settings.cookie_secure else STATE_COOKIE_PATH
def _clear_state_cookie(response) -> None:
response.delete_cookie(state_cookie_name(), path=_state_cookie_path(), secure=settings.cookie_secure, httponly=True, samesite="lax")
if settings.cookie_secure: # a plain-name cookie of an earlier version
response.delete_cookie(STATE_COOKIE, path=STATE_COOKIE_PATH, secure=True, httponly=True, samesite="lax")
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
# must not keep a 30-day session.
SSO_SESSION_SECONDS = 12 * 3600
def _login_error(code: str) -> RedirectResponse:
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
_clear_state_cookie(response)
return response
def _printable(value: str) -> str:
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
@router.get("/auth/oidc/config")
async def public_config(db: AsyncSession = Depends(get_db)):
"""For the login page: is there an SSO button, and what does it say. Nothing else is revealed."""
cfg = await oidc_service.get_settings(db)
ready = oidc_service.configured(cfg)
return {"enabled": ready, "name": cfg.display_name if ready else None}
@router.get("/auth/oidc/start")
async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db)):
cfg = await oidc_service.get_settings(db)
parts = urlsplit(origin)
# The redirect URI is built from the browser's own origin (so any hostname the operator uses works), but only
# an origin this server is actually reachable at is accepted: no path, no credentials, same host as the request.
if (not oidc_service.configured(cfg) or parts.path not in ("", "/") or parts.query or parts.fragment or parts.username
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
return _login_error("disabled")
try:
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
except oidc_service.OidcError as exc:
logger.warning("OIDC start fehlgeschlagen: %s", exc)
return _login_error(exc.code)
response = RedirectResponse(url, status_code=302)
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
# code+state pair is useless without the cookie.
response.set_cookie(state_cookie_name(), state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure,
path=_state_cookie_path())
return response
@router.get("/auth/oidc/callback")
async def callback(request: Request, code: str = "", state: str = "", error: str = "", db: AsyncSession = Depends(get_db)):
ip = client_ip(request)
if auth_service.throttle.retry_after(ip, "oidc") > 0:
return _login_error("throttled")
cfg = await oidc_service.get_settings(db)
if not oidc_service.configured(cfg):
return _login_error("disabled")
try:
if error or not code or not state:
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
# Only the cookie name of this deployment counts (on HTTPS never the plain one); no cookie never matches.
bound = request.cookies.get(state_cookie_name(), "")
if not bound or not secrets.compare_digest(bound.encode(), state.encode()):
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
user = await oidc_service.complete(db, cfg, code, state)
except oidc_service.OidcError as exc:
auth_service.throttle.record_failure(ip, "oidc")
logger.warning("OIDC-Anmeldung abgelehnt (%s): %s", exc.code, exc)
return _login_error(exc.code)
auth_service.throttle.reset(ip, "oidc")
await auth_service.finalize_login(db, user, ip)
token, _ = await auth_service.create_session(
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
)
response = RedirectResponse("/dashboard", status_code=302)
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
_clear_state_cookie(response)
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
return response
def _require_admin(user: AdminUser) -> None:
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
@router.get("/settings/oidc", response_model=OidcSettingsRead)
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
return await oidc_service.get_settings(db)
@router.put("/settings/oidc", response_model=OidcSettingsRead)
async def update(payload: OidcSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
cfg = await oidc_service.get_settings(db)
for field in ("enabled", "display_name", "issuer", "client_id", "scopes", "groups_claim", "admin_group", "operator_group", "read_only_group"):
value = getattr(payload, field)
if value is not None:
setattr(cfg, field, value.strip() if isinstance(value, str) else value)
if payload.clear_client_secret:
notification_secret_store.delete_secret(oidc_service.SECRET_NAME)
cfg.client_secret_set = False
elif payload.client_secret is not None:
notification_secret_store.save_secret(oidc_service.SECRET_NAME, payload.client_secret)
cfg.client_secret_set = True
await db.commit()
oidc_service.clear_caches()
return cfg
@router.post("/settings/oidc/test")
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
"""Fetch the provider's discovery document and signing keys: proves the issuer URL, not the login."""
_require_admin(user)
cfg = await oidc_service.get_settings(db)
if not cfg.issuer:
raise api_error(422, ErrorCode.OIDC_TEST_FAILED, reason="Kein Aussteller eingetragen")
try:
oidc_service.clear_caches()
doc = await oidc_service.discovery(cfg.issuer)
await oidc_service._jwk_for(doc, None)
except oidc_service.OidcError as exc:
raise api_error(502, ErrorCode.OIDC_TEST_FAILED, reason=str(exc)) from exc
return {"success": True, "authorization_endpoint": doc["authorization_endpoint"]}
+6 -2
View File
@@ -20,6 +20,7 @@ from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.one_to_one_deploy_service import confirm_one_to_one, deploy_one_to_one, undeploy_one_to_one, revert_deployment
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/one-to-one", tags=["one-to-one"])
@@ -79,6 +80,9 @@ def _to_read(rule: OneToOneRule, hosts: list[OpnsenseHost], deployments: list[On
)
add_adopt_routes(router, kind="one_to_one", model=OneToOneRule, load_options=_load_options, to_read=_to_read, read_model=OneToOneRead)
@router.get("", response_model=list[OneToOneRead])
async def list_one_to_one_rules(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(OneToOneRule).options(*_load_options()))
@@ -168,7 +172,7 @@ async def delete_one_to_one_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(
try:
await undeploy_one_to_one(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -268,4 +272,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_one_to_one_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+1 -1
View File
@@ -176,7 +176,7 @@ async def delete_instance(instance_id: uuid.UUID, db: AsyncSession = Depends(get
try:
await undeploy_instance(db, instance)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(instance, attribute_names=["deployments"])
openvpn_key_store.delete_secret(instance.id)
await db.delete(instance)
+6 -2
View File
@@ -21,6 +21,7 @@ from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.outbound_nat_deploy_service import confirm_outbound_nat, deploy_outbound_nat, undeploy_outbound_nat, revert_deployment
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/outbound-nat", tags=["outbound-nat"])
@@ -82,6 +83,9 @@ def _to_read(rule: OutboundNatRule, hosts: list[OpnsenseHost], deployments: list
)
add_adopt_routes(router, kind="outbound_nat", model=OutboundNatRule, load_options=_load_options, to_read=_to_read, read_model=OutboundNatRead)
@router.get("", response_model=list[OutboundNatRead])
async def list_outbound_nat_rules(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(OutboundNatRule).options(*_load_options()))
@@ -175,7 +179,7 @@ async def delete_outbound_nat_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
try:
await undeploy_outbound_nat(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -275,7 +279,7 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_outbound_nat_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
@router.get("/hosts/{host_id}/mode")
+142
View File
@@ -0,0 +1,142 @@
"""Management overlay API (see services/overlay_service.py). Writes and the pre-shared key are admin-only."""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.models.overlay import OverlayPeer
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.schemas.overlay import OverlaySettingsRead, OverlaySettingsUpdate, PeerCreate, PeerRead, PeerUpdate
from app.services import overlay_service as svc
router = APIRouter(prefix="/overlay", tags=["overlay"])
def _require_admin(user: AdminUser) -> None:
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
async def _settings_read(db: AsyncSession) -> dict:
cfg = await svc.get_settings(db)
net = svc.parse_subnet(cfg.subnet)
return {
"enabled": cfg.enabled, "endpoint": cfg.endpoint, "subnet": cfg.subnet, "listen_port": cfg.listen_port,
"hub_public_key": svc.hub_public_key() if cfg.enabled else None, "hub_address": str(svc.hub_address(net)),
"hub_state": svc.hub_state(svc.read_status()),
}
async def _peers(db: AsyncSession) -> list[dict]:
status = svc.read_status()
rows = (await db.execute(select(OverlayPeer, OpnsenseHost.name).outerjoin(OpnsenseHost, OverlayPeer.host_id == OpnsenseHost.id).order_by(OverlayPeer.ip))).all()
return [
{"id": p.id, "name": p.name, "ip": p.ip, "public_key": p.public_key, "host_id": p.host_id, "host_name": host_name,
**svc.peer_status(status, p.public_key)}
for p, host_name in rows
]
@router.get("")
async def overview(db: AsyncSession = Depends(get_db)):
return {"settings": await _settings_read(db), "peers": await _peers(db)}
@router.put("/settings", response_model=OverlaySettingsRead)
async def update_settings(payload: OverlaySettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
cfg = await svc.get_settings(db)
if payload.subnet is not None and payload.subnet.strip() != cfg.subnet:
try:
svc.parse_subnet(payload.subnet)
except svc.OverlayError as exc:
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
if (await db.scalar(select(OverlayPeer.id).limit(1))) is not None:
raise api_error(409, ErrorCode.OVERLAY_SUBNET_LOCKED) # peers already hold addresses in the old network
cfg.subnet = payload.subnet.strip()
for field in ("enabled", "listen_port"):
value = getattr(payload, field)
if value is not None:
setattr(cfg, field, value)
if payload.endpoint is not None:
cfg.endpoint = payload.endpoint.strip() or None
if cfg.enabled and not cfg.endpoint:
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Ohne Endpunkt wissen die Firewalls nicht, wohin sie sich verbinden sollen")
await db.commit()
await svc.sync(db)
return await _settings_read(db)
@router.post("/peers", status_code=201)
async def create_peer(payload: PeerCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
cfg = await svc.get_settings(db)
taken = set((await db.scalars(select(OverlayPeer.ip))).all())
try:
ip = svc.next_free_ip(svc.parse_subnet(cfg.subnet), taken)
except svc.OverlayError as exc:
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
peer = OverlayPeer(name=payload.name.strip(), ip=ip)
db.add(peer)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise api_error(409, ErrorCode.OVERLAY_NAME_TAKEN) from None
await svc.sync(db)
return {"id": peer.id, "name": peer.name, "ip": peer.ip, "instructions": svc.instructions(cfg, peer)}
@router.get("/peers/{peer_id}/instructions")
async def peer_instructions(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
peer = await db.get(OverlayPeer, peer_id)
if peer is None:
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
return svc.instructions(await svc.get_settings(db), peer)
@router.patch("/peers/{peer_id}")
async def update_peer(peer_id: uuid.UUID, payload: PeerUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
peer = await db.get(OverlayPeer, peer_id)
if peer is None:
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
if payload.public_key is not None:
key = payload.public_key.strip()
if key and not svc.valid_public_key(key):
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist kein WireGuard-Schlüssel (44 Zeichen, Base64)")
if key and key == svc.hub_public_key():
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist der Schlüssel des Hubs, nicht der der Firewall")
peer.public_key = key or None
if payload.clear_host:
peer.host_id = None
elif payload.host_id is not None:
if await db.get(OpnsenseHost, payload.host_id) is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
peer.host_id = payload.host_id
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason="Dieser Schlüssel gehört schon zu einem anderen Eintrag") from None
await svc.sync(db)
return {"ok": True}
@router.delete("/peers/{peer_id}", status_code=204)
async def delete_peer(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
_require_admin(user)
peer = await db.get(OverlayPeer, peer_id)
if peer is None:
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
await db.delete(peer)
await db.commit()
svc.delete_peer_secret(peer_id)
await svc.sync(db)
+1 -1
View File
@@ -294,7 +294,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_hierarchy_rule(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
+6 -2
View File
@@ -20,6 +20,7 @@ from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.port_forward_deploy_service import confirm_port_forward, deploy_port_forward, undeploy_port_forward, revert_deployment
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets, validate_hierarchy_group
from app.routers.adopt_routes import add_adopt_routes
router = APIRouter(prefix="/port-forward", tags=["port-forward"])
@@ -80,6 +81,9 @@ def _to_read(rule: PortForwardRule, hosts: list[OpnsenseHost], deployments: list
)
add_adopt_routes(router, kind="port_forward", model=PortForwardRule, load_options=_load_options, to_read=_to_read, read_model=PortForwardRead)
@router.get("", response_model=list[PortForwardRead])
async def list_port_forward_rules(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(PortForwardRule).options(*_load_options()))
@@ -170,7 +174,7 @@ async def delete_port_forward_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
try:
await undeploy_port_forward(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -270,4 +274,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_port_forward_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+434
View File
@@ -0,0 +1,434 @@
"""Firmware rollouts in waves (1.25): plans and runs.
Roles (enforced in AuthMiddleware, see api_token_service.is_rollout): everybody signed in reads;
operators prepare plans; starting/activating a plan and resuming/approving/skipping/aborting a run
need the deploy right (admin, or operator while operators may deploy). Pausing is deliberately NOT
a rollout action: anybody who may write at all may always brake.
"""
import uuid
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.maintenance_window import MaintenanceWindow
from app.models.policy import DeviceGroup
from app.models.rollout import RolloutPlan, RolloutPlanWave, RolloutRun
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import audit_service, rollout_engine, rollout_health, rollout_service
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/rollouts", tags=["rollouts"])
ACTIVE_RUN = rollout_engine.ACTIVE
class WaveIn(BaseModel):
label: str = Field(min_length=1, max_length=60)
group_ids: list[uuid.UUID] = Field(min_length=1, max_length=20)
soak_minutes: int = Field(default=30, ge=0, le=rollout_service.MAX_SOAK_MINUTES)
carp_handover: bool = False
require_approval: bool = False
class PlanIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str = Field(default="", max_length=2000)
action: Literal["update", "major_update"] = "update"
window_id: uuid.UUID | None = None
auto_start: bool = False
gate_checks: dict[str, bool] = Field(default_factory=dict)
compliance_mode: Literal["warn", "fail"] = "warn"
waves: list[WaveIn] = Field(min_length=1, max_length=rollout_service.MAX_WAVES)
class TokenIn(BaseModel):
preview_token: str = Field(min_length=64, max_length=64)
async def _plan_or_404(db: AsyncSession, plan_id: uuid.UUID) -> RolloutPlan:
plan = await rollout_service.load_plan(db, plan_id)
if plan is None:
raise api_error(404, ErrorCode.ROLLOUT_PLAN_NOT_FOUND)
return plan
async def read_plan(db: AsyncSession, plan: RolloutPlan, resolved: dict | None = None) -> dict:
resolved = resolved or await rollout_service.resolve(db, plan)
active = plan.activated_at is not None
last_run = await db.scalar(select(RolloutRun).where(RolloutRun.plan_id == plan.id).order_by(RolloutRun.created_at.desc()).limit(1))
return {
**rollout_service.definition(plan),
"description": plan.description,
"waves": [{"id": str(w.id), "position": w.position, "label": w.label, "soak_minutes": w.soak_minutes,
"carp_handover": w.carp_handover, "require_approval": w.require_approval,
"group_ids": [str(g.id) for g in w.groups], "groups": [g.name for g in w.groups],
"host_count": len(resolved["waves"][i]["hosts"]) if i < len(resolved["waves"]) else 0}
for i, w in enumerate(plan.waves)],
"active": active, "activated_by": plan.activated_by, "activated_at": plan.activated_at,
# Activated but the plan, its groups or its window changed since: the automatic start is held back.
"fingerprint_ok": (not active) or plan.activated_fingerprint == rollout_service.fingerprint(plan, resolved),
"auto_start_error": plan.auto_start_error,
"window": resolved["window"], "errors": resolved["errors"], "warnings": resolved["warnings"],
"host_count": sum(len(w["hosts"]) for w in resolved["waves"]),
"last_run": {"id": str(last_run.id), "status": last_run.status, "created_at": last_run.created_at} if last_run else None,
"created_by": plan.created_by, "created_at": plan.created_at, "updated_at": plan.updated_at,
}
async def _apply(db: AsyncSession, plan: RolloutPlan, payload: PlanIn) -> None:
name = payload.name.strip()
if not name:
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="name")
other = await db.scalar(select(RolloutPlan.id).where(RolloutPlan.name == name))
if other is not None and other != plan.id:
raise api_error(409, ErrorCode.ROLLOUT_NAME_TAKEN)
if payload.window_id is not None and await db.get(MaintenanceWindow, payload.window_id) is None:
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="window_id")
if any(k not in rollout_health.CHECKS for k in payload.gate_checks):
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="gate_checks")
wanted = {g for w in payload.waves for g in w.group_ids}
groups = {g.id: g for g in (await db.scalars(select(DeviceGroup).where(DeviceGroup.id.in_(wanted)))).all()}
if len(groups) != len(wanted):
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="group_ids")
plan.name, plan.description, plan.action = name, payload.description.strip(), payload.action
plan.window_id, plan.auto_start = payload.window_id, payload.auto_start
plan.gate_checks = {k: bool(v) for k, v in payload.gate_checks.items()}
plan.compliance_mode = payload.compliance_mode
plan.waves = [
RolloutPlanWave(position=i, label=w.label.strip() or f"{i + 1}", soak_minutes=w.soak_minutes, carp_handover=w.carp_handover,
require_approval=w.require_approval, groups=[groups[g] for g in dict.fromkeys(w.group_ids)])
for i, w in enumerate(payload.waves)
]
# Any edit disarms the automatic start: an operator cannot quietly change what an admin activated.
plan.activated_at = plan.activated_by = plan.activated_by_id = plan.activated_fingerprint = None
plan.auto_start_error = None
@router.get("/plans")
async def list_plans(db: AsyncSession = Depends(get_db)):
plans = (await db.scalars(select(RolloutPlan).order_by(RolloutPlan.name))).all()
return [await read_plan(db, p) for p in plans]
@router.post("/plans", status_code=201)
async def create_plan(payload: PlanIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
plan = RolloutPlan(created_by=request.state.user.username, created_at=datetime.now(timezone.utc), waves=[])
await _apply(db, plan, payload)
db.add(plan)
await db.commit()
return await read_plan(db, await _plan_or_404(db, plan.id))
@router.get("/plans/{plan_id}")
async def get_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
return await read_plan(db, await _plan_or_404(db, plan_id))
@router.put("/plans/{plan_id}")
async def update_plan(plan_id: uuid.UUID, payload: PlanIn, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
plan = await _plan_or_404(db, plan_id)
was_active = plan.activated_at is not None
await _apply(db, plan, payload)
await db.commit()
if was_active:
await audit_service.persist(kind="rollout", action="deactivate", status="completed", entity_id=str(plan.id),
entity_name=plan.name, message="Plan geändert: automatischer Start deaktiviert")
return await read_plan(db, await _plan_or_404(db, plan_id))
@router.delete("/plans/{plan_id}", status_code=204)
async def delete_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
plan = await _plan_or_404(db, plan_id)
if await db.scalar(select(RolloutRun.id).where(RolloutRun.plan_id == plan.id, RolloutRun.status.in_(ACTIVE_RUN)).limit(1)):
raise api_error(409, ErrorCode.ROLLOUT_PLAN_BUSY)
await db.delete(plan)
await db.commit()
@router.post("/plans/{plan_id}/preview")
async def preview_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
plan = await _plan_or_404(db, plan_id)
resolved = await rollout_service.resolve(db, plan)
return {**resolved, "preview_token": rollout_service.fingerprint(plan, resolved),
"can_start": not resolved["errors"], "plan": await read_plan(db, plan, resolved)}
async def _checked_token(db: AsyncSession, plan: RolloutPlan, token: str) -> tuple[dict, str]:
resolved = await rollout_service.resolve(db, plan)
current = rollout_service.fingerprint(plan, resolved)
if token != current:
raise api_error(409, ErrorCode.ROLLOUT_PREVIEW_STALE)
if resolved["errors"]:
raise api_error(422, ErrorCode.ROLLOUT_PLAN_INVALID, reason=resolved["errors"][0]["code"])
return resolved, current
@router.post("/plans/{plan_id}/activate")
async def activate_plan(plan_id: uuid.UUID, payload: TokenIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
plan = await _plan_or_404(db, plan_id)
if not plan.auto_start:
raise api_error(422, ErrorCode.ROLLOUT_INVALID, field="auto_start")
_resolved, current = await _checked_token(db, plan, payload.preview_token)
plan.activated_by, plan.activated_at, plan.activated_fingerprint = request.state.user.username, datetime.now(timezone.utc), current
plan.activated_by_id = request.state.user.id
plan.auto_start_error = None
await db.commit()
await audit_service.persist(kind="rollout", action="activate", status="completed", entity_id=str(plan.id),
entity_name=plan.name, details={"fingerprint": current})
return await read_plan(db, await _plan_or_404(db, plan_id))
@router.post("/plans/{plan_id}/deactivate")
async def deactivate_plan(plan_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
plan = await _plan_or_404(db, plan_id)
plan.activated_by = plan.activated_by_id = plan.activated_at = plan.activated_fingerprint = None
await db.commit()
await audit_service.persist(kind="rollout", action="deactivate", status="completed", entity_id=str(plan.id), entity_name=plan.name)
return await read_plan(db, await _plan_or_404(db, plan_id))
# ── runs ─────────────────────────────────────────────────────────────────────
class StartIn(TokenIn):
confirm_name: str = Field(min_length=1, max_length=120)
acknowledge_unattended: Literal[True]
class ReasonIn(BaseModel):
reason: str = Field(default="", max_length=500)
class SkipIn(BaseModel):
host_id: uuid.UUID
reason: str = Field(min_length=3, max_length=500)
class AbortIn(BaseModel):
reason: str = Field(min_length=3, max_length=500)
confirm_name: str = Field(min_length=1, max_length=120)
def _read_run_summary(run: RolloutRun) -> dict:
counts: dict[str, int] = {}
for h in run.hosts:
counts[h.status] = counts.get(h.status, 0) + 1
return {
"id": str(run.id), "plan_id": str(run.plan_id) if run.plan_id else None, "plan_name": run.plan_name,
"action": run.action, "status": run.status, "paused_from": run.paused_from, "current_wave": run.current_wave,
"wave_count": len(run.waves), "current_wave_label": next((w.label for w in run.waves if w.index == run.current_wave), None),
"target_version": run.target_version, "pause_reason": run.pause_reason, "pause_detail": run.pause_detail,
"started_by": run.started_by, "trigger": run.trigger, "window_occurrence": run.window_occurrence,
"created_at": run.created_at, "finished_at": run.finished_at, "step_at": run.step_at, "host_counts": counts,
"active": run.status in rollout_engine.ACTIVE,
}
async def read_run(db: AsyncSession, run: RolloutRun) -> dict:
scheds = {}
ids = [h.scheduled_maintenance_id for h in run.hosts if h.scheduled_maintenance_id]
if ids:
scheds = {s.id: s for s in (await db.scalars(select(ScheduledMaintenance).where(ScheduledMaintenance.id.in_(ids)))).all()}
soak_until = next((w.soak_until for w in run.waves if w.index == run.current_wave), None)
return {
**_read_run_summary(run),
"soak_until": soak_until, "progress": {k: v for k, v in (run.progress or {}).items()
if k in ("gate_recheck_at", "cluster_recheck_at", "blocked_since", "resume_gate",
"cluster_gate", "preflight_started", "resume_recheck_at")},
"snapshot": run.plan_snapshot,
"waves": [{"index": w.index, "label": w.label, "status": w.status, "gate_result": w.gate_result,
"gate_attempts": w.gate_attempts, "soak_until": w.soak_until, "approved_by": w.approved_by,
"started_at": w.started_at, "finished_at": w.finished_at,
"spec": run.plan_snapshot["waves"][w.index] if w.index < len(run.plan_snapshot.get("waves", [])) else None}
for w in run.waves],
"hosts": [{"id": str(h.id), "host_id": str(h.host_id) if h.host_id else None, "host_name": h.host_name,
"cluster_name": h.cluster_name, "wave_index": h.wave_index, "order": h.order, "status": h.status,
"version_before": h.version_before, "version_after": h.version_after, "detail": h.detail,
"started_at": h.started_at, "finished_at": h.finished_at,
"schedule": ({"id": str(s.id), "status": s.status, "phase": s.phase, "carp_handover": s.carp_handover,
"host_operation_id": str(s.host_operation_id) if s.host_operation_id else None}
if (s := scheds.get(h.scheduled_maintenance_id)) else None)}
for h in sorted(run.hosts, key=lambda h: (h.wave_index, h.order, h.host_name))],
}
async def _cancel_if_pending(db: AsyncSession, sched_id) -> bool:
"""Cancel a host's schedule only if it has not fired. Locked: the poll loop may be firing it right now
(fire_due holds the row), and a blind write would turn a running action into "cancelled"."""
if sched_id is None:
return False
sched = await db.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == sched_id)
.with_for_update().execution_options(populate_existing=True))
if sched is not None and sched.status == "pending":
sched.status = "cancelled"
return True
return False
async def _run_or_404(db: AsyncSession, run_id: uuid.UUID, *, lock: bool = False) -> RolloutRun:
stmt = select(RolloutRun).where(RolloutRun.id == run_id).execution_options(populate_existing=True)
if lock:
stmt = stmt.with_for_update()
run = await db.scalar(stmt)
if run is None:
raise api_error(404, ErrorCode.ROLLOUT_RUN_NOT_FOUND)
return run
def _not_now(run: RolloutRun):
return api_error(409, ErrorCode.ROLLOUT_ACTION_NOT_ALLOWED, status=run.status)
async def _done(db: AsyncSession, run: RolloutRun, action: str, status: str, request: Request, **details) -> dict:
run.revision += 1
await db.commit()
await audit_service.persist(kind="rollout", action=action, status=status, entity_id=str(run.id), entity_name=run.plan_name,
details={"wave": run.current_wave, **details})
return await read_run(db, await _run_or_404(db, run.id))
@router.post("/plans/{plan_id}/start", status_code=201)
async def start_plan(plan_id: uuid.UUID, payload: StartIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
plan = await _plan_or_404(db, plan_id)
if payload.confirm_name != plan.name:
raise api_error(409, ErrorCode.ROLLOUT_CONFIRM_NAME)
resolved, _token = await _checked_token(db, plan, payload.preview_token)
if (other := await rollout_engine.active_run(db)) is not None:
# One run at a time: two runs could otherwise touch the same cluster at once.
raise api_error(409, ErrorCode.ROLLOUT_ALREADY_ACTIVE, plan=other.plan_name)
run = await rollout_engine.create_run(db, plan, resolved, started_by=request.state.user.username,
started_by_id=request.state.user.id, trigger="manual")
await db.commit()
await audit_service.persist(kind="rollout", action="start", status="started", entity_id=str(run.id), entity_name=plan.name,
details={"trigger": "manual", "hosts": sum(len(w["hosts"]) for w in resolved["waves"]),
"waves": [w["label"] for w in resolved["waves"]]})
return await read_run(db, await _run_or_404(db, run.id))
@router.get("/runs")
async def list_runs(db: AsyncSession = Depends(get_db)):
runs = (await db.scalars(select(RolloutRun).order_by(RolloutRun.created_at.desc()).limit(50))).all()
return [_read_run_summary(r) for r in runs]
@router.get("/status")
async def rollout_status(db: AsyncSession = Depends(get_db)):
"""For /updates and the host pages: the active run and the hosts it still owns."""
run = await rollout_engine.active_run(db)
if run is None:
return {"active": None, "host_ids": [], "current_host_ids": []}
return {
"active": _read_run_summary(run),
"host_ids": [str(h.host_id) for h in run.hosts if h.host_id and h.status in ("queued", "blocked", "running", "verifying", "failed")],
"current_host_ids": [str(h.host_id) for h in run.hosts if h.host_id and h.wave_index == run.current_wave
and h.status in ("queued", "blocked", "running", "verifying", "failed")],
}
@router.get("/runs/{run_id}")
async def get_run(run_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
return await read_run(db, await _run_or_404(db, run_id))
@router.post("/runs/{run_id}/pause")
async def pause_run(run_id: uuid.UUID, payload: ReasonIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
run = await _run_or_404(db, run_id, lock=True)
if run.status not in rollout_engine.ACTIVE or run.status == "paused":
raise _not_now(run)
# A brake that lets the next host fire anyway is no brake: a step not fired yet goes back to the queue.
requeued = [h.host_name for h in run.hosts if await rollout_engine.requeue_if_pending(db, h)]
rollout_engine.pause(run, "manual", by=request.state.user.username, text=payload.reason.strip()[:300])
return await _done(db, run, "pause", "paused", request, reason="manual", text=payload.reason.strip()[:300], requeued=requeued)
@router.post("/runs/{run_id}/resume")
async def resume_run(run_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
run = await _run_or_404(db, run_id, lock=True)
if run.status != "paused":
raise _not_now(run)
failed = [h.host_name for h in run.hosts if h.status == "failed"]
if failed:
# A failed host is never retried by itself: check it, then skip it explicitly.
raise api_error(409, ErrorCode.ROLLOUT_HOST_FAILED, hosts=", ".join(failed))
previous = run.started_by
# Whoever resumes takes over the responsibility (and the permission check) from here on.
run.started_by, run.started_by_id = request.state.user.username, request.state.user.id
run.status = "resuming"
run.pause_reason = run.pause_detail = None
run.progress = {**(run.progress or {}), "resume_attempts": 0, "resume_recheck_at": None, "paused_at": None}
return await _done(db, run, "resume", "resuming", request, previous_owner=previous)
@router.post("/runs/{run_id}/approve")
async def approve_wave(run_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
run = await _run_or_404(db, run_id, lock=True)
if run.status != "awaiting_approval":
raise _not_now(run)
wave = rollout_engine.wave_row(run, run.current_wave)
wave.approved_by, wave.status = request.state.user.username, "preflight"
run.started_by, run.started_by_id = request.state.user.username, request.state.user.id
run.status = "preflight"
run.progress = {**(run.progress or {}), "preflight_started": None, "preflight_requested_at": None}
return await _done(db, run, "approve", "approved", request, wave_label=wave.label)
@router.post("/runs/{run_id}/skip-host")
async def skip_host(run_id: uuid.UUID, payload: SkipIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
run = await _run_or_404(db, run_id, lock=True)
if run.status not in rollout_engine.ACTIVE:
raise _not_now(run)
host = next((h for h in run.hosts if h.host_id == payload.host_id), None)
if host is None:
raise api_error(404, ErrorCode.ROLLOUT_HOST_NOT_IN_RUN)
if host.status in ("running", "verifying"):
if not await _cancel_if_pending(db, host.scheduled_maintenance_id):
raise api_error(409, ErrorCode.ROLLOUT_HOST_RUNNING, host=host.host_name)
# not fired yet: nothing happened on the firewall
elif host.status not in ("queued", "blocked", "failed"):
raise api_error(409, ErrorCode.ROLLOUT_HOST_RUNNING, host=host.host_name)
host.status = "skipped"
host.detail = {"code": "skipped_by", "params": {"user": request.state.user.username, "reason": payload.reason.strip()[:300]}}
host.finished_at = datetime.now(timezone.utc)
return await _done(db, run, "skip_host", "skipped", request, host=host.host_name, reason=payload.reason.strip()[:300])
@router.post("/runs/{run_id}/abort")
async def abort_run(run_id: uuid.UUID, payload: AbortIn, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
run = await _run_or_404(db, run_id, lock=True)
if run.status not in rollout_engine.ACTIVE:
raise _not_now(run)
if payload.confirm_name != run.plan_name:
raise api_error(409, ErrorCode.ROLLOUT_CONFIRM_NAME)
now = datetime.now(timezone.utc)
still_running = []
for host in run.hosts:
if host.status in ("running", "verifying") and host.scheduled_maintenance_id:
if await _cancel_if_pending(db, host.scheduled_maintenance_id):
host.status, host.finished_at = "cancelled", now
else:
still_running.append(host.host_name) # an action already on the firewall is finished in a controlled way
elif host.status in ("queued", "blocked"):
host.status, host.finished_at = "cancelled", now
for wave in run.waves:
if wave.status not in ("passed", "skipped"):
wave.status = "cancelled"
run.status, run.finished_at = "cancelled", now
run.pause_reason, run.pause_detail = "aborted", {"by": request.state.user.username, "reason": payload.reason.strip()[:300],
"still_running": still_running}
return await _done(db, run, "abort", "cancelled", request, reason=payload.reason.strip()[:300], still_running=still_running)
+128
View File
@@ -0,0 +1,128 @@
"""Rule analysis: which rule decides a packet (read-only, GET so ``read`` tokens and MCP can use it).
Nothing is stored and nothing is written to a firewall; every request reads the rules, the
loaded pf ruleset and the interfaces (cached for 60 s per firewall, ``refresh=true`` skips
the cache) and evaluates them with services/rule_analysis/engine.py.
"""
import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.models.policy import DeviceGroup
from app.models.rule_history import LiveRuleState
from app.services import rule_history_service
from app.services.rule_analysis import service
logger = logging.getLogger(__name__)
router = APIRouter(tags=["rule-analysis"])
_PARALLEL = 5
def _query(src: str, dst: str, proto: str, sport: str | None, dport: str | None, icmptype: str | None,
iface: str | None, direction: str, egress: str | None):
return service.build_query(src=src, dst=dst, proto=proto, sport=sport, dport=dport, icmptype=icmptype,
iface=iface, direction=direction, egress=egress)
async def _run(host: OpnsenseHost, q, refresh: bool) -> dict:
head = {"host_id": str(host.id), "host_name": host.name, "cluster_name": host.cluster_name}
try:
return {**head, **await service.analyze_host(host, q, refresh=refresh), "error": None}
except HTTPException as exc:
return {**head, "error": exc.detail}
except Exception as exc: # noqa: BLE001 - one unreachable firewall must not fail the fleet answer
logger.info("rule analysis failed for %s: %s", host.name, exc)
return {**head, "error": {"code": ErrorCode.ANALYSE_FIREWALL_NICHT_LESBAR.value, "params": {}}}
@router.get("/hosts/{host_id}/rule-analysis/match")
async def host_match(
host_id: uuid.UUID, src: str, dst: str, proto: str = "tcp", sport: str | None = None, dport: str | None = None,
icmptype: str | None = None, iface: str | None = None, direction: str = "in", egress: str | None = None,
refresh: bool = False, db: AsyncSession = Depends(get_db),
):
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
q = _query(src, dst, proto, sport, dport, icmptype, iface, direction, egress)
result = await _run(host, q, refresh)
error = result.get("error")
if error:
status = 422 if error.get("code") == ErrorCode.ANALYSE_INTERFACE_UNBEKANNT.value else 502
raise HTTPException(status_code=status, detail=error)
return result
@router.get("/rule-analysis/match")
async def fleet_match(
src: str, dst: str, proto: str = "tcp", sport: str | None = None, dport: str | None = None,
icmptype: str | None = None, iface: str | None = None, direction: str = "in", egress: str | None = None,
host_ids: str | None = Query(None, description="comma separated host ids; default: all enabled hosts"),
group_id: uuid.UUID | None = None, refresh: bool = False, db: AsyncSession = Depends(get_db),
):
q = _query(src, dst, proto, sport, dport, icmptype, iface, direction, egress)
if group_id is not None:
group = await db.scalar(select(DeviceGroup).options(selectinload(DeviceGroup.hosts)).where(DeviceGroup.id == group_id))
if group is None:
raise api_error(404, ErrorCode.GRUPPE_NICHT_GEFUNDEN)
hosts = sorted(group.hosts, key=lambda h: h.name)
elif host_ids:
try:
ids = [uuid.UUID(part.strip()) for part in host_ids.split(",") if part.strip()]
except ValueError:
raise api_error(422, ErrorCode.ANALYSE_PARAMETER_UNGUELTIG, field="host_ids", value=host_ids[:64]) from None
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.id.in_(ids)).order_by(OpnsenseHost.name))).all())
if len(hosts) != len(set(ids)):
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
else:
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
gate = asyncio.Semaphore(_PARALLEL)
async def one(host):
async with gate:
return await _run(host, q, refresh)
return list(await asyncio.gather(*(one(h) for h in hosts)))
@router.get("/hosts/{host_id}/rule-analysis/cleanup")
async def host_cleanup(
host_id: uuid.UUID, unused_days: int = Query(30, ge=1, le=3650, description="observation window for 'unused' / 'disabled for a long time'"),
refresh: bool = False, db: AsyncSession = Depends(get_db),
):
"""Cleanup candidates (unused, shadowed, duplicate, disabled for a long time) with confidence and whether
each rule could be offered for removal. Read-only; removal is not part of this endpoint."""
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await service.cleanup_candidates(db, host, unused_days=unused_days, refresh=refresh)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001 - an unreachable firewall is a 502, not a crash
logger.info("cleanup candidates failed for %s: %s", host.name, exc)
raise api_error(502, ErrorCode.ANALYSE_FIREWALL_NICHT_LESBAR) from exc
@router.get("/hosts/{host_id}/live-rules/{rule_uuid}/history")
async def live_rule_history(host_id: uuid.UUID, rule_uuid: str, limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db)):
"""What happened to one rule on one firewall: observed changes (also hand-made ones), saved revisions of
rules OPNexus manages and the audit entries, newest first. Reads the database only."""
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
baseline = await db.get(LiveRuleState, (host_id, rule_history_service.BASELINE_KEY))
return {
"host_id": str(host.id), "rule_uuid": rule_uuid.lower()[:64],
"watching_since": baseline.first_seen_at if baseline else None,
"events": await rule_history_service.rule_timeline(db, host, rule_uuid, limit),
}
+46 -1
View File
@@ -1,3 +1,4 @@
import json
import uuid
from fastapi import APIRouter, Depends, Body
@@ -17,6 +18,9 @@ from app.services.alias_service import check_alias_consistency, describe_alias_c
from app.services.interface_service import check_interface_consistency
from app.services.opnsense_client import OpnsenseAPIError
from app.services.rule_deploy_service import confirm_rule, deploy_rule, undeploy_rule, revert_deployment
from app.services import rule_adopt_service
from app.services.adopt_common import LINK_ERRORS, link_api_error
from app.schemas.rule import AdoptRequest, UnmanagedRule
router = APIRouter(prefix="/rules", tags=["rules"])
@@ -59,6 +63,7 @@ def _to_read(
destination_net=rule.destination_net,
destination_port=rule.destination_port,
enabled=rule.enabled,
log=rule.log,
created_at=rule.created_at,
updated_at=rule.updated_at,
target_host_ids=[host.id for host in rule.targets],
@@ -84,6 +89,45 @@ async def list_rules(db: AsyncSession = Depends(get_db)):
return [_to_read(rule, rule.targets, rule.deployments) for rule in rules]
@router.get("/unmanaged", response_model=list[UnmanagedRule])
async def list_unmanaged_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
"""Rules found on the host that OPNexus does not manage yet (live read)."""
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
return await rule_adopt_service.list_unmanaged(db, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
@router.post("/adopt", response_model=RuleRead, status_code=201)
async def adopt_rule(payload: AdoptRequest, db: AsyncSession = Depends(get_db)):
"""Takes over a hand-made rule. Writes nothing to the firewall."""
await policy_lock(db)
host = await db.get(OpnsenseHost, payload.host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
try:
rule = await rule_adopt_service.adopt(db, host, payload.rule_uuid, link_to=payload.link_to)
except rule_adopt_service.RuleNotFoundOnHost as exc:
raise api_error(404, ErrorCode.REGEL_NICHT_GEFUNDEN) from exc
except rule_adopt_service.NotAdoptable as exc:
raise api_error(409, ErrorCode.RULE_NOT_ADOPTABLE, reasons=", ".join(exc.reasons),
reason_details=json.dumps(exc.details, ensure_ascii=False)) from exc
except LINK_ERRORS as exc:
raise link_api_error(exc) from exc
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.HOST_REQUEST_FAILED, host=host.name) from exc
await db.commit()
# The new object sits in the session without its relationships loaded; db.get() would return it as-is.
rule = (await db.execute(
select(FirewallRule).where(FirewallRule.id == rule.id).options(*_load_options())
.execution_options(populate_existing=True)
)).scalar_one()
return _to_read(rule, rule.targets, rule.deployments)
@router.post("", response_model=RuleRead, status_code=201)
async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
@@ -101,6 +145,7 @@ async def create_rule(payload: RuleCreate, db: AsyncSession = Depends(get_db)):
destination_net=payload.destination_net,
destination_port=payload.destination_port,
enabled=payload.enabled,
log=payload.log,
targets=hosts,
groups=groups,
)
@@ -172,7 +217,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_rule(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
+25 -2
View File
@@ -11,6 +11,8 @@ from app.errors import ErrorCode, api_error
from app.database import get_db
from app.models import OpnsenseHost
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services.host_maintenance_service import peers
from app.services.host_status_service import get_statuses
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"])
@@ -20,6 +22,10 @@ class Schedule(BaseModel):
action: Literal["reboot", "update", "major_update"]
run_at: datetime
acknowledge_unattended: Literal[True]
# Hand the traffic over to the CARP partner first and switch back afterwards (cluster nodes only).
carp_handover: bool = False
# Check the result automatically afterwards and close the operation (no manual "Wartung abschließen").
auto_verify: bool = False
def _read(sched: ScheduledMaintenance) -> dict:
@@ -27,6 +33,9 @@ def _read(sched: ScheduledMaintenance) -> dict:
"id": sched.id, "host_id": sched.host_id, "action": sched.action, "run_at": sched.run_at,
"status": sched.status, "actor": sched.actor, "created_at": sched.created_at,
"result": sched.result, "host_operation_id": sched.host_operation_id,
"carp_handover": sched.carp_handover, "phase": sched.phase,
"auto_verify": sched.auto_verify, "origin": sched.origin,
"rollout_run_id": ((sched.progress or {}).get("rollout") or {}).get("run_id"),
}
@@ -61,9 +70,21 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc)
if run_at <= now:
raise api_error(422, ErrorCode.ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN)
if payload.carp_handover:
if not host.cluster_name or len(await peers(db, host)) < 2:
raise api_error(409, ErrorCode.CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER)
elif host.cluster_name:
# Without a handover, fire time refuses every cluster node that is not the CARP BACKUP, so a
# plan for the current MASTER can only ever end as "failed" at 3 a.m. Say so now.
# Only a *known* MASTER is rejected here: an unreachable node or an unclear role
# is still judged by the full check when the plan fires.
statuses = await get_statuses(await peers(db, host))
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
raise api_error(409, ErrorCode.GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH)
sched = ScheduledMaintenance(
host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at,
actor=request.state.user.username, created_at=now,
actor=request.state.user.username, created_at=now, carp_handover=payload.carp_handover,
auto_verify=payload.auto_verify,
)
db.add(sched)
await db.commit()
@@ -74,7 +95,9 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
async def cancel_scheduled(host_id: uuid.UUID, schedule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
await _host_or_404(db, host_id)
sched = await db.get(ScheduledMaintenance, schedule_id)
# Locked and re-read: the poll loop may be firing this very row right now (it holds the lock meanwhile).
sched = await db.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == schedule_id)
.with_for_update().execution_options(populate_existing=True))
if not sched or sched.host_id != host_id:
raise api_error(404, ErrorCode.GEPLANTE_WARTUNG_NICHT_GEFUNDEN)
if sched.status != "pending":
+13
View File
@@ -31,6 +31,19 @@ async def _control(host_id: uuid.UUID, name: str, action: str, fn, db: AsyncSess
await policy_lock(db)
host = await host_or_404(db, host_id)
await ensure_no_maintenance(db, [host])
# The UI greys out locked services, but the API must not rely on that:
# unknown names are accepted by the firewall with "ok", and locked ones
# (pf, webgui, routing, ...) can be stopped through it.
try:
listing = await client.search_services(host)
except client.OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
rows = listing.get("rows", []) if isinstance(listing, dict) else []
row = next((r for r in rows if name in (r.get("id"), r.get("name"))), None)
if row is None:
raise api_error(404, ErrorCode.SERVICE_NOT_FOUND, service=name)
if row.get("locked") in (1, "1", True):
raise api_error(409, ErrorCode.SERVICE_LOCKED, service=name)
await audit_service.persist(
kind="host", action=action, status="requested",
host_id=str(host.id), host_name=host.name,
+5 -2
View File
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
class Preferences(BaseModel):
theme: Literal['light','dark','system'] | None = None
language: Literal['de','en'] | None = None
setup_done: bool | None = None
@router.get('')
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
preferences=await db.get(UserPreferences,user.id)
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
'language':preferences.language if preferences else 'de',
'setup_done':preferences.setup_done if preferences else False,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
current = await db.get(UserPreferences, user.id)
theme = payload.theme or (current.theme if current else 'system')
language = payload.language or (current.language if current else 'de')
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
await db.commit()
return {'username':username,'theme':theme,'language':language,
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
+55
View File
@@ -0,0 +1,55 @@
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.host import OpnsenseHost
from app.routers.hosts import _upstream_error
from app.services import snapshot_service
from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/hosts/{host_id}/snapshots", tags=["snapshots"])
class SnapshotCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
note: str = Field(default="", max_length=200)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.get("")
async def list_snapshots(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id)
try:
return await snapshot_service.list_snapshots(host)
except OpnsenseAPIError as exc:
raise _upstream_error(host, exc) from exc
@router.post("", status_code=201)
async def create_snapshot(host_id: uuid.UUID, payload: SnapshotCreate, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, host_id)
await ensure_no_maintenance(db, [host])
return await snapshot_service.create(host, payload.name, payload.note)
@router.delete("/{snapshot_uuid}", status_code=204)
async def delete_snapshot(host_id: uuid.UUID, snapshot_uuid: str, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host(db, host_id)
await ensure_no_maintenance(db, [host])
if not snapshot_uuid.replace("-", "").isalnum() or len(snapshot_uuid) > 64:
raise api_error(404, ErrorCode.NETWORK_ITEM_NOT_FOUND)
await snapshot_service.delete(host, snapshot_uuid)
+156 -4
View File
@@ -1,12 +1,164 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.services import update_check_service
from app.config import settings
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.services import rollout_engine
from app.services import update_check_service, updater_client
router = APIRouter(prefix="/system", tags=["system"])
@router.get("/version-check")
async def version_check() -> dict:
async def version_check(db: AsyncSession = Depends(get_db)) -> dict:
"""Ist eine neuere OPNexus-Version veröffentlicht? Aus, solange
UPDATE_CHECK_URL nicht gesetzt ist (dann ohne ausgehende Anfrage)."""
return await update_check_service.check()
return await update_check_service.check(db)
def _admin(user: AdminUser) -> AdminUser:
# Der Updater ist Administration der Installation selbst — auch das reine
# Lesen (Logs, Rollback-Optionen) bleibt Admins vorbehalten.
if user.role != "admin":
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
return user
def _major(version: str | None) -> int | None:
try:
return int((version or "").split(".")[0])
except ValueError:
return None
class UpdateCheckRead(BaseModel):
enabled: bool
feed_url: str
managed_by_server: bool
class UpdateCheckUpdate(BaseModel):
enabled: bool | None = None
feed_url: str | None = Field(default=None, max_length=500)
async def _update_check_settings(db: AsyncSession) -> UpdateCheckRead:
row = await update_check_service.get_settings(db)
managed = update_check_service.managed_by_server()
return UpdateCheckRead(
enabled=True if managed else row.enabled,
feed_url=settings.update_check_url.strip() if managed else row.feed_url,
managed_by_server=managed,
)
@router.get("/update-check", response_model=UpdateCheckRead)
async def read_update_check(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
"""Versionsprüfung für „Einstellungen › OPNexus aktualisieren“ (nur Admins)."""
_admin(user)
return await _update_check_settings(db)
@router.put("/update-check", response_model=UpdateCheckRead)
async def update_update_check(payload: UpdateCheckUpdate, db: AsyncSession = Depends(get_db),
user: AdminUser = Depends(current_user)):
_admin(user)
if update_check_service.managed_by_server():
raise api_error(409, ErrorCode.UPDATE_CHECK_MANAGED_BY_SERVER)
row = await update_check_service.get_settings(db)
if payload.feed_url is not None:
url = payload.feed_url.strip()
if not update_check_service.valid_feed_url(url):
raise api_error(422, ErrorCode.UPDATE_CHECK_URL_INVALID)
row.feed_url = url
if payload.enabled is not None:
row.enabled = payload.enabled
await db.commit()
update_check_service.reset_cache()
return await _update_check_settings(db)
class UpdateRequest(BaseModel):
version: str = Field(min_length=5, max_length=20)
@router.get("/updater")
async def updater_status(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)) -> dict:
"""Zustand für den Abschnitt "OPNexus aktualisieren" in den Einstellungen:
Versionshinweis des Release-Feeds plus (falls aktiviert) der Updater."""
_admin(user)
check = await update_check_service.check(db)
result: dict = {
"available": False, "reason": None, "mode": None,
"current": check["current"], "latest": check["latest"], "update_available": check["update_available"],
"release_url": check["release_url"], "feed_enabled": check["enabled"],
"feed_error": check["error"], "checked_at": check["checked_at"],
"can_update": False, "major_change": False,
"job": None, "phases": [], "log": [], "services": {},
"rollback_available": False, "rollback_loses_data": False, "rollback_version": None,
# A restart in the middle of a firmware rollout is safe (its state is in the database), but the
# UI warns: the run pauses its 15-second steps for as long as OPNexus is down.
"active_rollouts": await rollout_engine.count_active(db),
}
try:
updater = await updater_client.status()
except updater_client.UpdaterUnavailable as exc:
result["reason"] = str(exc)
# Without the updater the install mode is still known from the image registry setting; the UI uses it
# to not offer enabling the updater on an install that builds from source (it could not update anyway).
result["mode"] = "release" if settings.opnexus_image_repo.strip() else "local"
return result
except updater_client.UpdaterRejected:
result["reason"] = "unreachable"
return result
mode = updater.get("mode")
running = (updater.get("job") or {}).get("state") == "running"
result.update(
available=True, mode=mode, job=updater.get("job"), phases=updater.get("phases") or [],
log=updater.get("log") or [], services=updater.get("services") or {},
rollback_available=bool(updater.get("rollback_available")),
rollback_loses_data=bool(updater.get("rollback_loses_data")),
rollback_version=((updater.get("last_update") or {}).get("prev_version")),
)
# Hauptversionssprünge nur bewusst über die Kommandozeile (Release-Notes lesen).
result["major_change"] = bool(check["latest"]) and _major(check["latest"]) != _major(check["current"])
result["can_update"] = (
mode == "release" and not running and bool(check["update_available"]) and not result["major_change"]
)
return result
@router.post("/updater/update", status_code=202)
async def updater_update(payload: UpdateRequest, db: AsyncSession = Depends(get_db),
user: AdminUser = Depends(current_user)) -> dict:
"""Aktualisiert OPNexus auf die vom Release-Feed gemeldete neueste stabile
Version. Beliebige Versionen werden bewusst nicht angenommen."""
_admin(user)
check = await update_check_service.check(db)
if not check["update_available"] or payload.version.lstrip("v") != check["latest"]:
raise api_error(409, ErrorCode.UPDATE_NOT_OFFERED)
if _major(check["latest"]) != _major(check["current"]):
raise api_error(409, ErrorCode.UPDATE_MAJOR_CHANGE)
return await _forward(updater_client.start_update(check["latest"]))
@router.post("/updater/rollback", status_code=202)
async def updater_rollback(user: AdminUser = Depends(current_user)) -> dict:
_admin(user)
return await _forward(updater_client.start_rollback())
async def _forward(call) -> dict:
try:
return await call
except updater_client.UpdaterUnavailable:
raise api_error(503, ErrorCode.UPDATER_UNAVAILABLE)
except updater_client.UpdaterRejected as rejected:
if rejected.code == "busy":
raise api_error(409, ErrorCode.UPDATER_BUSY)
raise api_error(409, ErrorCode.UPDATER_REJECTED, reason=rejected.code)
+68
View File
@@ -0,0 +1,68 @@
"""Manage personal API tokens (see services/api_token_service.py for scopes).
Session-only: an API token cannot reach these endpoints (AuthMiddleware), so a
leaked token can never mint a replacement or list/revoke its siblings. A
read_only user may manage their own tokens, but only with read scope (an operator up to deploy).
"""
import uuid
from fastapi import APIRouter, Depends, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.api_token import ApiToken
from app.models.user import AdminUser
from app.routers.auth import _account_failure, _account_success, _check_account_budget, current_user
from app.schemas.api_token import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, TokenScope
from app.services import api_token_service, auth_service
router = APIRouter(prefix="/tokens", tags=["tokens"])
def _read(token: ApiToken, owner: str) -> dict:
return {
"id": token.id, "name": token.name, "scope": token.scope, "prefix": token.prefix, "owner": owner,
"created_at": token.created_at, "expires_at": token.expires_at,
"last_used_at": token.last_used_at, "last_used_ip": token.last_used_ip,
}
@router.get("", response_model=list[ApiTokenRead])
async def list_tokens(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
"""Own tokens; an admin sees everybody's (to be able to revoke them)."""
query = select(ApiToken, AdminUser.username).join(AdminUser, ApiToken.user_id == AdminUser.id)
if user.role != "admin":
query = query.where(ApiToken.user_id == user.id)
rows = (await db.execute(query.order_by(ApiToken.created_at.desc()))).all()
return [_read(token, owner) for token, owner in rows]
@router.post("", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED)
async def create_token(
payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
):
allowed = {"admin": (TokenScope.read, TokenScope.deploy, TokenScope.admin), "operator": (TokenScope.read, TokenScope.deploy)}
if payload.scope not in allowed.get(user.role, (TokenScope.read,)):
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_TOO_HIGH)
_check_account_budget(user, "create-token")
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
_account_failure(user, "create-token")
raise api_error(401, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
_account_success(user, "create-token")
token, raw = await api_token_service.create(
db, user, name=payload.name.strip(), scope=payload.scope.value, expires_in_days=payload.expires_in_days
)
return {**_read(token, user.username), "token": raw}
@router.delete("/{token_id}", status_code=204)
async def revoke_token(
token_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
):
token = await db.get(ApiToken, token_id)
if token is None or (token.user_id != user.id and user.role != "admin"):
raise api_error(404, ErrorCode.API_TOKEN_NOT_FOUND)
await db.delete(token)
await db.commit()
+6 -1
View File
@@ -9,7 +9,7 @@ from app.database import get_db
from app.models.user import AdminUser
from app.routers.auth import current_user
from app.schemas.user import UserCreate, UserPasswordReset, UserRead, UserRoleUpdate
from app.services import auth_service, totp_service
from app.services import api_token_service, auth_service, totp_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -47,6 +47,8 @@ async def update_role(user_id: uuid.UUID, payload: UserRoleUpdate, db: AsyncSess
target = await db.get(AdminUser, user_id)
if target is None:
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
if target.auth_source == "oidc":
raise api_error(409, ErrorCode.OIDC_MANAGED_USER) # the identity provider's groups decide, at every login
if target.role == "admin" and payload.role.value != "admin" and await _admin_count(db, exclude_id=target.id) == 0:
raise api_error(409, ErrorCode.MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN)
target.role = payload.role.value
@@ -75,10 +77,13 @@ async def reset_password(user_id: uuid.UUID, payload: UserPasswordReset, db: Asy
target = await db.get(AdminUser, user_id)
if target is None:
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN)
if target.auth_source == "oidc":
raise api_error(409, ErrorCode.OIDC_MANAGED_USER)
target.password_hash = await auth_service.hash_password_async(payload.new_password)
# A reset is the answer to a suspected compromise: every session of the
# account ends with the old password, atomically with the new hash.
await auth_service.revoke_sessions(db, target.id, commit=False)
await api_token_service.revoke_all(db, target.id, commit=False)
await db.commit()
return target
+98
View File
@@ -0,0 +1,98 @@
"""Site variables of one host: the values for {{NAME}} placeholders, and which placeholders its objects use."""
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.errors import ErrorCode, api_error
from app.models.alias import FirewallAlias
from app.models.host import OpnsenseHost
from app.models.host_variable import HostVariable
from app.models.one_to_one import OneToOneRule
from app.models.outbound_nat import OutboundNatRule
from app.models.policy import DeviceGroup
from app.models.port_forward import PortForwardRule
from app.models.rule import FirewallRule
from app.services import alias_deploy_service, one_to_one_deploy_service, outbound_nat_deploy_service
from app.services import port_forward_deploy_service, rule_deploy_service, variables_service
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
from app.services.policy_service import effective_hosts
router = APIRouter(prefix="/hosts/{host_id}/variables", tags=["variables"])
_KINDS = (
("rule", FirewallRule, rule_deploy_service),
("alias", FirewallAlias, alias_deploy_service),
("port_forward", PortForwardRule, port_forward_deploy_service),
("one_to_one", OneToOneRule, one_to_one_deploy_service),
("outbound_nat", OutboundNatRule, outbound_nat_deploy_service),
)
class VariablesUpdate(BaseModel):
variables: dict[str, str] = Field(default_factory=dict, max_length=200)
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if host is None:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
async def _usage(db: AsyncSession, host: OpnsenseHost) -> dict[str, list[dict]]:
"""name -> objects (that are deployed to this host) which use it."""
used: dict[str, list[dict]] = {}
for kind, model, service in _KINDS:
entities = (await db.execute(
select(model).options(selectinload(model.targets), selectinload(model.groups).selectinload(DeviceGroup.hosts))
)).scalars().unique().all()
await hydrate_hierarchy_targets(db, entities)
for entity in entities:
if host.id not in {h.id for h in effective_hosts(entity)}:
continue
label = getattr(entity, "description", None) or getattr(entity, "name", None) or str(entity.id)
for name in variables_service.names_in_payload(service._base_payload(entity)):
used.setdefault(name, []).append({"kind": kind, "id": str(entity.id), "label": label})
return used
async def _read(db: AsyncSession, host: OpnsenseHost) -> dict:
values = variables_service.host_values(host)
used = await _usage(db, host)
names = sorted(set(values) | set(used))
return {"variables": [
{"name": n, "value": values.get(n), "used_by": used.get(n, []), "missing": n in used and n not in values}
for n in names
]}
@router.get("")
async def read(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
return await _read(db, await _host(db, host_id))
@router.put("")
async def replace(host_id: uuid.UUID, payload: VariablesUpdate, db: AsyncSession = Depends(get_db)):
"""Replace this host's variables with exactly the given set."""
host = await _host(db, host_id)
for name, value in payload.variables.items():
if not variables_service.NAME_RE.fullmatch(name):
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Name „{name}“: Großbuchstaben, Ziffern und _, beginnend mit einem Buchstaben")
if not variables_service.VALUE_RE.fullmatch(value):
raise api_error(422, ErrorCode.VARIABLE_INVALID, reason=f"Wert von {name}: 1–255 Zeichen aus Buchstaben, Ziffern und . : / , @ + - _ Leerzeichen")
existing = {v.name: v for v in host.variables}
for name in set(existing) - set(payload.variables):
host.variables.remove(existing[name])
for name, value in payload.variables.items():
if name in existing:
existing[name].value = value
else:
host.variables.append(HostVariable(name=name, value=value))
await db.commit()
await db.refresh(host)
return await _read(db, host)
+1 -1
View File
@@ -187,7 +187,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
try:
await undeploy_tunnel(db, tunnel)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(tunnel, attribute_names=["deployments"])
wireguard_key_store.delete_private_key(tunnel.id)
wireguard_key_store.delete_psk(tunnel.id)
+76
View File
@@ -0,0 +1,76 @@
import uuid
from pydantic import BaseModel, Field
class AdoptObjectRequest(BaseModel):
host_id: uuid.UUID
# Link to this existing central object instead of creating a new one (services/adopt_link_service.py).
# Declared before `uuid`: that field's name shadows the module inside the class body.
link_to: uuid.UUID | None = None
uuid: str = Field(min_length=1, max_length=64) # the object's UUID on the firewall
class AdoptAllRequest(BaseModel):
host_id: uuid.UUID
# Link entries that have exactly one exact central twin instead of creating a duplicate.
link_existing: bool = False
class LinkDiff(BaseModel):
field: str # model field ("content", "destination_port", ...) or "variables" (a {{VARIABLE}} has no value on this host)
central: str
live: str
class LinkTarget(BaseModel):
"""An existing central object that a live object could be linked to (exact match) or is similar to."""
id: uuid.UUID
name: str
targets: list[str] # names of the hosts it is deployed to
diff: list[LinkDiff] = Field(default_factory=list) # empty for an exact match
class UnmanagedObject(BaseModel):
"""An alias / NAT rule found on a host that OPNexus does not manage yet (see object_adopt_service.py)."""
uuid: str
title: str
enabled: bool
adoptable: bool # can be adopted on its own right now
# Adoptable in the same "adopt all" run although not on its own (an alias whose referenced aliases come first).
bulk_adoptable: bool
# Codes such as "unsupported_type" or "no_target"; the UI turns them into text.
reasons: list[str]
reason_details: dict[str, str] = Field(default_factory=dict) # code -> extra text (alias names, type, ...)
# Settings OPNexus does not manage that stay untouched on the firewall (log, pool options, tags, ...).
kept: list[str] = Field(default_factory=list)
# Facts for display, depend on the object type.
info: dict[str, str] = Field(default_factory=dict)
# Central objects whose content, resolved for this host, equals the live object exactly (first = preferred).
matches: list[LinkTarget] = Field(default_factory=list)
# Objects with the same name/description but different content: a hint with the differing fields, never linked.
similar: list[LinkTarget] = Field(default_factory=list)
linkable: bool = False # can be linked to `matches[0]` (or another match) on its own
# What "adopt all" would do: "link" | "new" | "skip" (None only on the adopt-all result).
bulk_action: str | None = None
class AdoptedObject(BaseModel):
uuid: str
title: str
id: uuid.UUID
linked: bool = False # linked to an existing central object instead of newly created
class SkippedObject(BaseModel):
uuid: str
title: str
reasons: list[str]
reason_details: dict[str, str] = Field(default_factory=dict)
class AdoptAllResult(BaseModel):
adopted: list[AdoptedObject]
skipped: list[SkippedObject]
+40
View File
@@ -0,0 +1,40 @@
import uuid
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field
class TokenScope(str, Enum):
read = "read"
deploy = "deploy"
admin = "admin"
class ApiTokenCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
# Least privilege by default.
scope: TokenScope = TokenScope.read
# None = never expires. The UI defaults to 90 days.
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
# Creating a long-lived credential needs the account password again.
current_password: str = Field(min_length=1, max_length=1024)
class ApiTokenRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
scope: TokenScope
prefix: str
owner: str
created_at: datetime
expires_at: datetime | None
last_used_at: datetime | None
last_used_ip: str | None
class ApiTokenCreated(ApiTokenRead):
# The only time the raw token is ever returned.
token: str
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class AuditExportRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
syslog_enabled: bool
syslog_host: str | None
syslog_port: int
syslog_protocol: Literal["udp", "tcp", "tls"]
webhook_enabled: bool
webhook_url: str | None
webhook_signing_secret_set: bool
last_success_at: datetime | None
last_error: str | None
last_error_at: datetime | None
class AuditExportUpdate(BaseModel):
"""Partial update; the signing secret is write-only (set it or clear it)."""
syslog_enabled: bool | None = None
syslog_host: str | None = Field(default=None, max_length=255)
syslog_port: int | None = Field(default=None, ge=1, le=65535)
syslog_protocol: Literal["udp", "tcp", "tls"] | None = None
webhook_enabled: bool | None = None
webhook_url: str | None = Field(default=None, max_length=500)
webhook_signing_secret: str | None = Field(default=None, min_length=1)
clear_webhook_signing_secret: bool = False
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class BackupStatusRead(BaseModel):
"""What every signed-in user may see: no endpoints, no keys."""
model_config = ConfigDict(from_attributes=True)
scheduled_enabled: bool
interval_hours: int
keep_count: int
offsite_enabled: bool
last_run_at: datetime | None
last_offsite_ok_at: datetime | None
last_offsite_error: str | None
class BackupSettingsRead(BackupStatusRead):
s3_endpoint: str | None
s3_region: str
s3_bucket: str | None
s3_prefix: str
s3_access_key: str | None
s3_path_style: bool
s3_secret_set: bool
passphrase_set: bool
class BackupSettingsUpdate(BaseModel):
"""Partial update; the S3 secret key and the passphrase are write-only."""
scheduled_enabled: bool | None = None
interval_hours: int | None = Field(default=None, ge=1, le=24 * 30)
keep_count: int | None = Field(default=None, ge=3, le=1000)
offsite_enabled: bool | None = None
s3_endpoint: str | None = Field(default=None, max_length=255)
s3_region: str | None = Field(default=None, min_length=1, max_length=64)
s3_bucket: str | None = Field(default=None, max_length=255)
s3_prefix: str | None = Field(default=None, max_length=255)
s3_access_key: str | None = Field(default=None, max_length=255)
s3_path_style: bool | None = None
s3_secret_key: str | None = Field(default=None, min_length=1, max_length=512)
clear_s3_secret: bool = False
passphrase: str | None = Field(default=None, min_length=12, max_length=512)
+7
View File
@@ -3,6 +3,8 @@ from datetime import datetime
from pydantic import BaseModel
from app.schemas.gateway import GatewayHealth
class DashboardHost(BaseModel):
id: uuid.UUID
@@ -10,6 +12,11 @@ class DashboardHost(BaseModel):
status: str
carp_status: str | None
cluster_name: str | None
# From the last resource sample (no live call); None when there is no sample from the last 3 minutes.
gateways: list[GatewayHealth] | None = None
# Readings the newest fresh sample could not take because the API key lacks the right (gateways, services, ...);
# None = no fresh sample (or one from before this existed), [] = nothing missing.
api_rights_missing: list[str] | None = None
class PendingAction(BaseModel):
+33
View File
@@ -0,0 +1,33 @@
from typing import Literal
from pydantic import BaseModel
# online: all fine · degraded: OPNsense flags loss and/or latency above the gateway's own limits ·
# offline: down · forced_down: switched off by the admin on purpose · unknown: nothing measured
# (monitoring disabled) or the verdict is still pending.
GatewayState = Literal["online", "degraded", "offline", "forced_down", "unknown"]
class GatewayHealth(BaseModel):
name: str
address: str | None = None
status: str # OPNsense' raw verdict: none | down | force_down | delay | loss | delay+loss | …
state: GatewayState
loss: float | None = None # percent
delay: float | None = None # milliseconds
stddev: float | None = None # milliseconds
monitor: str | None = None # the monitored address
alert_enabled: bool = True # False: the gateway alert is switched off for this gateway (Settings, 1.29)
class GatewayAlertUpdate(BaseModel):
enabled: bool
class GatewayAlertState(BaseModel):
name: str
alert_enabled: bool
class ApiRightsState(BaseModel):
missing: list[str] | None = None # names of the refused readings (gateways, services, swap, cpu); None = unknown
+11
View File
@@ -47,6 +47,8 @@ class HostUpdate(BaseModel):
recreate is still the way to rotate a host's API key for now."""
name: str | None = Field(default=None, min_length=1, max_length=120)
# New address of the firewall, e.g. after its management IP was changed.
base_url: str | None = Field(default=None, min_length=1, max_length=255)
verify_tls: bool | None = None
tls_ca_pem: str | None = Field(default=None, max_length=32768)
clear_tls_pin: bool = False
@@ -56,6 +58,11 @@ class HostUpdate(BaseModel):
policy_hierarchy_group_id: uuid.UUID | None = None
clear_policy_hierarchy_group_id: bool = False # same ambiguity as clear_cluster_name above
@field_validator("base_url")
@classmethod
def _base_url_is_safe(cls, value: str | None) -> str | None:
return validate_base_url(value) if value is not None else None
class HostRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -123,6 +130,9 @@ CertStatus = Literal["valid", "expiring_soon", "expired", "unknown"]
class HostCertificate(BaseModel):
uuid: str
# OPNsense's own cross-references (OpenVPN cert/ca, Trust-Store caref) use
# this id, not the row UUID.
refid: str = ""
descr: str
common_name: str | None
issuer: str
@@ -135,6 +145,7 @@ class HostCertificate(BaseModel):
class HostCa(BaseModel):
uuid: str
refid: str = ""
descr: str
common_name: str | None
valid_from: datetime | None
+183
View File
@@ -0,0 +1,183 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class NetAddress(BaseModel):
address: str # CIDR, e.g. 192.168.1.2/24
# Set for CARP addresses only
vhid: str | None = None
carp_status: str | None = None
class NetInterface(BaseModel):
device: str
identifier: str | None # OPNsense's own name (wan, lan, opt3) — None when unassigned
description: str
status: str
enabled: bool
is_physical: bool
mtu: str | None
mac: str | None
media: str | None
vlan_tag: str | None
vlan_parent: str | None
ipv4_type: str | None # "Static IPv4" / "DHCP" ... (assigned interfaces only)
ipv6_type: str | None
addresses: list[NetAddress]
pending_action: str | None = None
# Listed in the firewall's interface assignment (wan, lan, optN) — only those can be edited or removed.
assigned: bool = False
# This is the interface the firewall answers OPNexus on. Changing it can cut the connection.
is_management: bool = False
class NetVlan(BaseModel):
uuid: str
parent: str
tag: str
pcp: str | None
description: str
device: str | None
class NetVip(BaseModel):
"""No CARP password here, by design: OPNsense returns it in plain text."""
uuid: str
interface: str
interface_label: str
mode: str
address: str
vhid: str | None
advbase: str | None
advskew: str | None
description: str
class NetGateway(BaseModel):
uuid: str
name: str
description: str
interface: str
interface_label: str
ip_protocol: str
address: str
is_default: bool
disabled: bool
priority: str | None
status: str
class NetRoute(BaseModel):
uuid: str
network: str
gateway: str
description: str
disabled: bool
class NetDnsHost(BaseModel):
uuid: str
hostname: str
domain: str
record_type: str
server: str
enabled: bool
description: str
class NetDhcpHost(BaseModel):
uuid: str
host: str
domain: str
ip: str
hwaddr: str
lease_time: str
description: str
class NetKeaSubnet(BaseModel):
uuid: str
subnet: str
pools: str
routers: str
dns_servers: str
description: str
class NetKeaReservation(BaseModel):
uuid: str
subnet: str
ip_address: str
hw_address: str
hostname: str
description: str
class HostNetwork(BaseModel):
host_id: str
host_name: str
interfaces: list[NetInterface]
vlans: list[NetVlan]
vips: list[NetVip]
gateways: list[NetGateway]
routes: list[NetRoute]
dns_hosts: list[NetDnsHost] = []
dhcp_hosts: list[NetDhcpHost] = []
kea_subnets: list[NetKeaSubnet] = []
kea_reservations: list[NetKeaReservation] = []
management_device: str | None
# The firewall's own API can set interface IPs/types (OPNsense >= ~26.7.6). Older 26.7.x
# only lists assignments. Detected from the data, not from a version number.
can_configure_interfaces: bool
# Sections the firewall could not deliver (e.g. older OPNsense without that API) — never silently empty.
unavailable: list[str]
class NetworkChangeRequest(BaseModel):
kind: Literal["vlan", "vip", "gateway", "route", "interface", "dns_host", "dhcp_host", "kea_subnet", "kea_reservation",
"tunable", "cron_job", "syslog_destination", "gateway_group", "lagg", "bridge", "loopback", "gre", "gif", "vxlan", "dns_forward", "dhcp_range"]
action: Literal["add", "set", "del"]
# Goes into a URL path, so only plain identifiers.
uuid: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,64}$")
fields: dict[str, Any] = Field(default_factory=dict)
class NetworkChangeExecute(NetworkChangeRequest):
preview_token: str = Field(min_length=64, max_length=64)
acknowledge: bool = False
class NetworkChangeRisk(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class NetworkFieldChange(BaseModel):
field: str
before: str | None
after: str | None
class NetworkChangePreview(BaseModel):
token: str
kind: str
action: str
uuid: str | None
host_id: str
host_name: str
changes: list[NetworkFieldChange]
risks: list[NetworkChangeRisk]
warnings: list[NetworkChangeRisk]
blocks: list[NetworkChangeRisk]
requires_acknowledgement: bool
management_device: str | None
can_execute: bool
class NetworkChangeResult(BaseModel):
status: str
uuid: str | None
verified: bool
mismatches: list[str]
backup_id: str
+120 -2
View File
@@ -1,16 +1,25 @@
import re
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.config import settings
from app.models.notification import DEFAULT_SERVICE_WATCHLIST
SmtpSecurity = Literal["none", "starttls", "ssl"]
ProblemType = Literal[
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
"scheduled_maintenance_failed",
"backup_stale",
"backup_offsite_failed",
"compliance_finding",
"gateway_down", "resource_threshold", "service_down", "api_rights_missing",
"rollout_paused", "rollout_start_failed",
]
ChannelType = Literal["ntfy", "gotify", "telegram", "teams", "matrix"]
class NotificationSettingsRead(BaseModel):
@@ -36,11 +45,35 @@ class NotificationSettingsRead(BaseModel):
maintenance_unknown_enabled: bool
carp_health_enabled: bool
nat_drift_enabled: bool
network_drift_enabled: bool = True
carp_auto_revert_failed_enabled: bool
scheduled_maintenance_failed_enabled: bool = True
backup_failed_enabled: bool = True
compliance_alerts_enabled: bool = False
gateway_down_enabled: bool = True
resource_threshold_enabled: bool = False
service_down_enabled: bool = False
rollout_problems_enabled: bool = True
api_rights_enabled: bool = True
gateway_alert_muted: list[str] = Field(default_factory=list)
gateway_loss_threshold_pct: int = 20
gateway_delay_threshold_ms: int = 500
disk_threshold_pct: int = 90
memory_threshold_pct: int = 95
swap_threshold_pct: int = 50
load_per_cpu_threshold: float = 2.0
alert_confirm_samples: int = 3
resource_confirm_samples: int = 5
service_watchlist: list[str] = Field(default_factory=lambda: list(DEFAULT_SERVICE_WATCHLIST))
poll_interval_seconds: int
updated_at: datetime
@field_validator("service_watchlist", mode="before")
@classmethod
def _default_watchlist(cls, value):
return list(DEFAULT_SERVICE_WATCHLIST) if value is None else value
class NotificationSettingsUpdate(BaseModel):
"""Partial update — everything optional, only provided fields change.
@@ -71,7 +104,40 @@ class NotificationSettingsUpdate(BaseModel):
maintenance_unknown_enabled: bool | None = None
carp_health_enabled: bool | None = None
nat_drift_enabled: bool | None = None
network_drift_enabled: bool | None = None
carp_auto_revert_failed_enabled: bool | None = None
scheduled_maintenance_failed_enabled: bool | None = None
backup_failed_enabled: bool | None = None
compliance_alerts_enabled: bool | None = None
gateway_down_enabled: bool | None = None
resource_threshold_enabled: bool | None = None
service_down_enabled: bool | None = None
rollout_problems_enabled: bool | None = None
api_rights_enabled: bool | None = None
# 0 switches that single threshold off.
gateway_loss_threshold_pct: int | None = Field(default=None, ge=0, le=100)
gateway_delay_threshold_ms: int | None = Field(default=None, ge=0, le=60000)
disk_threshold_pct: int | None = Field(default=None, ge=0, le=100)
memory_threshold_pct: int | None = Field(default=None, ge=0, le=100)
swap_threshold_pct: int | None = Field(default=None, ge=0, le=100)
load_per_cpu_threshold: float | None = Field(default=None, ge=0, le=1000)
alert_confirm_samples: int | None = Field(default=None, ge=1, le=60)
resource_confirm_samples: int | None = Field(default=None, ge=1, le=60)
service_watchlist: list[str] | None = Field(default=None, max_length=100)
@field_validator("service_watchlist")
@classmethod
def _clean_watchlist(cls, value):
if value is None:
return None
names = []
for raw in value:
name = str(raw).strip()
if not re.fullmatch(r"[A-Za-z0-9._+-]{1,64}", name):
raise ValueError("Dienstnamen bestehen aus Buchstaben, Ziffern und . _ + -")
if name not in names:
names.append(name)
return names
poll_interval_seconds: int | None = Field(
default=None, ge=settings.notification_poll_min_interval_seconds
@@ -94,3 +160,55 @@ class NotificationProblemRead(BaseModel):
class TestNotificationRequest(BaseModel):
channel: Literal["webhook", "email"]
class NotificationChannelRead(BaseModel):
"""Never carries the credential; ``config`` is withheld from non-admins (an ntfy topic is a password)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
channel_type: ChannelType
enabled: bool
config: dict
secret_set: bool
created_at: datetime
updated_at: datetime
def _channel_name(value: str | None) -> str | None:
if value is None:
return None
if not value.strip():
raise ValueError("Name darf nicht leer sein")
return value.strip()
class NotificationChannelCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
channel_type: ChannelType
enabled: bool = True
config: dict = Field(default_factory=dict)
# Token, or for Teams the webhook address. Write-only.
secret: str | None = Field(default=None, max_length=2100)
@field_validator("name")
@classmethod
def _clean_name(cls, value):
return _channel_name(value)
class NotificationChannelUpdate(BaseModel):
"""Partial: omitted fields stay. ``secret`` replaces the credential, ``clear_secret`` removes it."""
name: str | None = Field(default=None, min_length=1, max_length=100)
enabled: bool | None = None
config: dict | None = None
secret: str | None = Field(default=None, max_length=2100)
clear_secret: bool = False
@field_validator("name")
@classmethod
def _clean_name(cls, value):
return _channel_name(value)
+39
View File
@@ -0,0 +1,39 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
class OidcSettingsRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
enabled: bool
display_name: str
issuer: str | None
client_id: str | None
client_secret_set: bool
scopes: str
groups_claim: str
admin_group: str | None
operator_group: str | None
read_only_group: str | None
class OidcSettingsUpdate(BaseModel):
"""Partial update; the client secret is write-only (set it or clear it)."""
enabled: bool | None = None
display_name: str | None = Field(default=None, min_length=1, max_length=60)
issuer: str | None = Field(default=None, max_length=500)
client_id: str | None = Field(default=None, max_length=255)
client_secret: str | None = Field(default=None, min_length=1, max_length=512)
clear_client_secret: bool = False
scopes: str | None = Field(default=None, min_length=1, max_length=255)
groups_claim: str | None = Field(default=None, min_length=1, max_length=64)
admin_group: str | None = Field(default=None, max_length=255)
operator_group: str | None = Field(default=None, max_length=255)
read_only_group: str | None = Field(default=None, max_length=255)
@field_validator("issuer")
@classmethod
def https_only(cls, value):
if value and not value.startswith("https://"):
raise ValueError("issuer must be an https:// URL")
return value.strip().rstrip("/") if value else value
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
import uuid
from pydantic import BaseModel, Field
class OverlaySettingsRead(BaseModel):
enabled: bool
endpoint: str | None
subnet: str
listen_port: int
hub_public_key: str | None
hub_address: str
hub_state: str # running | stale | absent
class OverlaySettingsUpdate(BaseModel):
enabled: bool | None = None
endpoint: str | None = Field(default=None, max_length=255)
subnet: str | None = Field(default=None, max_length=18)
listen_port: int | None = Field(default=None, ge=1, le=65535)
class PeerCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
class PeerUpdate(BaseModel):
public_key: str | None = Field(default=None, max_length=44)
host_id: uuid.UUID | None = None
clear_host: bool = False
class PeerRead(BaseModel):
id: uuid.UUID
name: str
ip: str
public_key: str | None
host_id: uuid.UUID | None
host_name: str | None
connected: bool
last_handshake_at: datetime | None
rx_bytes: int
tx_bytes: int
remote_endpoint: str | None
+34
View File
@@ -4,6 +4,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.adopt import LinkTarget
class RuleCreate(BaseModel):
description: str = Field(min_length=1, max_length=255)
@@ -17,6 +19,7 @@ class RuleCreate(BaseModel):
destination_net: str = Field(default="any", min_length=1, max_length=128)
destination_port: str | None = Field(default=None, max_length=32)
enabled: bool = True
log: bool = False
target_host_ids: list[uuid.UUID] = Field(default_factory=list)
target_group_ids: list[uuid.UUID] = Field(default_factory=list)
@@ -56,6 +59,7 @@ class RuleRead(BaseModel):
destination_net: str
destination_port: str | None
enabled: bool
log: bool = False
created_at: datetime
updated_at: datetime
target_host_ids: list[uuid.UUID]
@@ -72,3 +76,33 @@ class RuleEdit(RuleCreate):
class DeployApproval(BaseModel):
preview_token: str = Field(min_length=64, max_length=64)
class AdoptRequest(BaseModel):
host_id: uuid.UUID
# Link to this existing central rule instead of creating a new one (services/adopt_link_service.py).
link_to: uuid.UUID | None = None
rule_uuid: str = Field(min_length=1, max_length=64)
class UnmanagedRule(BaseModel):
uuid: str
description: str
interface: str
interface_label: str
direction: str
action: str
protocol: str
source_net: str
source_port: str
destination_net: str
destination_port: str
enabled: bool
log: bool
adoptable: bool
# Codes such as "negated_source" or "not_quick"; the UI turns them into text.
reasons: list[str]
# Central rules equal to this one (resolved for the host), same-description rules that differ, link possible.
matches: list[LinkTarget] = Field(default_factory=list)
similar: list[LinkTarget] = Field(default_factory=list)
linkable: bool = False
+4
View File
@@ -7,6 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
class UserRole(str, Enum):
admin = "admin"
# Rolls out and operates (rules, NAT, VPN, maintenance, tokens of its own) but cannot
# change who may access OPNexus or where it sends data (users, settings, hosts).
operator = "operator"
read_only = "read_only"
@@ -36,3 +39,4 @@ class UserRead(BaseModel):
created_at: datetime
last_login_at: datetime | None
totp_enabled: bool = False
auth_source: str = "local"
+56
View File
@@ -7,9 +7,19 @@ how the proxy setup works for IP *and* name access) nor an explicitly allowed
origin. Requests without either header (curl, scripts) are not browsers, so
they cannot be riding on a victim's cookie and pass through to normal auth.
Sec-Fetch-Site (1.27): a page on another port of the same host (e.g. a
firewall GUI behind the GUI gateway) is *same-site* to OPNexus, so the
browser attaches the session cookie to its requests and SameSite=Lax does
not help. Cookie-carrying /api requests that the browser itself marks as
same-site or cross-site are therefore refused — unless the Origin is an
explicitly configured frontend origin (development setup with the frontend
on its own port). Clients that send no Sec-Fetch-Site header (curl,
scripts, API tokens) are not browsers and stay unaffected.
SecurityHeadersMiddleware: API responses must never be cached by browsers or
proxies and must not be sniffed or framed.
"""
import re
from urllib.parse import urlparse
from starlette.middleware.base import BaseHTTPMiddleware
@@ -18,6 +28,7 @@ from starlette.responses import JSONResponse
from app.config import settings
from app.errors import ErrorCode
from app.routers.auth import has_session_cookie
_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
@@ -35,8 +46,53 @@ def _origin_allowed(origin: str, host_header: str) -> bool:
return f"{parsed.scheme}://{parsed.netloc}".lower() in {o.lower() for o in settings.allowed_origins}
# The SSO provider sends the browser back here by a cross-site navigation;
# the endpoint authenticates by its own state cookie and creates the session.
# The exemption is only for exactly that: a top-level GET navigation (a script, image or fetch from another site
# is still blocked, whatever it carries) — and the callback fails without the matching state cookie anyway.
_SEC_FETCH_EXEMPT = {"/api/auth/oidc/callback"}
def _is_top_level_navigation(request: Request) -> bool:
if request.method != "GET":
return False
mode = request.headers.get("sec-fetch-mode", "").strip().lower()
dest = request.headers.get("sec-fetch-dest", "").strip().lower()
return mode in ("", "navigate") and dest in ("", "document")
def _explicit_frontend_origin(origin: str) -> bool:
origin = origin.rstrip("/").lower()
if not origin or origin == "null":
return False
if settings.allowed_origins:
return origin in {o.lower() for o in settings.allowed_origins}
return re.fullmatch(settings.cors_origin_regex, origin) is not None
def sec_fetch_blocked(request: Request) -> bool:
path = request.url.path
if not (path == "/api" or path.startswith("/api/")):
return False
if path in _SEC_FETCH_EXEMPT and _is_top_level_navigation(request):
return False
if not has_session_cookie(request):
return False
site = request.headers.get("sec-fetch-site", "").strip().lower()
if site == "cross-site":
return True
if site == "same-site":
return not _explicit_frontend_origin(request.headers.get("origin", ""))
return False # same-origin, none (typed URL/bookmark) or no header (not a browser)
class OriginCheckMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if sec_fetch_blocked(request):
return JSONResponse(
status_code=403,
content={"detail": {"code": ErrorCode.CROSS_SITE_REQUEST_BLOCKED.value, "params": {}}},
)
if request.method not in _SAFE_METHODS:
source = request.headers.get("origin") or request.headers.get("referer")
if source and source != "null" and not _origin_allowed(source, request.headers.get("host", "")):
+63
View File
@@ -0,0 +1,63 @@
"""Small pieces shared by every "take over what was made by hand on a firewall" feature
(rule_adopt_service.py for filter rules, object_adopt_service.py for aliases and NAT)."""
class NotAdoptable(Exception):
"""The object exists on the host but cannot be described without distortion.
`reasons` are machine-readable codes (the UI translates them); `details` maps a code to
extra text the translation can show (e.g. the alias names a reference points to)."""
def __init__(self, reasons: list[str], details: dict[str, str] | None = None):
super().__init__(", ".join(reasons))
self.reasons = reasons
self.details = details or {}
class NotFoundOnHost(Exception):
"""The UUID is not (or no longer) present in the host's live configuration."""
def on(value) -> bool:
"""OPNsense booleans arrive as "1"/"0" strings, sometimes as real booleans."""
return value in ("1", 1, True)
def clean(value) -> str:
return "" if value is None else str(value).strip()
class LinkTargetNotFound(Exception):
"""The central object chosen to link to was deleted in the meantime."""
class LinkTargetChanged(Exception):
"""The central object no longer equals what is live on the host. `diff` lists the differing fields."""
def __init__(self, diff: list[dict]):
super().__init__(", ".join(d["field"] for d in diff))
self.diff = diff
class HostAlreadyHasObject(Exception):
"""The central object is already deployed to / managed on this host under another UUID."""
class LinkTargetBusy(Exception):
"""The central object has an unconfirmed deployment or a failed rollback; linking waits until it is settled."""
LINK_ERRORS = (LinkTargetNotFound, LinkTargetChanged, HostAlreadyHasObject, LinkTargetBusy)
def link_api_error(exc: Exception):
"""The HTTP error for a failed link (shared by the rule and the object endpoints)."""
from app.errors import ErrorCode, api_error
if isinstance(exc, LinkTargetNotFound):
return api_error(404, ErrorCode.ADOPT_LINK_TARGET_NOT_FOUND)
if isinstance(exc, LinkTargetChanged):
return api_error(409, ErrorCode.ADOPT_LINK_TARGET_CHANGED, fields=", ".join(d["field"] for d in exc.diff))
if isinstance(exc, HostAlreadyHasObject):
return api_error(409, ErrorCode.ADOPT_LINK_HOST_HAS_OBJECT)
return api_error(409, ErrorCode.ADOPT_LINK_BUSY)
+151
View File
@@ -0,0 +1,151 @@
"""Links a hand-made object that was found on a firewall to a central object that already exists, instead
of creating a second central object for it ("Mit vorhandenem zentralem Objekt verknüpfen").
Typical case: the same rule/alias/NAT entry was made by hand on several firewalls (or on both nodes of an HA
pair). The first adoption creates the central object; every further firewall is LINKED to it: the host
becomes another target of that object and gets a deployment row holding the live UUID (remote_uuid) and
the payload OPNexus would send. Like adopting, linking writes NOTHING to the firewall. The live state is
read fresh from the host at link time (the browser only names the central object), so the preview right
afterwards says "unchanged" and a later rollout updates the one object on every host without a duplicate.
A central object is an EXACT match when every field OPNexus manages is equal after the object's
{{VARIABLE}} placeholders are replaced with this host's values (variables_service.resolve). An object with
the same identity (alias name / rule description) but different content is only shown as a hint with the
differing fields and is NEVER linked automatically.
Callers hold policy_lock (same serialisation as adopting), which also makes a repeated link idempotent.
"""
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.host import OpnsenseHost
from app.models.policy import DeviceGroup
from app.services import variables_service
from app.services.adopt_common import HostAlreadyHasObject, LinkTargetBusy, LinkTargetChanged, LinkTargetNotFound
from app.services.policy_hierarchy_target_service import hydrate_hierarchy_targets
from app.services.policy_service import effective_hosts
LINK_MESSAGE = "Mit vorhandenem Objekt verknüpft"
BUSY_STATUSES = ("pending", "revert_failed") # the central object waits for a confirmation / failed to roll back
@dataclass(frozen=True)
class LinkSpec:
model: type
deployment: type
fk: str # deployment column pointing at the central object
uuid_attr: str # deployment column holding the OPNsense UUID (remote_uuid)
identity: str # attribute naming the object: "name" (alias) or "description"
payload: Callable[[Any, OpnsenseHost], dict]
revision: Callable
@dataclass
class Match:
obj: Any
diff: list[dict] # empty = exact match
busy: bool = False # the central object waits for a confirmation: linking is refused until that is settled
def _norm(value) -> Any:
if value is None:
return ""
return value if isinstance(value, bool) else str(value)
def _show(value) -> str:
return ("true" if value else "false") if isinstance(value, bool) else ("" if value is None else str(value))
def diff_fields(obj, values: dict, host: OpnsenseHost) -> list[dict]:
"""Fields in which the central object (resolved for `host`) differs from the live `values`."""
raw = {key: getattr(obj, key) for key in values}
try:
central = variables_service.resolve(raw, host)
except variables_service.MissingVariables as exc:
# Cannot be compared (and could not be deployed to this host either): report, never match.
return [{"field": "variables", "central": ", ".join(exc.missing[host.name]), "live": ""}]
return [{"field": key, "central": _show(central[key]), "live": _show(values[key])}
for key in values if _norm(central[key]) != _norm(values[key])]
def _options(spec: LinkSpec):
return [
selectinload(spec.model.targets),
selectinload(spec.model.groups).selectinload(DeviceGroup.hosts),
selectinload(spec.model.deployments),
]
async def load_central(db: AsyncSession, spec: LinkSpec) -> list:
objs = list((await db.scalars(select(spec.model).options(*_options(spec)))).unique().all())
await hydrate_hierarchy_targets(db, objs)
return objs
def find(objs: list, spec: LinkSpec, host: OpnsenseHost, values: dict) -> tuple[list[Match], list[Match]]:
"""(exact matches, same-identity objects that differ). Objects that already have a deployment on
this host are not candidates: linking would collide with that deployment."""
exact: list[Match] = []
similar: list[Match] = []
for obj in objs:
if any(d.host_id == host.id for d in obj.deployments):
continue
if _norm(getattr(obj, spec.identity)) != _norm(values[spec.identity]):
continue
d = diff_fields(obj, values, host)
busy = any(dep.status in BUSY_STATUSES for dep in obj.deployments)
(similar if d else exact).append(Match(obj, d, busy))
# prefer the object that already reaches the most hosts (the "real" one), then a stable order
exact.sort(key=lambda m: (-len(effective_hosts(m.obj)), str(m.obj.id)))
return exact, similar
def describe(spec: LinkSpec, match: Match) -> dict:
return {
"id": match.obj.id,
"name": getattr(match.obj, spec.identity),
"targets": [h.name for h in effective_hosts(match.obj)],
"diff": match.diff,
}
async def link(db: AsyncSession, spec: LinkSpec, host: OpnsenseHost, object_id, remote_uuid: str, values: dict):
"""Links the live object `remote_uuid` (whose normalised fields are `values`, read fresh by the caller)
to the central object `object_id`. Returns it; a repeated call for the same pair changes nothing."""
obj = (await db.execute(
select(spec.model).where(spec.model.id == object_id).options(*_options(spec))
.execution_options(populate_existing=True)
)).unique().scalar_one_or_none()
if obj is None:
raise LinkTargetNotFound(str(object_id))
await hydrate_hierarchy_targets(db, [obj])
existing = next((d for d in obj.deployments if d.host_id == host.id), None)
if existing is not None:
if getattr(existing, spec.uuid_attr) == remote_uuid:
return obj # already linked: nothing to do
raise HostAlreadyHasObject(str(object_id))
if any(d.status in BUSY_STATUSES for d in obj.deployments):
raise LinkTargetBusy(str(object_id))
changed = diff_fields(obj, values, host)
if changed:
raise LinkTargetChanged(changed)
if host.id not in {h.id for h in effective_hosts(obj)}:
obj.targets.append(host)
# an association-only change does not touch the row; stale edit forms must still be refused (see edit_rule)
obj.updated_at = datetime.now(timezone.utc)
await db.flush()
spec.revision(db, obj, f"Mit {host.name} verknüpft")
db.add(spec.deployment(**{
spec.fk: obj.id, "host_id": host.id, "status": "success", "message": LINK_MESSAGE,
spec.uuid_attr: remote_uuid, "last_payload": spec.payload(obj, host), "previous_payload": None,
}))
await db.flush()
return obj
+10 -4
View File
@@ -18,7 +18,7 @@ from app.models.host import OpnsenseHost
from app.models.alias import FirewallAlias
from app.models.alias_deployment import AliasDeployment
from app.services.audit_service import tracked_rollback
from app.services import opnsense_client
from app.services import opnsense_client, variables_service
from app.services.deploy_flow import push_to_host
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
@@ -27,7 +27,12 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
def _build_payload(alias: FirewallAlias) -> dict:
def _build_payload(alias: FirewallAlias, host: OpnsenseHost | None = None) -> dict:
payload = _base_payload(alias)
return variables_service.resolve(payload, host) if host is not None else payload
def _base_payload(alias: FirewallAlias) -> dict:
return {
"enabled": "1" if alias.enabled else "0",
"name": alias.name,
@@ -38,7 +43,7 @@ def _build_payload(alias: FirewallAlias) -> dict:
async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: OpnsenseHost) -> AliasDeployment:
payload = _build_payload(alias)
payload = _build_payload(alias, host)
existing = await db.scalar(
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id)
)
@@ -79,6 +84,7 @@ async def deploy_alias(db: AsyncSession, alias: FirewallAlias, hosts: list[Opnse
from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
await ensure_no_maintenance(db, hosts)
variables_service.ensure_resolvable(_build_payload, alias, hosts) # before anything is written
deployments = [await _deploy_to_host(db, alias, host) for host in hosts]
await db.flush()
for host, deployment in zip(hosts, deployments):
@@ -135,7 +141,7 @@ async def undeploy_alias(db: AsyncSession, alias: FirewallAlias) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+10 -4
View File
@@ -9,6 +9,7 @@ import json
from app.models.policy import AliasRevision
from app.services.alias_deploy_service import _build_payload
from app.services import variables_service
from app.services.policy_service import effective_hosts
ALIAS_FIELDS = ("name", "type", "content", "description", "enabled")
@@ -28,13 +29,17 @@ def revision(db, alias, note):
def preview(alias):
payload = _build_payload(alias)
deployments = {d.host_id: d for d in alias.deployments}
hosts = effective_hosts(alias)
blocked = any(d.status in ("pending", "revert_failed") for d in alias.deployments)
rows = []
for host in hosts:
dep = deployments.get(host.id)
try:
payload = _build_payload(alias, host)
except variables_service.MissingVariables as exc:
rows.append(variables_service.preview_row_missing(host, exc.missing[host.name], dep))
continue
before = dep.last_payload if dep else None
action = "create" if not dep or not dep.opnsense_alias_uuid else "update"
if before == payload and dep and dep.status in ("success", "reverted"):
@@ -61,6 +66,8 @@ def preview(alias):
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
if any(r["action"] == "retained" for r in rows):
warnings.append("Frühere Ziele behalten diesen Alias, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.")
if any(r["action"] == "missing_variable" for r in rows):
warnings.append("Für mindestens einen Ziel-Host fehlt der Wert einer Variable ({{NAME}}). Erst auf der Host-Seite unter „Variablen“ eintragen.")
if any(r["action"] == "disabled" for r in rows):
warnings.append("Deaktivierte Hosts werden übersprungen.")
if any(d.status == "error" for d in alias.deployments):
@@ -71,7 +78,7 @@ def preview(alias):
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in alias.deployments], key=lambda row: row[0])}
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
return {"token": token, "hosts": rows, "warnings": warnings,
"can_deploy": not blocked and any(r["action"] in ("create", "update") for r in rows),
"can_deploy": not blocked and not any(r["action"] == "missing_variable" for r in rows) and any(r["action"] in ("create", "update") for r in rows),
"basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
@@ -79,12 +86,11 @@ def workflow(alias, deployments):
hosts = effective_hosts(alias)
active = [h for h in hosts if h.enabled]
by_host = {d.host_id: d for d in deployments}
payload = _build_payload(alias)
target_ids = {h.id for h in hosts}
counts = {
"targets": len(hosts),
"active_targets": len(active),
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != payload),
"changes": sum(1 for h in active if h.id not in by_host or by_host[h.id].last_payload != variables_service.payload_or_none(_build_payload, alias, h)),
"pending": sum(d.status == "pending" for d in deployments),
"errors": sum(d.status in ("error", "revert_failed") for d in deployments),
"retained": sum(d.host_id not in target_ids for d in deployments),
+170
View File
@@ -0,0 +1,170 @@
"""Personal API tokens: creation, lookup and scope rules.
Scopes (a token can never do more than its owner's role allows; a read_only
user's token is read-only whatever its scope says):
* ``read`` — GET/HEAD/OPTIONS only, except endpoints that return secrets (raw
config backups, notification settings): those need ``admin``. For Prometheus,
dashboards, MCP clients.
* ``deploy`` — additionally every write that rolls out or operates on the fleet
(rules, aliases, NAT, VPN, maintenance, backups...). NOT the writes that
change who can access OPNexus or where it sends data: users, settings,
notifications, host registration/removal, log settings, OPNexus self-update.
* ``admin`` — everything the owner may do, except what is session-only.
Session-only for every scope: managing tokens themselves and the account
endpoints (/api/auth/*), so a leaked token can neither mint more tokens nor
change a password or second factor.
The enforcement lives in AuthMiddleware (one place, like the read_only role).
"""
import hashlib
import re
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.models.api_token import ApiToken
from app.models.user import AdminUser
TOKEN_PREFIX = "opx_"
SCOPES = ("read", "deploy", "admin")
MAX_TOKENS_PER_USER = 50
# last_used_at is only rewritten when older than this, so a scraper hitting
# /api/metrics every few seconds does not turn into a write per request.
_LAST_USED_GRANULARITY = timedelta(minutes=1)
_SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
# GUI tickets open a firewall's web interface in the browser: an interactive admin session only.
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)|^/api/hosts/[^/]+/gui-ticket$")
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
# notification settings the webhook URL/SMTP data): admin scope only.
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)|^/api/settings/|^/api/overlay/peers/[^/]+/instructions$")
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
_MCP_PATH = "/api/mcp"
_ADMIN_ONLY_WRITES = re.compile(
r"^/api/(users|settings|system|notifications|overlay)(/|$)"
r"|^/api/logs/settings$"
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
r"|^/api/hosts/[^/]+/gui-ticket$"
# The per-gateway alert switch is a notification setting (same rights as PUT /api/notifications/settings).
r"|^/api/hosts/[^/]+/gateways/[^/]+/alert$"
)
def _now() -> datetime:
return datetime.now(timezone.utc)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def denies(scope: str, method: str, path: str) -> bool:
"""True if a token with this scope must not make this request."""
if _SESSION_ONLY.match(path):
return True
if path == _MCP_PATH:
return False
if method in _SAFE_METHODS:
return scope != "admin" and bool(_ADMIN_SCOPE_READS.match(path))
if scope == "read":
return True
if scope == "deploy" and _ADMIN_ONLY_WRITES.match(path):
return True
return False
def operator_denies(method: str, path: str) -> bool:
"""True if an `operator` account must not make this request: the writes that
change who may access OPNexus or where it sends data. (Their own theme at
PUT /api/settings stays open; account endpoints are not matched here.)"""
return method not in _SAFE_METHODS and path != "/api/settings" and bool(_ADMIN_ONLY_WRITES.match(path))
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
_ROLLOUT_POST = re.compile(
r"/(deploy|push|sign|poll|register|reassert-ordering"
r"|deployments/[^/]+/(remove|retry-rollback)"
r"|maintenance/(execute|carp-execute|schedule)"
r"|network/changes"
r"|services/[^/]+/(start|stop|restart)"
# Firmware rollouts (1.25): starting/arming a plan and moving a run forward. Pausing is deliberately absent.
r"|rollouts/plans/[^/]+/(start|activate)"
r"|rollouts/runs/[^/]+/(resume|approve|skip-host|abort))$"
)
_ROLLOUT_DELETE = re.compile(
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
)
# Recurring maintenance windows decide WHEN unattended firmware actions run (1.25): every write counts.
_ROLLOUT_ANY_WRITE = re.compile(r"^/api/maintenance-windows(/|$)")
def is_rollout(method: str, path: str) -> bool:
if method in ("POST", "PUT", "PATCH", "DELETE") and _ROLLOUT_ANY_WRITE.match(path):
return True
if method == "POST":
return bool(_ROLLOUT_POST.search(path))
return method == "DELETE" and bool(_ROLLOUT_DELETE.match(path))
def bearer_from_header(header: str) -> str | None:
"""The token of an ``Authorization: Bearer opx_...`` header, else None."""
scheme, _, value = header.partition(" ")
value = value.strip()
if scheme.lower() == "bearer" and value.startswith(TOKEN_PREFIX):
return value
return None
async def create(
db: AsyncSession, user: AdminUser, *, name: str, scope: str, expires_in_days: int | None
) -> tuple[ApiToken, str]:
count = len((await db.scalars(select(ApiToken.id).where(ApiToken.user_id == user.id))).all())
if count >= MAX_TOKENS_PER_USER:
raise api_error(409, ErrorCode.API_TOKEN_LIMIT_REACHED, limit=MAX_TOKENS_PER_USER)
raw = TOKEN_PREFIX + secrets.token_urlsafe(32)
token = ApiToken(
user_id=user.id,
name=name,
scope=scope,
token_hash=hash_token(raw),
prefix=raw[: len(TOKEN_PREFIX) + 6],
expires_at=_now() + timedelta(days=expires_in_days) if expires_in_days else None,
)
db.add(token)
await db.commit()
await db.refresh(token)
return token, raw
async def resolve(db: AsyncSession, raw: str, ip: str | None) -> tuple[AdminUser, ApiToken] | None:
"""The owner and token for a raw bearer token, or None if unknown/expired."""
token = await db.scalar(select(ApiToken).where(ApiToken.token_hash == hash_token(raw)))
if token is None or (token.expires_at is not None and token.expires_at <= _now()):
return None
user = await db.get(AdminUser, token.user_id)
if user is None:
return None
if token.last_used_at is None or _now() - token.last_used_at > _LAST_USED_GRANULARITY:
token.last_used_at = _now()
token.last_used_ip = ip
await db.commit()
return user, token
async def revoke_all(db: AsyncSession, user_id, *, commit: bool = True) -> None:
"""Delete every token of an account (password reset: answer to a suspected compromise)."""
await db.execute(delete(ApiToken).where(ApiToken.user_id == user_id))
if commit:
await db.commit()
@@ -0,0 +1,177 @@
"""Ships new audit events to syslog (RFC 5424, UDP / TCP / TLS) and/or a webhook.
A poll loop (main.py) calls ``run_once``: events after the stored cursor, oldest
first, in batches. A channel failing leaves the cursor where it is, so the same
events are tried again next round (at-least-once; every event carries its id so a
receiver can de-duplicate). If both channels are on, both must succeed to advance.
What is sent: who, what kind/action/status, which entity and host, the message and
a correlation id. NOT ``details`` — those hold before/after payloads of firewall
objects, which have no business in a log aggregator.
"""
import asyncio
import hashlib
import hmac
import json
import logging
import socket
import ssl
import uuid
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.audit import AuditEvent
from app.models.audit_export import AuditExportSettings
from app.services import notification_secret_store
from app.version import APP_VERSION
logger = logging.getLogger(__name__)
SINGLETON_ID = 1
BATCH = 200
SECRET_NAME = "audit_webhook_signing_secret"
_FACILITY_LOCAL0 = 16
_BAD_STATUSES = {"denied", "rejected", "error", "revert_failed", "failed"}
class ExportError(Exception):
pass
async def get_settings(db: AsyncSession) -> AuditExportSettings:
row = await db.get(AuditExportSettings, SINGLETON_ID)
if row is None:
row = AuditExportSettings(
id=SINGLETON_ID, syslog_enabled=False, syslog_port=514, syslog_protocol="udp",
webhook_enabled=False, webhook_signing_secret_set=False,
)
db.add(row)
await db.flush()
return row
def event_dict(event: AuditEvent) -> dict:
return {
"id": str(event.id), "time": event.created_at.isoformat(), "correlation_id": event.correlation_id,
"actor": event.actor, "kind": event.kind, "action": event.action, "status": event.status,
"entity_id": event.entity_id, "entity_name": event.entity_name,
"host_id": event.host_id, "host_name": event.host_name, "message": event.message,
}
def syslog_line(event: dict, hostname: str = "opnexus") -> bytes:
"""RFC 5424: <PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID - MSG (JSON)."""
severity = 4 if event["status"] in _BAD_STATUSES else 6
pri = _FACILITY_LOCAL0 * 8 + severity
return f"<{pri}>1 {event['time']} {hostname} opnexus - AUDIT - {json.dumps(event, ensure_ascii=False)}".encode()
def _frame_tcp(lines: list[bytes]) -> bytes:
"""RFC 6587 octet counting: '<len> <message>' per message."""
return b"".join(str(len(line)).encode() + b" " + line for line in lines)
def _send_syslog_sync(row: AuditExportSettings, lines: list[bytes]) -> None:
host, port = row.syslog_host, row.syslog_port
if row.syslog_protocol == "udp":
with socket.socket(socket.AF_INET6 if ":" in (host or "") else socket.AF_INET, socket.SOCK_DGRAM) as sock:
for line in lines:
sock.sendto(line[:8000], (host, port))
return
with socket.create_connection((host, port), timeout=settings.opnsense_timeout) as raw:
if row.syslog_protocol == "tls":
context = ssl.create_default_context(cafile=settings.smtp_ca_file or None)
with context.wrap_socket(raw, server_hostname=host) as tls:
tls.sendall(_frame_tcp(lines))
else:
raw.sendall(_frame_tcp(lines))
async def send_syslog(row: AuditExportSettings, events: list[dict]) -> None:
if not row.syslog_host:
raise ExportError("Kein Syslog-Ziel konfiguriert")
try:
await asyncio.to_thread(_send_syslog_sync, row, [syslog_line(e) for e in events])
except (OSError, ssl.SSLError) as exc:
raise ExportError(f"Syslog-Zustellung fehlgeschlagen ({type(exc).__name__})") from None
async def send_webhook(row: AuditExportSettings, events: list[dict]) -> None:
if not row.webhook_url:
raise ExportError("Keine Webhook-URL konfiguriert")
raw = json.dumps({"source": "opnexus", "version": APP_VERSION, "events": events}, ensure_ascii=False).encode()
headers = {"Content-Type": "application/json"}
if row.webhook_signing_secret_set:
secret = notification_secret_store.read_secret(SECRET_NAME)
if not secret:
raise ExportError("Signaturschlüssel ist konfiguriert, aber nicht lesbar – bitte neu setzen")
headers["X-OPNexus-Signature"] = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
try:
async with httpx.AsyncClient(timeout=settings.opnsense_timeout) as client:
(await client.post(row.webhook_url, content=raw, headers=headers)).raise_for_status()
except httpx.HTTPError as exc:
# The URL often is the credential: never put it in a message.
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
raise ExportError(f"Webhook-Zustellung fehlgeschlagen ({reason})") from None
def active(row: AuditExportSettings) -> bool:
return bool((row.syslog_enabled and row.syslog_host) or (row.webhook_enabled and row.webhook_url))
async def _ship(row: AuditExportSettings, events: list[dict]) -> None:
if row.syslog_enabled and row.syslog_host:
await send_syslog(row, events)
if row.webhook_enabled and row.webhook_url:
await send_webhook(row, events)
async def run_once(db: AsyncSession, lag_seconds: float = 3.0) -> int:
"""Ship one batch; returns how many events went out (0 when idle or failing).
Only events at least ``lag_seconds`` old are shipped: an event is stamped when it
is created, a moment before its transaction commits, and the cursor must not
jump past one that becomes visible a little later."""
row = await get_settings(db)
if not active(row):
return 0
if row.cursor_created_at is None:
# First round after enabling: start from now, do not replay history.
row.cursor_created_at = datetime.now(timezone.utc)
row.cursor_id = None
await db.commit()
return 0
after = AuditEvent.created_at > row.cursor_created_at
if row.cursor_id:
after = or_(after, and_(AuditEvent.created_at == row.cursor_created_at, AuditEvent.id > uuid.UUID(row.cursor_id)))
ripe = AuditEvent.created_at <= datetime.now(timezone.utc) - timedelta(seconds=lag_seconds)
# Ordering by (created_at, id) must match the cursor comparison above.
events = (await db.scalars(select(AuditEvent).where(after, ripe).order_by(AuditEvent.created_at, AuditEvent.id).limit(BATCH))).all()
if not events:
return 0
try:
await _ship(row, [event_dict(e) for e in events])
except ExportError as exc:
row.last_error, row.last_error_at = str(exc)[:300], datetime.now(timezone.utc)
await db.commit()
logger.warning("Audit-Export: %s", exc)
return 0
row.cursor_created_at, row.cursor_id = events[-1].created_at, str(events[-1].id)
row.last_success_at, row.last_error = datetime.now(timezone.utc), None
await db.commit()
return len(events)
async def send_test(row: AuditExportSettings) -> None:
event = {
"id": "00000000-0000-0000-0000-000000000000", "time": datetime.now(timezone.utc).isoformat(),
"correlation_id": "test", "actor": "opnexus", "kind": "test", "action": "test", "status": "accepted",
"entity_id": None, "entity_name": None, "host_id": None, "host_name": None,
"message": "Dies ist ein Test des Audit-Exports von OPNexus.",
}
if not (row.syslog_enabled or row.webhook_enabled):
raise ExportError("Kein Kanal eingeschaltet")
await _ship(row, [event])
+23 -3
View File
@@ -15,10 +15,27 @@ from app.models.audit import AuditEvent
context = ContextVar("audit_context", default=None)
# Column widths of audit_events. entity_id/host_id/action are taken from the
# request path BEFORE the router validated it, so a junk segment (300 chars,
# or a NUL byte which Postgres text cannot store) used to turn into a 500 or a
# dropped connection instead of a plain 404/422 (found by fuzzing, 2026-10-09).
_LIMITS = {"correlation_id": 36, "actor": 150, "kind": 32, "action": 40, "status": 32,
"entity_id": 36, "entity_name": 255, "host_id": 36, "host_name": 255}
def _clean(key, value):
if not isinstance(value, str):
return value
value = value.replace("\x00", "")
limit = _LIMITS.get(key)
return value[:limit] if limit else value
def make_event(**values):
ctx = context.get() or {}
return AuditEvent(actor=ctx.get("actor", "system:rollback"),
correlation_id=ctx.get("id", str(uuid.uuid4())), **values)
values = {k: _clean(k, v) for k, v in values.items()}
return AuditEvent(actor=_clean("actor", ctx.get("actor", "system:rollback")),
correlation_id=_clean("correlation_id", ctx.get("id", str(uuid.uuid4()))), **values)
async def persist(**values):
@@ -71,7 +88,10 @@ def deployment_history(session, flush_context, instances):
if entity is not None and action == "deploy":
from importlib import import_module
builder = import_module(f"app.services.{kind}_deploy_service")._build_payload
details["requested_payload"] = builder(entity)
try:
details["requested_payload"] = builder(entity, host) if host is not None else builder(entity)
except Exception: # a missing variable must not break the audit write; record the unresolved intent instead
details["requested_payload"] = builder(entity)
session.add(make_event(kind=kind, action=action, status="removed" if deleted else dep.status,
entity_id=str(entity_id), entity_name=(getattr(entity, "name", None) or getattr(entity, "description", None)),
host_id=str(dep.host_id), host_name=host.name if host else None,
+5 -3
View File
@@ -1,7 +1,7 @@
"""Authentication core: password hashing, server-side sessions, brute-force
protection and the initial admin bootstrap.
Two roles ("admin", "read_only"), no orgs, no API keys — see
Two roles ("admin", "read_only"), no orgs; API tokens live in api_token_service — see
AdminUser/AuthMiddleware for how a role is enforced. Originally mirrored
InfraPanel's own app/services/auth_service.py (single-admin-only); the role
column and its enforcement are OPNexus-specific additions on top of that.
@@ -236,6 +236,7 @@ async def create_session(
user_agent: str | None = None,
ip_address: str | None = None,
verified_hash: str | None = None,
lifetime_seconds: int | None = None,
) -> tuple[str, datetime]:
"""Create a session, returning the raw token (to be set as a cookie) and its expiry.
@@ -253,7 +254,7 @@ async def create_session(
await db.rollback()
raise api_error(401, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
expires_at = _now() + timedelta(seconds=settings.session_lifetime)
expires_at = _now() + timedelta(seconds=lifetime_seconds or settings.session_lifetime)
session = Session(
user_id=user_id,
token_hash=_hash_token(token),
@@ -367,7 +368,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
logger.warning(
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
" generated password:\n\n %s\n\n"
" Log in and change it now. This is shown only once.\n%s",
" Log in and change it now. This is shown only once.\n"
" Lost it? ./opnexus password (sets and prints a new one)\n%s",
"=" * 64, settings.admin_username, password, "=" * 64,
)
else:
+45
View File
@@ -0,0 +1,45 @@
"""Client-side encryption of config backups before they leave the server.
File format (``.opxbak``): ``OPXBAK1`` | salt (16) | nonce (12) | AES-256-GCM ciphertext+tag.
The key is scrypt(passphrase, salt). The header is authenticated as associated data, so a
file cannot be re-labelled. The passphrase never leaves OPNexus' secret store; without it
an offsite copy is unreadable, which is the point — and also why it must be kept somewhere
else too (see the handbook). Decrypt with ``python -m app.backup_decrypt``.
"""
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
MAGIC = b"OPXBAK1"
_SALT, _NONCE = 16, 12
# scrypt cost: ~64 MiB, well above interactive defaults; a backup is encrypted rarely.
_N, _R, _P = 2**16, 8, 1
class DecryptError(Exception):
pass
def _key(passphrase: str, salt: bytes) -> bytes:
return Scrypt(salt=salt, length=32, n=_N, r=_R, p=_P).derive(passphrase.encode())
def encrypt(plaintext: bytes, passphrase: str) -> bytes:
salt, nonce = os.urandom(_SALT), os.urandom(_NONCE)
header = MAGIC + salt + nonce
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, plaintext, header)
def decrypt(blob: bytes, passphrase: str) -> bytes:
header_len = len(MAGIC) + _SALT + _NONCE
if len(blob) < header_len + 16 or not blob.startswith(MAGIC):
raise DecryptError("Keine OPNexus-Backup-Datei (Kopfzeile fehlt).")
header, body = blob[:header_len], blob[header_len:]
salt = header[len(MAGIC): len(MAGIC) + _SALT]
nonce = header[len(MAGIC) + _SALT:]
try:
return AESGCM(_key(passphrase, salt)).decrypt(nonce, body, header)
except InvalidTag:
raise DecryptError("Falsche Passphrase oder beschädigte Datei.") from None
Loaded 100 of 371 files, more files were not shown because too many files have changed in this diff. Show more