Compare commits

..
5 Commits
Author SHA1 Message Date
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
120 changed files with 11232 additions and 117 deletions

No files matched your search

+5
View File
@@ -93,3 +93,8 @@ TRUST_PROXY_HEADERS=false
# SMTP_CA_FILE=/data/smtp-ca.pem # SMTP_CA_FILE=/data/smtp-ca.pem
# SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt. # SMTP-Anmeldung ohne Verschlüsselung (Sicherheit "none") bleibt gesperrt.
# ALLOW_INSECURE_SMTP_AUTH=false # 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
+56 -1
View File
@@ -29,7 +29,62 @@ in der App auf der Login-Seite, im Footer jeder Seite, auf `/about` und in
den Einstellungen. den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Lanner, Merlin … ansteht): Merlin …
## [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 ## [1.18.0] "Kestrel" — 2026-10-19
+5 -3
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp) ![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.18.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden. > **Stand:** `1.23.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen ## Funktionen
@@ -24,7 +24,9 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional). - **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. - **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. - **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch. - **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. Alle Screenshots zeigen fiktive Beispieldaten.
@@ -60,7 +62,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
```bash ```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.18.0 OPNEXUS_VERSION=1.23.0
``` ```
```bash ```bash
+5 -3
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](docs/screenshots/readme-rules-en.webp) ![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.18.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges. > **Status:** `1.23.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does ## What it does
@@ -24,7 +24,9 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in). - **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. - **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. - **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI. - **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](docs/screenshots/readme-cluster-en.webp) ![Cluster view](docs/screenshots/readme-cluster-en.webp)
@@ -62,7 +64,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
```bash ```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.18.0 OPNEXUS_VERSION=1.23.0
``` ```
```bash ```bash
+4
View File
@@ -8,6 +8,10 @@ WORKDIR /app
COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh COPY scripts/install_pg_client.sh /tmp/install_pg_client.sh
RUN /tmp/install_pg_client.sh 16 && rm /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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+26 -1
View File
@@ -8,6 +8,7 @@ from starlette.responses import JSONResponse
from app.database import SessionLocal from app.database import SessionLocal
from app.errors import ErrorCode from app.errors import ErrorCode
from app.routers.auth import COOKIE_NAME from app.routers.auth import COOKIE_NAME
from app.routers import governance
from app.routers.auth import client_ip from app.routers.auth import client_ip
from app.services import api_token_service, auth_service from app.services import api_token_service, auth_service
@@ -16,6 +17,10 @@ _PUBLIC_PATHS = {
"/api/auth/logout", "/api/auth/logout",
"/api/auth/me", "/api/auth/me",
"/api/health", "/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 # /docs, /redoc and /openapi.json used to be public — fully documenting a
@@ -112,13 +117,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
) )
if user is not None: if user is not None:
parts = request.url.path.strip("/").split("/") parts = request.url.path.strip("/").split("/")
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system"} 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"}
writing = request.method in ("POST", "PUT", "PATCH", "DELETE") writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
if len(parts) > 1 and parts[1] in _SECURITY_KINDS: if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}} event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
kind = event["kind"] kind = event["kind"]
else: else:
kind = kinds.get(parts[1]) if len(parts) > 1 else None 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") 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, 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, "host_id": parts[2] if kind == "host" and len(parts) > 2 else None,
@@ -143,6 +150,24 @@ class AuthMiddleware(BaseHTTPMiddleware):
status_code=403, status_code=403,
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}}, 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 request.state.user = user
request.state.api_token = api_token request.state.api_token = api_token
actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150] actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150]
+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())
+5
View File
@@ -53,6 +53,11 @@ class Settings(BaseSettings):
# reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan". # reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan".
frontend_origin: str = "" frontend_origin: str = ""
opnsense_secret_dir: str = "/data/opnsense-secrets" 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_backup_dir: str = "/data/opnsense-backups"
opnsense_timeout: float = 10.0 opnsense_timeout: float = 10.0
# Firewall API credentials are sent as HTTP Basic auth. Plain http:// # Firewall API credentials are sent as HTTP Basic auth. Plain http://
+11
View File
@@ -155,6 +155,17 @@ class ErrorCode(str, Enum):
SERVICE_LOCKED = "SERVICE_LOCKED" SERVICE_LOCKED = "SERVICE_LOCKED"
READ_ONLY_ACCESS = "READ_ONLY_ACCESS" READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED" 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"
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_NOT_FOUND = "API_TOKEN_NOT_FOUND"
API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN" API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN"
API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH" API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH"
+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))
+58 -2
View File
@@ -18,7 +18,7 @@ from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddle
from app.config import settings from app.config import settings
from app.database import SessionLocal from app.database import SessionLocal
from app import migrate from app import migrate
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router from app.routers import 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.routers import one_to_one
from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments
from app.routers import outbound_nat from app.routers import outbound_nat
@@ -51,6 +51,9 @@ from app.services import resource_monitor_service
from app.routers import scheduled_maintenance as scheduled_maintenance_router from app.routers import scheduled_maintenance as scheduled_maintenance_router
from app.services import scheduled_maintenance_service from app.services import scheduled_maintenance_service
from app.services import compliance_service 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 carp_event_service, carp_maintenance_window_service
# Without this, Python's root logger defaults to WARNING with no handler, # Without this, Python's root logger defaults to WARNING with no handler,
@@ -274,11 +277,48 @@ async def _compliance_poll_loop():
await asyncio.sleep(_COMPLIANCE_INTERVAL_SECONDS) 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)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await migrate.run() await migrate.run()
async with SessionLocal() as db: async with SessionLocal() as db:
await auth_service.bootstrap_admin(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: if any(o.startswith("https://") for o in settings.allowed_origins) and not settings.cookie_secure:
logger.warning( logger.warning(
"FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie " "FRONTEND_ORIGIN is https:// but COOKIE_SECURE is false — the session cookie "
@@ -293,6 +333,8 @@ async def lifespan(app: FastAPI):
resource_task = asyncio.create_task(_resource_poll_loop()) resource_task = asyncio.create_task(_resource_poll_loop())
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop()) scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
compliance_task = asyncio.create_task(_compliance_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())
try: try:
yield yield
finally: finally:
@@ -302,7 +344,9 @@ async def lifespan(app: FastAPI):
resource_task.cancel() resource_task.cancel()
scheduled_maintenance_task.cancel() scheduled_maintenance_task.cancel()
compliance_task.cancel() compliance_task.cancel()
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task): audit_export_task.cancel()
backup_task.cancel()
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task, audit_export_task, backup_task):
try: try:
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -352,6 +396,12 @@ 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) @app.exception_handler(DBAPIError)
async def _database_text_errors(request: Request, exc: 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 """A NUL byte in any text field cannot be stored by Postgres and used to
@@ -402,6 +452,12 @@ app.include_router(tokens_router.router, prefix="/api")
app.include_router(metrics_router.router, prefix="/api") app.include_router(metrics_router.router, prefix="/api")
app.include_router(mcp_router.router, prefix="/api") app.include_router(mcp_router.router, prefix="/api")
app.include_router(compliance_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(logs.router, prefix="/api")
app.include_router(groups.router, prefix="/api") app.include_router(groups.router, prefix="/api")
app.include_router(aliases.router, prefix="/api") app.include_router(aliases.router, prefix="/api")
+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)
+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)
+4
View File
@@ -44,3 +44,7 @@ class OpnsenseHost(Base):
policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship( policy_hierarchy_group: Mapped["PolicyHierarchyGroup | None"] = relationship(
"PolicyHierarchyGroup", back_populates="hosts" "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)
+3
View File
@@ -51,6 +51,9 @@ class NotificationSettings(Base):
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
scheduled_maintenance_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")
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300) poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_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")
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())
+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)
+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_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_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) 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( sessions: Mapped[list["Session"]] = relationship(
"Session", back_populates="user", cascade="all, delete-orphan" "Session", back_populates="user", cascade="all, delete-orphan"
+191
View File
@@ -0,0 +1,191 @@
"""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]
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)
+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}
+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}
+22 -1
View File
@@ -5,6 +5,7 @@ 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. read-only, evaluates it and stores the result; see services/compliance_service.py.
""" """
import uuid import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
@@ -14,7 +15,8 @@ from app.database import get_db
from app.errors import ErrorCode, api_error from app.errors import ErrorCode, api_error
from app.models.compliance_result import ComplianceResult from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost from app.models.host import OpnsenseHost
from app.services import compliance_service from app.models.rule_hit import RuleHit
from app.services import compliance_service, rule_hit_service
router = APIRouter(tags=["compliance"]) router = APIRouter(tags=["compliance"])
@@ -65,3 +67,22 @@ async def check_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
host = await _host(db, host_id) host = await _host(db, host_id)
row = await compliance_service.check_host(db, host) row = await compliance_service.check_host(db, host)
return _read(host, row, detail=True) 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"]),
)
+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
+120
View File
@@ -0,0 +1,120 @@
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
import logging
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.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"
def _login_error(code: str) -> RedirectResponse:
return RedirectResponse(f"/login?sso_error={code}", status_code=302)
@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 = 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)
return RedirectResponse(url, status_code=302)
@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", f"Anbieter meldet {error or 'keinen Code'}")
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)
response = RedirectResponse("/dashboard", status_code=302)
_set_session_cookie(response, token)
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"]}
+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)
+3 -2
View File
@@ -2,7 +2,7 @@
Session-only: an API token cannot reach these endpoints (AuthMiddleware), so a 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 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. read_only user may manage their own tokens, but only with read scope (an operator up to deploy).
""" """
import uuid import uuid
@@ -43,7 +43,8 @@ async def list_tokens(db: AsyncSession = Depends(get_db), user: AdminUser = Depe
async def create_token( async def create_token(
payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user) payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
): ):
if user.role != "admin" and payload.scope != TokenScope.read: 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) raise api_error(403, ErrorCode.API_TOKEN_SCOPE_TOO_HIGH)
_check_account_budget(user, "create-token") _check_account_budget(user, "create-token")
if not await auth_service.verify_password_async(user.password_hash, payload.current_password): if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
+4
View File
@@ -47,6 +47,8 @@ async def update_role(user_id: uuid.UUID, payload: UserRoleUpdate, db: AsyncSess
target = await db.get(AdminUser, user_id) target = await db.get(AdminUser, user_id)
if target is None: if target is None:
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN) 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: 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) raise api_error(409, ErrorCode.MINDESTENS_EIN_ADMIN_ACCOUNT_MUSS_BESTEHEN_BLEIBEN)
target.role = payload.role.value target.role = payload.role.value
@@ -75,6 +77,8 @@ async def reset_password(user_id: uuid.UUID, payload: UserPasswordReset, db: Asy
target = await db.get(AdminUser, user_id) target = await db.get(AdminUser, user_id)
if target is None: if target is None:
raise api_error(404, ErrorCode.BENUTZER_NICHT_GEFUNDEN) 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) 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 # A reset is the answer to a suspected compromise: every session of the
# account ends with the old password, atomically with the new hash. # account ends with the old password, atomically with the new hash.
+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.match(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.match(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)
+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
@@ -11,6 +11,9 @@ ProblemType = Literal[
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown", "host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift", "carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
"scheduled_maintenance_failed", "scheduled_maintenance_failed",
"backup_stale",
"backup_offsite_failed",
"compliance_finding",
] ]
@@ -40,6 +43,8 @@ class NotificationSettingsRead(BaseModel):
network_drift_enabled: bool = True network_drift_enabled: bool = True
carp_auto_revert_failed_enabled: bool carp_auto_revert_failed_enabled: bool
scheduled_maintenance_failed_enabled: bool = True scheduled_maintenance_failed_enabled: bool = True
backup_failed_enabled: bool = True
compliance_alerts_enabled: bool = False
poll_interval_seconds: int poll_interval_seconds: int
updated_at: datetime updated_at: datetime
@@ -77,6 +82,8 @@ class NotificationSettingsUpdate(BaseModel):
network_drift_enabled: bool | None = None network_drift_enabled: bool | None = None
carp_auto_revert_failed_enabled: bool | None = None carp_auto_revert_failed_enabled: bool | None = None
scheduled_maintenance_failed_enabled: bool | None = None scheduled_maintenance_failed_enabled: bool | None = None
backup_failed_enabled: bool | None = None
compliance_alerts_enabled: bool | None = None
poll_interval_seconds: int | None = Field( poll_interval_seconds: int | None = Field(
default=None, ge=settings.notification_poll_min_interval_seconds default=None, ge=settings.notification_poll_min_interval_seconds
+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
+4
View File
@@ -7,6 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
class UserRole(str, Enum): class UserRole(str, Enum):
admin = "admin" 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" read_only = "read_only"
@@ -36,3 +39,4 @@ class UserRead(BaseModel):
created_at: datetime created_at: datetime
last_login_at: datetime | None last_login_at: datetime | None
totp_enabled: bool = False totp_enabled: bool = False
auth_source: str = "local"
+9 -3
View File
@@ -18,7 +18,7 @@ from app.models.host import OpnsenseHost
from app.models.alias import FirewallAlias from app.models.alias import FirewallAlias
from app.models.alias_deployment import AliasDeployment from app.models.alias_deployment import AliasDeployment
from app.services.audit_service import tracked_rollback 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.deploy_flow import push_to_host
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
@@ -27,7 +27,12 @@ def _now() -> datetime:
return datetime.now(timezone.utc) 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 { return {
"enabled": "1" if alias.enabled else "0", "enabled": "1" if alias.enabled else "0",
"name": alias.name, "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: 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( existing = await db.scalar(
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id) 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.host_maintenance_service import ensure_no_maintenance
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
await ensure_no_maintenance(db, hosts) 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] deployments = [await _deploy_to_host(db, alias, host) for host in hosts]
await db.flush() await db.flush()
for host, deployment in zip(hosts, deployments): for host, deployment in zip(hosts, deployments):
+10 -4
View File
@@ -9,6 +9,7 @@ import json
from app.models.policy import AliasRevision from app.models.policy import AliasRevision
from app.services.alias_deploy_service import _build_payload from app.services.alias_deploy_service import _build_payload
from app.services import variables_service
from app.services.policy_service import effective_hosts from app.services.policy_service import effective_hosts
ALIAS_FIELDS = ("name", "type", "content", "description", "enabled") ALIAS_FIELDS = ("name", "type", "content", "description", "enabled")
@@ -28,13 +29,17 @@ def revision(db, alias, note):
def preview(alias): def preview(alias):
payload = _build_payload(alias)
deployments = {d.host_id: d for d in alias.deployments} deployments = {d.host_id: d for d in alias.deployments}
hosts = effective_hosts(alias) hosts = effective_hosts(alias)
blocked = any(d.status in ("pending", "revert_failed") for d in alias.deployments) blocked = any(d.status in ("pending", "revert_failed") for d in alias.deployments)
rows = [] rows = []
for host in hosts: for host in hosts:
dep = deployments.get(host.id) 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 before = dep.last_payload if dep else None
action = "create" if not dep or not dep.opnsense_alias_uuid else "update" action = "create" if not dep or not dep.opnsense_alias_uuid else "update"
if before == payload and dep and dep.status in ("success", "reverted"): 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.") warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
if any(r["action"] == "retained" for r in rows): 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.") 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): if any(r["action"] == "disabled" for r in rows):
warnings.append("Deaktivierte Hosts werden übersprungen.") warnings.append("Deaktivierte Hosts werden übersprungen.")
if any(d.status == "error" for d in alias.deployments): 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])} 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() token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
return {"token": token, "hosts": rows, "warnings": warnings, 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."} "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
@@ -79,12 +86,11 @@ def workflow(alias, deployments):
hosts = effective_hosts(alias) hosts = effective_hosts(alias)
active = [h for h in hosts if h.enabled] active = [h for h in hosts if h.enabled]
by_host = {d.host_id: d for d in deployments} by_host = {d.host_id: d for d in deployments}
payload = _build_payload(alias)
target_ids = {h.id for h in hosts} target_ids = {h.id for h in hosts}
counts = { counts = {
"targets": len(hosts), "targets": len(hosts),
"active_targets": len(active), "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), "pending": sum(d.status == "pending" for d in deployments),
"errors": sum(d.status in ("error", "revert_failed") 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), "retained": sum(d.host_id not in target_ids for d in deployments),
+20 -2
View File
@@ -41,11 +41,11 @@ _SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)") _SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the # Reads that expose secrets (config.xml holds password hashes and VPN keys, the
# notification settings the webhook URL/SMTP data): admin scope only. # notification settings the webhook URL/SMTP data): admin scope only.
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)") _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. # The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
_MCP_PATH = "/api/mcp" _MCP_PATH = "/api/mcp"
_ADMIN_ONLY_WRITES = re.compile( _ADMIN_ONLY_WRITES = re.compile(
r"^/api/(users|settings|system|notifications)(/|$)" r"^/api/(users|settings|system|notifications|overlay)(/|$)"
r"|^/api/logs/settings$" r"|^/api/logs/settings$"
r"|^/api/hosts(/tls-probe|/[^/]+)?$" r"|^/api/hosts(/tls-probe|/[^/]+)?$"
) )
@@ -74,6 +74,24 @@ def denies(scope: str, method: str, path: str) -> bool:
return False 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, and applying network changes.
_ROLLOUT = re.compile(
r"/(deploy|deployments/[^/]+/(remove|retry-rollback)|maintenance/(execute|carp-execute|schedule)|network/changes)$"
)
def is_rollout(method: str, path: str) -> bool:
return method == "POST" and bool(_ROLLOUT.search(path))
def bearer_from_header(header: str) -> str | None: def bearer_from_header(header: str) -> str | None:
"""The token of an ``Authorization: Bearer opx_...`` header, else None.""" """The token of an ``Authorization: Bearer opx_...`` header, else None."""
scheme, _, value = header.partition(" ") scheme, _, value = header.partition(" ")
@@ -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])
+3
View File
@@ -88,6 +88,9 @@ def deployment_history(session, flush_context, instances):
if entity is not None and action == "deploy": if entity is not None and action == "deploy":
from importlib import import_module from importlib import import_module
builder = import_module(f"app.services.{kind}_deploy_service")._build_payload builder = import_module(f"app.services.{kind}_deploy_service")._build_payload
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) details["requested_payload"] = builder(entity)
session.add(make_event(kind=kind, action=action, status="removed" if deleted else dep.status, 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)), entity_id=str(entity_id), entity_name=(getattr(entity, "name", None) or getattr(entity, "description", None)),
+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
@@ -0,0 +1,55 @@
"""Readable, redacted diff between two stored config backups of the same host.
Both files are parsed (defusedxml), the volatile ``revision`` block is dropped, and every leaf that
looks like a secret (password, key, token, PSK, community...) or is a long blob (certificates,
private keys) is replaced by a short hash. A changed secret therefore still shows up as a change,
but its value never leaves the server. Output is a unified diff, capped in size.
"""
import difflib
import hashlib
import re
from xml.etree.ElementTree import Element, indent, tostring
import defusedxml.ElementTree as safe_ET
MAX_LINES = 3000
MAX_BYTES = 32 * 1024 * 1024
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
_BLOB = 120
class InvalidConfig(ValueError):
pass
def _redact(root: Element) -> None:
for el in root.iter():
if len(el) == 0 and el.text and el.text.strip():
text = el.text.strip()
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
def normalise(xml_bytes: bytes) -> list[str]:
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
raise InvalidConfig("leer oder zu groß")
try:
root = safe_ET.fromstring(xml_bytes)
except Exception as exc: # defusedxml raises several parse/forbidden-entity types
raise InvalidConfig(type(exc).__name__) from None
if root.tag != "opnsense":
raise InvalidConfig("keine OPNsense-Konfiguration")
for volatile in root.findall("revision"):
root.remove(volatile)
_redact(root)
indent(root, space=" ")
return tostring(root, encoding="unicode").splitlines()
def diff(old: bytes, new: bytes) -> dict:
a, b = normalise(old), normalise(new)
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
return {"identical": not lines, "added": added, "removed": removed, "truncated": len(lines) > MAX_LINES, "lines": lines[:MAX_LINES]}
@@ -0,0 +1,136 @@
"""Scheduled config backups: take, keep the newest N, copy encrypted offsite.
Called by a poll loop (main.py). Per enabled host a new snapshot is taken when the newest one
is older than the interval; the oldest beyond ``keep_count`` are deleted. When an offsite target
is set, every local snapshot that is not offsite yet is encrypted (backup_crypto) and uploaded
(s3_client); the ledger ``backup_uploads`` remembers what is already there. Nothing is ever
deleted offsite: use the bucket's lifecycle rules for that.
"""
import logging
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.backup_settings import BackupSettings, BackupUpload
from app.models.host import OpnsenseHost
from app.services import backup_crypto, backup_store, notification_secret_store
from app.services import opnsense_client as client
from app.services.opnsense_client import OpnsenseAPIError
from app.services.s3_client import S3Client, S3Error
logger = logging.getLogger(__name__)
SINGLETON_ID = 1
S3_SECRET, PASSPHRASE = "backup_s3_secret", "backup_passphrase"
MAX_UPLOADS_PER_RUN = 20
async def get_settings(db: AsyncSession) -> BackupSettings:
row = await db.get(BackupSettings, SINGLETON_ID)
if row is None:
row = BackupSettings(
id=SINGLETON_ID, scheduled_enabled=True, interval_hours=24, keep_count=30, offsite_enabled=False,
s3_region="us-east-1", s3_prefix="opnexus", s3_path_style=True, s3_secret_set=False, passphrase_set=False,
)
db.add(row)
await db.flush()
return row
def offsite_ready(cfg: BackupSettings) -> bool:
return bool(cfg.offsite_enabled and cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and cfg.s3_secret_set and cfg.passphrase_set)
def s3_for(cfg: BackupSettings) -> S3Client:
secret = notification_secret_store.read_secret(S3_SECRET)
if not (cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and secret):
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten)")
return S3Client(endpoint=cfg.s3_endpoint, region=cfg.s3_region, bucket=cfg.s3_bucket, access_key=cfg.s3_access_key,
secret_key=secret, path_style=cfg.s3_path_style)
def remote_key(cfg: BackupSettings, host: OpnsenseHost, snapshot_id: str) -> str:
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", host.name).strip("-") or "host"
prefix = cfg.s3_prefix.strip("/")
return "/".join(p for p in (prefix, f"{safe}-{str(host.id)[:8]}", snapshot_id.removesuffix(".xml") + ".opxbak") if p)
async def _take(host: OpnsenseHost) -> str:
content = await client.download_backup(host)
if b"<opnsense" not in content[:4096]:
raise OpnsenseAPIError("Antwort ist keine OPNsense-Konfiguration")
return backup_store.save_snapshot(host.id, content)
async def _prune(db: AsyncSession, host: OpnsenseHost, keep: int) -> int:
old = backup_store.list_snapshots(host.id)[keep:]
for snapshot in old:
backup_store.delete_snapshot(host.id, snapshot["id"])
if old:
rows = (await db.scalars(select(BackupUpload).where(BackupUpload.host_id == host.id, BackupUpload.snapshot_id.in_([s["id"] for s in old])))).all()
for row in rows:
await db.delete(row)
return len(old)
async def upload_pending(db: AsyncSession, cfg: BackupSettings, hosts: list[OpnsenseHost]) -> int:
"""Encrypt and upload local snapshots that are not offsite yet (newest first, bounded per run)."""
passphrase = notification_secret_store.read_secret(PASSPHRASE)
if not passphrase:
raise S3Error("Passphrase fehlt oder ist nicht lesbar – bitte neu setzen")
s3 = s3_for(cfg)
done, budget = 0, MAX_UPLOADS_PER_RUN
for host in hosts:
have = set((await db.scalars(select(BackupUpload.snapshot_id).where(BackupUpload.host_id == host.id))).all())
for snapshot in backup_store.list_snapshots(host.id):
if snapshot["id"] in have:
continue
if budget == 0:
return done
content = backup_store.read_snapshot(host.id, snapshot["id"])
if content is None:
continue
key = remote_key(cfg, host, snapshot["id"])
await s3.put(key, backup_crypto.encrypt(content, passphrase))
db.add(BackupUpload(host_id=host.id, snapshot_id=snapshot["id"], remote_key=key, uploaded_at=datetime.now(timezone.utc)))
await db.commit()
budget -= 1
done += 1
return done
async def run_due(db: AsyncSession, *, force: bool = False) -> dict:
cfg = await get_settings(db)
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
taken = failed = pruned = 0
if cfg.scheduled_enabled or force:
now = datetime.now(timezone.utc)
for host in hosts:
snapshots = backup_store.list_snapshots(host.id)
if not force and snapshots and now - snapshots[0]["created_at"] < timedelta(hours=cfg.interval_hours):
pruned += await _prune(db, host, cfg.keep_count)
continue
try:
await _take(host)
taken += 1
except OpnsenseAPIError as exc:
failed += 1
logger.warning("Geplantes Backup von %s fehlgeschlagen: %s", host.name, exc)
pruned += await _prune(db, host, cfg.keep_count)
cfg.last_run_at = now
uploaded = 0
if cfg.offsite_enabled:
try:
if not offsite_ready(cfg):
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten, Passphrase)")
uploaded = await upload_pending(db, cfg, hosts)
cfg.last_offsite_error = None
if uploaded or cfg.last_offsite_ok_at is None:
cfg.last_offsite_ok_at = datetime.now(timezone.utc)
except S3Error as exc:
cfg.last_offsite_error = str(exc)[:300]
logger.warning("Offsite-Backup: %s", exc)
await db.commit()
return {"hosts": len(hosts), "taken": taken, "failed": failed, "pruned": pruned, "uploaded": uploaded}
+10 -1
View File
@@ -42,7 +42,8 @@ def list_snapshots(host_id: uuid.UUID) -> list[dict]:
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
"size": stat.st_size, "size": stat.st_size,
}) })
return sorted(items, key=lambda i: i["created_at"], reverse=True) # Names are sortable timestamps: they break ties between files written within one mtime tick.
return sorted(items, key=lambda i: (i["created_at"], i["id"]), reverse=True)
def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None: def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
@@ -53,3 +54,11 @@ def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
if backup_id != path.name or not path.is_file(): if backup_id != path.name or not path.is_file():
return None return None
return path.read_bytes() return path.read_bytes()
def delete_snapshot(host_id: uuid.UUID, backup_id: str) -> bool:
path = _host_dir(host_id) / backup_id
if backup_id != path.name or not path.is_file():
return False
path.unlink()
return True
+23 -3
View File
@@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.compliance_result import ComplianceResult from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost from app.models.host import OpnsenseHost
from app.services import backup_store from app.services import backup_store, rule_hit_service
from app.services import opnsense_client as client from app.services import opnsense_client as client
from app.services.opnsense_client import OpnsenseAPIError from app.services.opnsense_client import OpnsenseAPIError
@@ -31,6 +31,15 @@ BACKUP_MAX_AGE_DAYS = 7
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0} PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info") SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
MAX_EXAMPLES = 5 MAX_EXAMPLES = 5
# German titles for notifications (the UI translates the check ids itself).
TITLES_DE = {
"webgui_https": "Web-Oberfläche nur über HTTPS", "ssh_hardened": "SSH abgesichert", "snmp_community": "SNMP-Community nicht „public“",
"wan_any_any": "Keine Allow-all-Regel am WAN", "internal_any_any": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
"rules_shadowed": "Keine verdeckten Regeln", "rules_duplicate": "Keine doppelten Regeln", "rules_documented": "Regeln sind beschriftet",
"rules_unused": "Regeln in Benutzung", "ntp_configured": "Zeitserver gesetzt", "remote_logging": "Logs liegen auch woanders",
"ids_enabled": "Einbruchserkennung aktiv", "firmware_current": "Firmware aktuell", "backup_recent": "Aktuelles Backup",
}
SEVERITIES_DE = {"critical": "kritisch", "high": "hoch", "medium": "mittel", "low": "niedrig", "info": "Hinweis"}
def grade(score: int) -> str: def grade(score: int) -> str:
@@ -171,7 +180,8 @@ def _listing(items: list[str]) -> dict:
return {"count": len(items), "examples": items[:MAX_EXAMPLES]} return {"count": len(items), "examples": items[:MAX_EXAMPLES]}
def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None) -> list[dict]: def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None,
unused_rules: dict | None = None) -> list[dict]:
if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip(): if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip():
raise ValueError("config.xml missing or too large") raise ValueError("config.xml missing or too large")
root = safe_ET.fromstring(xml_bytes) root = safe_ET.fromstring(xml_bytes)
@@ -212,6 +222,11 @@ def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age
undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]] undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]]
findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented))) findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented)))
# Usage (from the pf counters OPNexus has been sampling; None = no data yet)
if unused_rules is not None:
names = unused_rules["unused"]
findings.append(_finding("rules_unused", "low", None if (not names and unused_rules["collecting"]) else not names, **_listing(names)))
# Operations # Operations
findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers")))) findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers"))))
syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"] syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"]
@@ -257,7 +272,12 @@ async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
firmware_status = None firmware_status = None
snapshots = backup_store.list_snapshots(host.id) snapshots = backup_store.list_snapshots(host.id)
age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age) try:
await rule_hit_service.sample_host(db, host)
except OpnsenseAPIError:
pass # usage data is a bonus; the check does not depend on it
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age,
unused_rules=await rule_hit_service.unused_summary(db, host.id))
row.findings, row.score, row.error = findings, score(findings), None row.findings, row.score, row.error = findings, score(findings), None
except OpnsenseAPIError as exc: except OpnsenseAPIError as exc:
row.findings, row.score, row.error = [], None, "unreachable" row.findings, row.score, row.error = [], None, "unreachable"
+52 -1
View File
@@ -9,6 +9,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alias_deployment import AliasDeployment from app.models.alias_deployment import AliasDeployment
from app.models.backup_settings import BackupSettings
from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation from app.models.host_operation import HostOperation
from app.models.one_to_one_deployment import OneToOneDeployment from app.models.one_to_one_deployment import OneToOneDeployment
@@ -16,7 +18,7 @@ from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.port_forward_deployment import PortForwardDeployment from app.models.port_forward_deployment import PortForwardDeployment
from app.models.rule_deployment import RuleDeployment from app.models.rule_deployment import RuleDeployment
from app.models.scheduled_maintenance import ScheduledMaintenance from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import nat_drift_service, network_drift_service, rule_drift_service from app.services import backup_store, nat_drift_service, network_drift_service, rule_drift_service
from app.services.notification_dedup import Problems from app.services.notification_dedup import Problems
from app.services.host_status_service import get_statuses from app.services.host_status_service import get_statuses
from app.services.live_certificates_service import get_live_certificates from app.services.live_certificates_service import get_live_certificates
@@ -290,3 +292,52 @@ async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict
"message": (sched.result or {}).get("error"), "message": (sched.result or {}).get("error"),
} }
return problems return problems
async def check_backup_stale(db: AsyncSession) -> dict[str, dict]:
"""Scheduled backups are on but a host has no recent one: three missed intervals
(at least 6 hours) cover a reboot or a short outage without crying wolf."""
cfg = await db.get(BackupSettings, 1)
if cfg is not None and not cfg.scheduled_enabled:
return {}
interval = cfg.interval_hours if cfg is not None else 24
limit = timedelta(hours=max(3 * interval, 6))
now = datetime.now(timezone.utc)
problems: dict[str, dict] = {}
for host in await _enabled_hosts(db):
snapshots = backup_store.list_snapshots(host.id)
newest = snapshots[0]["created_at"] if snapshots else None
if (newest is None and now - host.created_at > limit) or (newest is not None and now - newest > limit):
problems[str(host.id)] = {
"host_id": str(host.id), "host_name": host.name,
"last_backup": newest.isoformat() if newest else "noch nie",
}
return problems
async def check_backup_offsite(db: AsyncSession) -> dict[str, dict]:
cfg = await db.get(BackupSettings, 1)
if cfg is None or not cfg.offsite_enabled or not cfg.last_offsite_error:
return {}
return {"offsite": {"message": cfg.last_offsite_error}}
async def check_compliance(db: AsyncSession) -> dict[str, dict]:
"""Critical and high findings of the last security check, one problem per (host, check); resolved when it passes."""
from app.services import compliance_service as cs
hosts = {h.id: h for h in await _enabled_hosts(db)}
problems: dict[str, dict] = {}
for row in (await db.scalars(select(ComplianceResult))).all():
host = hosts.get(row.host_id)
if host is None or row.error:
continue
for finding in row.findings:
if finding["passed"] is False and finding["severity"] in ("critical", "high"):
params = finding.get("params") or {}
problems[f"{host.id}:{finding['id']}"] = {
"host_id": str(host.id), "host_name": host.name, "check": finding["id"],
"befund": cs.TITLES_DE.get(finding["id"], finding["id"]),
"schweregrad": cs.SEVERITIES_DE[finding["severity"]],
"beispiele": ", ".join(params.get("examples", [])) or None,
}
return problems
@@ -34,6 +34,9 @@ _LABELS = {
"network_drift": "Netzwerk-Abweichung im Cluster", "network_drift": "Netzwerk-Abweichung im Cluster",
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen", "carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt", "scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
"backup_stale": "Kein aktuelles Backup",
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
"compliance_finding": "Sicherheitsbefund",
} }
@@ -31,6 +31,9 @@ _CHECKS = [
("network_drift", "network_drift_enabled", notification_checks.check_network_drift), ("network_drift", "network_drift_enabled", notification_checks.check_network_drift),
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed), ("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed), ("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
("compliance_finding", "compliance_alerts_enabled", notification_checks.check_compliance),
] ]
# Event-like problems: when they drop out of the window nothing was "fixed", so no # Event-like problems: when they drop out of the window nothing was "fixed", so no
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
"smtp_from_address", "smtp_to_addresses", "smtp_from_address", "smtp_to_addresses",
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled", "host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled", "revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
"poll_interval_seconds", "poll_interval_seconds",
] ]
for field in simple_fields: for field in simple_fields:
+270
View File
@@ -0,0 +1,270 @@
"""OpenID Connect login (authorization code flow with PKCE) and role mapping.
What is checked on the ID token, in this order of suspicion: the signature (against the provider's JWKS,
asymmetric algorithms only: ``none`` and HMAC are never accepted), ``iss`` equal to the configured issuer,
``aud`` containing our client id (and ``azp`` equal to it if there are several audiences), ``exp``/``nbf``,
and the ``nonce`` we sent. The role comes from the groups claim (ID token, else userinfo) at EVERY login, so
removing someone from the group takes the access away at their next sign-in. Local accounts are never linked
to or taken over by an identity: an OIDC user is keyed by ``<issuer>|<sub>`` and a name clash with a local
account gets a suffix. Without a mapped group nobody is let in.
"""
import base64
import hashlib
import logging
import re
import secrets
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
import jwt
from jwt import PyJWK
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.oidc import OidcLoginState, OidcSettings
from app.models.user import AdminUser
from app.services import notification_secret_store
logger = logging.getLogger(__name__)
SINGLETON_ID = 1
SECRET_NAME = "oidc_client_secret"
STATE_TTL = timedelta(minutes=10)
ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"]
UNUSABLE_PASSWORD = "!oidc"
_ROLE_ORDER = ("admin", "operator", "read_only")
class OidcError(Exception):
"""Carries a short, user-safe code (shown on the login page), never provider internals."""
def __init__(self, code: str, detail: str = ""):
super().__init__(detail or code)
self.code = code
async def get_settings(db: AsyncSession) -> OidcSettings:
row = await db.get(OidcSettings, SINGLETON_ID)
if row is None:
row = OidcSettings(
id=SINGLETON_ID, enabled=False, display_name="SSO", client_secret_set=False,
scopes="openid profile email groups", groups_claim="groups",
)
db.add(row)
await db.flush()
return row
def configured(cfg: OidcSettings) -> bool:
return bool(cfg.enabled and cfg.issuer and cfg.client_id and (cfg.admin_group or cfg.operator_group or cfg.read_only_group))
# ── provider metadata (cached) ───────────────────────────────────────────
_discovery_cache: dict[str, tuple[float, dict]] = {}
_jwks_cache: dict[str, tuple[float, dict]] = {}
_DISCOVERY_TTL, _JWKS_TTL, _JWKS_MIN_REFETCH = 600, 3600, 60
def clear_caches() -> None:
_discovery_cache.clear()
_jwks_cache.clear()
async def _get_json(url: str, **kw) -> dict:
if not url.startswith("https://"):
raise OidcError("provider", "Nur https:// wird akzeptiert")
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
response = await client.get(url, **kw)
response.raise_for_status()
data = response.json()
except (httpx.HTTPError, ValueError):
raise OidcError("provider", "Der Identitätsanbieter ist nicht erreichbar oder antwortet unverständlich") from None
if not isinstance(data, dict):
raise OidcError("provider", "Unerwartete Antwort des Identitätsanbieters")
return data
async def discovery(issuer: str) -> dict:
cached = _discovery_cache.get(issuer)
if cached and time.monotonic() - cached[0] < _DISCOVERY_TTL:
return cached[1]
doc = await _get_json(issuer.rstrip("/") + "/.well-known/openid-configuration")
if doc.get("issuer") != issuer and doc.get("issuer", "").rstrip("/") != issuer.rstrip("/"):
raise OidcError("provider", "Der Aussteller in der Discovery-Antwort passt nicht zur eingetragenen URL")
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
if not str(doc.get(key, "")).startswith("https://"):
raise OidcError("provider", f"Discovery enthält kein gültiges {key}")
_discovery_cache[issuer] = (time.monotonic(), doc)
return doc
async def _jwk_for(doc: dict, kid: str | None) -> PyJWK:
uri = doc["jwks_uri"]
for attempt in (0, 1):
cached = _jwks_cache.get(uri)
stale = cached is None or time.monotonic() - cached[0] > _JWKS_TTL
if stale or attempt == 1:
if attempt == 1 and cached and time.monotonic() - cached[0] < _JWKS_MIN_REFETCH:
break # unknown kid, but we just fetched: do not let a forged kid hammer the provider
_jwks_cache[uri] = (time.monotonic(), await _get_json(uri))
for key in _jwks_cache[uri][1].get("keys", []):
if (kid is None or key.get("kid") == kid) and key.get("use", "sig") == "sig":
try:
return PyJWK(key)
except Exception: # unsupported key type: try the next one
continue
raise OidcError("token", "Kein passender Signaturschlüssel")
# ── the flow ─────────────────────────────────────────────────────────────
def _hash(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def _challenge(verifier: str) -> str:
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
async def start(db: AsyncSession, cfg: OidcSettings, redirect_uri: str) -> str:
"""Remember a fresh login attempt and return the provider URL to send the browser to."""
doc = await discovery(cfg.issuer)
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(24), secrets.token_urlsafe(48)
await db.execute(delete(OidcLoginState).where(OidcLoginState.created_at < datetime.now(timezone.utc) - STATE_TTL))
db.add(OidcLoginState(state_hash=_hash(state), nonce=nonce, code_verifier=verifier, redirect_uri=redirect_uri))
await db.commit()
query = urlencode({
"response_type": "code", "client_id": cfg.client_id, "redirect_uri": redirect_uri, "scope": cfg.scopes,
"state": state, "nonce": nonce, "code_challenge": _challenge(verifier), "code_challenge_method": "S256",
})
return f"{doc['authorization_endpoint']}{'&' if '?' in doc['authorization_endpoint'] else '?'}{query}"
async def _consume_state(db: AsyncSession, state: str) -> OidcLoginState:
row = await db.scalar(select(OidcLoginState).where(OidcLoginState.state_hash == _hash(state)).with_for_update())
if row is None:
raise OidcError("state", "Unbekannter oder bereits benutzter Anmeldeversuch")
created = row.created_at
await db.delete(row) # single use, whatever happens next
await db.commit()
if datetime.now(timezone.utc) - created > STATE_TTL:
raise OidcError("state", "Der Anmeldeversuch ist abgelaufen")
return row
async def _exchange(cfg: OidcSettings, doc: dict, code: str, state: OidcLoginState) -> dict:
data = {"grant_type": "authorization_code", "code": code, "redirect_uri": state.redirect_uri,
"client_id": cfg.client_id, "code_verifier": state.code_verifier}
secret = notification_secret_store.read_secret(SECRET_NAME) if cfg.client_secret_set else None
auth = None
if secret:
methods = doc.get("token_endpoint_auth_methods_supported") or ["client_secret_basic"]
if "client_secret_basic" in methods:
auth = httpx.BasicAuth(cfg.client_id, secret)
else:
data["client_secret"] = secret
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
response = await client.post(doc["token_endpoint"], data=data, auth=auth)
if response.status_code >= 400:
raise OidcError("token", f"Token-Endpunkt antwortet mit HTTP {response.status_code}")
tokens = response.json()
except httpx.HTTPError:
raise OidcError("provider", "Token-Endpunkt nicht erreichbar") from None
except ValueError:
raise OidcError("token", "Unverständliche Token-Antwort") from None
if not isinstance(tokens, dict) or not tokens.get("id_token"):
raise OidcError("token", "Die Antwort enthält kein ID-Token")
return tokens
async def _verify_id_token(cfg: OidcSettings, doc: dict, id_token: str, nonce: str) -> dict:
try:
header = jwt.get_unverified_header(id_token)
except jwt.PyJWTError:
raise OidcError("token", "ID-Token unlesbar") from None
if header.get("alg") not in ALGORITHMS:
raise OidcError("token", f"Nicht erlaubter Signaturalgorithmus {header.get('alg')!r}")
key = await _jwk_for(doc, header.get("kid"))
try:
claims = jwt.decode(
id_token, key.key, algorithms=ALGORITHMS, audience=cfg.client_id, issuer=cfg.issuer,
options={"require": ["exp", "iss", "aud", "sub"]}, leeway=60,
)
except jwt.PyJWTError as exc:
raise OidcError("token", f"ID-Token ungültig ({type(exc).__name__})") from None
audience = claims.get("aud")
if isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != cfg.client_id:
raise OidcError("token", "azp passt nicht zur Client-ID")
if not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
raise OidcError("token", "Nonce stimmt nicht")
return claims
def _as_groups(value) -> list[str]:
if isinstance(value, str):
return [g for g in re.split(r"[,\s]+", value) if g]
if isinstance(value, list):
return [str(g) for g in value]
return []
def role_for(cfg: OidcSettings, groups: list[str]) -> str | None:
"""The most privileged role whose group the user is in; None = not allowed in."""
wanted = {"admin": cfg.admin_group, "operator": cfg.operator_group, "read_only": cfg.read_only_group}
have = {g.lower() for g in groups}
for role in _ROLE_ORDER:
group = wanted[role]
if group and group.strip().lower() in have:
return role
return None
async def _groups(cfg: OidcSettings, doc: dict, claims: dict, access_token: str | None) -> list[str]:
if cfg.groups_claim in claims:
return _as_groups(claims[cfg.groups_claim])
if access_token and str(doc.get("userinfo_endpoint", "")).startswith("https://"):
info = await _get_json(doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"})
if info.get("sub") == claims.get("sub"): # a userinfo answer for someone else is worthless
return _as_groups(info.get(cfg.groups_claim))
return []
def _username(claims: dict) -> str:
raw = claims.get("preferred_username") or claims.get("email") or claims["sub"]
return re.sub(r"[\x00-\x1f]", "", str(raw)).strip()[:140] or "sso-user"
async def provision(db: AsyncSession, cfg: OidcSettings, claims: dict, role: str) -> AdminUser:
"""The account for this identity: created on first login, role synced on every login."""
subject = f"{cfg.issuer}|{claims['sub']}"[:600]
user = await db.scalar(select(AdminUser).where(AdminUser.oidc_subject == subject))
if user is None:
base, name, n = _username(claims), None, 0
while True:
name = base if n == 0 else f"{base}~sso{n if n > 1 else ''}"
if await db.scalar(select(AdminUser.id).where(AdminUser.username == name)) is None:
break
n += 1
user = AdminUser(username=name, password_hash=UNUSABLE_PASSWORD, role=role, auth_source="oidc", oidc_subject=subject)
db.add(user)
else:
user.role = role
await db.commit()
return user
async def complete(db: AsyncSession, cfg: OidcSettings, code: str, state_value: str) -> AdminUser:
"""Callback: state -> tokens -> verified identity -> role -> account. Raises OidcError."""
state = await _consume_state(db, state_value)
doc = await discovery(cfg.issuer)
tokens = await _exchange(cfg, doc, code, state)
claims = await _verify_id_token(cfg, doc, tokens["id_token"], state.nonce)
role = role_for(cfg, await _groups(cfg, doc, claims, tokens.get("access_token")))
if role is None:
raise OidcError("no_role", "Kein zugeordnete Gruppe")
return await provision(db, cfg, claims, role)
@@ -9,7 +9,7 @@ from app.models.host import OpnsenseHost
from app.models.one_to_one import OneToOneRule from app.models.one_to_one import OneToOneRule
from app.models.one_to_one_deployment import OneToOneDeployment from app.models.one_to_one_deployment import OneToOneDeployment
from app.services.audit_service import tracked_rollback 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.deploy_flow import push_to_host
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
@@ -18,7 +18,12 @@ def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _build_payload(rule: OneToOneRule) -> dict: def _build_payload(rule: OneToOneRule, host: OpnsenseHost | None = None) -> dict:
payload = _base_payload(rule)
return variables_service.resolve(payload, host) if host is not None else payload
def _base_payload(rule: OneToOneRule) -> dict:
return { return {
"description": rule.description, "description": rule.description,
"interface": rule.interface, "interface": rule.interface,
@@ -35,7 +40,7 @@ def _build_payload(rule: OneToOneRule) -> dict:
async def _deploy_to_host(db: AsyncSession, rule: OneToOneRule, host: OpnsenseHost) -> OneToOneDeployment: async def _deploy_to_host(db: AsyncSession, rule: OneToOneRule, host: OpnsenseHost) -> OneToOneDeployment:
payload = _build_payload(rule) payload = _build_payload(rule, host)
existing = await db.scalar( existing = await db.scalar(
select(OneToOneDeployment).where( select(OneToOneDeployment).where(
OneToOneDeployment.one_to_one_id == rule.id, OneToOneDeployment.host_id == host.id OneToOneDeployment.one_to_one_id == rule.id, OneToOneDeployment.host_id == host.id
@@ -78,6 +83,7 @@ async def deploy_one_to_one(db: AsyncSession, rule: OneToOneRule, hosts: list[Op
from app.services.host_maintenance_service import ensure_no_maintenance from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
await ensure_no_maintenance(db, hosts) await ensure_no_maintenance(db, hosts)
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
deployments = [await _deploy_to_host(db, rule, host) for host in hosts] deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
await db.flush() await db.flush()
for host, deployment in zip(hosts, deployments): for host, deployment in zip(hosts, deployments):
@@ -8,6 +8,7 @@ import json
from app.models.policy import OneToOneRevision from app.models.policy import OneToOneRevision
from app.services.one_to_one_deploy_service import _build_payload from app.services.one_to_one_deploy_service import _build_payload
from app.services import variables_service
from app.services.policy_service import effective_hosts from app.services.policy_service import effective_hosts
ONE_TO_ONE_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'type', 'external', 'natreflection', 'log') ONE_TO_ONE_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'type', 'external', 'natreflection', 'log')
@@ -27,13 +28,17 @@ def revision(db, rule, note):
def preview(rule): def preview(rule):
payload = _build_payload(rule)
deployments = {d.host_id: d for d in rule.deployments} deployments = {d.host_id: d for d in rule.deployments}
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments) blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
rows = [] rows = []
for host in hosts: for host in hosts:
dep = deployments.get(host.id) dep = deployments.get(host.id)
try:
payload = _build_payload(rule, 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 before = dep.last_payload if dep else None
action = "create" if not dep or not dep.opnsense_one_to_one_uuid else "update" action = "create" if not dep or not dep.opnsense_one_to_one_uuid else "update"
if before == payload and dep and dep.status in ("success", "reverted"): if before == payload and dep and dep.status in ("success", "reverted"):
@@ -60,6 +65,8 @@ def preview(rule):
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.") warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
if any(r["action"] == "retained" for r in rows): if any(r["action"] == "retained" for r in rows):
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.") warnings.append("Frühere Ziele behalten diese Regel, 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): if any(r["action"] == "disabled" for r in rows):
warnings.append("Deaktivierte Hosts werden übersprungen.") warnings.append("Deaktivierte Hosts werden übersprungen.")
if any(d.status == "error" for d in rule.deployments): if any(d.status == "error" for d in rule.deployments):
@@ -70,7 +77,7 @@ def preview(rule):
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])} d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
return {"token": token, "hosts": rows, "warnings": warnings, 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."} "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
@@ -78,12 +85,11 @@ def workflow(rule, deployments):
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
active = [h for h in hosts if h.enabled] active = [h for h in hosts if h.enabled]
by_host = {d.host_id: d for d in deployments} by_host = {d.host_id: d for d in deployments}
payload = _build_payload(rule)
target_ids = {h.id for h in hosts} target_ids = {h.id for h in hosts}
counts = { counts = {
"targets": len(hosts), "targets": len(hosts),
"active_targets": len(active), "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, rule, h)),
"pending": sum(d.status == "pending" for d in deployments), "pending": sum(d.status == "pending" for d in deployments),
"errors": sum(d.status in ("error", "revert_failed") 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), "retained": sum(d.host_id not in target_ids for d in deployments),
+23 -1
View File
@@ -16,7 +16,7 @@ import httpx
from app.config import settings from app.config import settings
from app.models.host import OpnsenseHost from app.models.host import OpnsenseHost
from app.services import firewall_url, opnsense_secret_store, tls_pin_service from app.services import firewall_url, opnsense_secret_store, overlay_service, tls_pin_service
from app.services.redaction import redact_data, redact_text from app.services.redaction import redact_data, redact_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,10 +46,23 @@ def _client(host: OpnsenseHost) -> httpx.AsyncClient:
auth=(host.api_key, secret), auth=(host.api_key, secret),
verify=tls_pin_service.verify_argument(host.verify_tls, host.tls_ca_pem), verify=tls_pin_service.verify_argument(host.verify_tls, host.tls_ca_pem),
timeout=settings.opnsense_timeout, timeout=settings.opnsense_timeout,
# Firewalls reached through the management overlay (WireGuard hub) go via the hub's CONNECT proxy.
proxy=overlay_service.proxy_for(host.base_url),
) )
def _refuse_unresolved_placeholders(method: str, path: str, kwargs: dict) -> None:
"""Last line of defence for site variables: a payload that still contains a {{NAME}} placeholder was never
resolved for this host and must not reach a firewall."""
if method.upper() == "GET" or "json" not in kwargs:
return
from app.services.variables_service import PLACEHOLDER
if PLACEHOLDER.search(json.dumps(kwargs["json"], default=str)):
raise OpnsenseAPIError(f"{path}: Die Anfrage enthält eine nicht aufgelöste Variable ({{{{NAME}}}}) und wird nicht gesendet")
async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> httpx.Response: async def _do_request(host: OpnsenseHost, method: str, path: str, **kwargs) -> httpx.Response:
_refuse_unresolved_placeholders(method, path, kwargs)
async with _client(host) as client: async with _client(host) as client:
try: try:
resp = await client.request(method, path, **kwargs) resp = await client.request(method, path, **kwargs)
@@ -369,6 +382,15 @@ async def get_source_nat_settings(host: OpnsenseHost) -> dict:
# Core controllers, checked against OPNsense core source: # Core controllers, checked against OPNsense core source:
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/FirmwareController.php # https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/FirmwareController.php
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php # https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php
async def get_pf_rule_statistics(host: OpnsenseHost) -> dict:
"""`diagnostics/firewall/pfStatistics/rules` (Diagnostics Api FirewallController):
{"rules": {"filter rules": {"@N <pf rule text> label \"<uuid>\"": {evaluations, packets,
bytes, states, ...}}}}. Verified live on OPNsense 26.7: the pf label of a rule created in
the rule editor/API is its UUID, so counters can be tied to rules; automatic rules carry
an md5 label instead."""
return await _request(host, "GET", "/diagnostics/firewall/pfStatistics/rules")
async def get_firmware_status(host: OpnsenseHost) -> dict: async def get_firmware_status(host: OpnsenseHost) -> dict:
# GET reads cached results. POST would trigger a synchronous probe. # GET reads cached results. POST would trigger a synchronous probe.
return await _request(host, "GET", "/core/firmware/status") return await _request(host, "GET", "/core/firmware/status")
@@ -10,7 +10,7 @@ from app.models.outbound_nat import OutboundNatRule
from app.models.outbound_nat_deployment import OutboundNatDeployment from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.services.outbound_nat_mode import require_manual_mode from app.services.outbound_nat_mode import require_manual_mode
from app.services.audit_service import tracked_rollback 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.deploy_flow import push_to_host
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
@@ -19,7 +19,12 @@ def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _build_payload(rule: OutboundNatRule) -> dict: def _build_payload(rule: OutboundNatRule, host: OpnsenseHost | None = None) -> dict:
payload = _base_payload(rule)
return variables_service.resolve(payload, host) if host is not None else payload
def _base_payload(rule: OutboundNatRule) -> dict:
return { return {
"description": rule.description, "description": rule.description,
"interface": rule.interface, "interface": rule.interface,
@@ -39,7 +44,7 @@ def _build_payload(rule: OutboundNatRule) -> dict:
async def _deploy_to_host(db: AsyncSession, rule: OutboundNatRule, host: OpnsenseHost) -> OutboundNatDeployment: async def _deploy_to_host(db: AsyncSession, rule: OutboundNatRule, host: OpnsenseHost) -> OutboundNatDeployment:
await require_manual_mode(host) await require_manual_mode(host)
payload = _build_payload(rule) payload = _build_payload(rule, host)
existing = await db.scalar( existing = await db.scalar(
select(OutboundNatDeployment).where( select(OutboundNatDeployment).where(
OutboundNatDeployment.outbound_nat_id == rule.id, OutboundNatDeployment.host_id == host.id OutboundNatDeployment.outbound_nat_id == rule.id, OutboundNatDeployment.host_id == host.id
@@ -82,6 +87,7 @@ async def deploy_outbound_nat(db: AsyncSession, rule: OutboundNatRule, hosts: li
from app.services.host_maintenance_service import ensure_no_maintenance from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
await ensure_no_maintenance(db, hosts) await ensure_no_maintenance(db, hosts)
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
deployments = [] deployments = []
try: try:
for host in hosts: for host in hosts:
@@ -8,6 +8,7 @@ import json
from app.models.policy import OutboundNatRevision from app.models.policy import OutboundNatRevision
from app.services.outbound_nat_deploy_service import _build_payload from app.services.outbound_nat_deploy_service import _build_payload
from app.services import variables_service
from app.services.policy_service import effective_hosts from app.services.policy_service import effective_hosts
OUTBOUND_NAT_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'ipprotocol', 'protocol', 'source_port', 'destination_port', 'target', 'target_port') OUTBOUND_NAT_FIELDS = ('description', 'interface', 'source_net', 'destination_net', 'source_not', 'destination_not', 'enabled', 'ipprotocol', 'protocol', 'source_port', 'destination_port', 'target', 'target_port')
@@ -27,13 +28,17 @@ def revision(db, rule, note):
def preview(rule): def preview(rule):
payload = _build_payload(rule)
deployments = {d.host_id: d for d in rule.deployments} deployments = {d.host_id: d for d in rule.deployments}
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments) blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
rows = [] rows = []
for host in hosts: for host in hosts:
dep = deployments.get(host.id) dep = deployments.get(host.id)
try:
payload = _build_payload(rule, 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 before = dep.last_payload if dep else None
action = "create" if not dep or not dep.opnsense_outbound_nat_uuid else "update" action = "create" if not dep or not dep.opnsense_outbound_nat_uuid else "update"
if before == payload and dep and dep.status in ("success", "reverted"): if before == payload and dep and dep.status in ("success", "reverted"):
@@ -60,6 +65,8 @@ def preview(rule):
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.") warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
if any(r["action"] == "retained" for r in rows): if any(r["action"] == "retained" for r in rows):
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.") warnings.append("Frühere Ziele behalten diese Regel, 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): if any(r["action"] == "disabled" for r in rows):
warnings.append("Deaktivierte Hosts werden übersprungen.") warnings.append("Deaktivierte Hosts werden übersprungen.")
if any(d.status == "error" for d in rule.deployments): if any(d.status == "error" for d in rule.deployments):
@@ -70,7 +77,7 @@ def preview(rule):
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])} d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
return {"token": token, "hosts": rows, "warnings": warnings, 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."} "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
@@ -78,12 +85,11 @@ def workflow(rule, deployments):
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
active = [h for h in hosts if h.enabled] active = [h for h in hosts if h.enabled]
by_host = {d.host_id: d for d in deployments} by_host = {d.host_id: d for d in deployments}
payload = _build_payload(rule)
target_ids = {h.id for h in hosts} target_ids = {h.id for h in hosts}
counts = { counts = {
"targets": len(hosts), "targets": len(hosts),
"active_targets": len(active), "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, rule, h)),
"pending": sum(d.status == "pending" for d in deployments), "pending": sum(d.status == "pending" for d in deployments),
"errors": sum(d.status in ("error", "revert_failed") 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), "retained": sum(d.host_id not in target_ids for d in deployments),
+207
View File
@@ -0,0 +1,207 @@
"""Management overlay: keys, addressing, the hub's config file and the live status.
How it fits together
--------------------
* The ``wg-hub`` container (app/overlay_hub.py, same image as the backend, NET_ADMIN, UDP port published)
owns the WireGuard interface. It reads ``<overlay_dir>/hub.json`` — written here whenever settings or peers
change — and writes ``<overlay_dir>/status.json`` (handshakes, traffic) every few seconds.
* The backend itself needs no extra privileges: requests to a firewall whose address lies in the overlay subnet
go through the hub's small CONNECT proxy (``proxy_for``), which only lets traffic into that subnet.
* The firewall dials out to the hub. Its private key never leaves it; OPNexus only learns its public key. A
per-peer pre-shared key is generated here and shown to an admin for entering on the firewall.
"""
import base64
import ipaddress
import json
import os
import re
import secrets
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.overlay import OverlayPeer, OverlaySettings
from app.services import secure_files
SINGLETON_ID = 1
KEEPALIVE = 25
CONNECTED_WITHIN = 180 # seconds since the last handshake
_B64_KEY = re.compile(r"^[A-Za-z0-9+/]{43}=$")
_active_network: ipaddress.IPv4Network | None = None
class OverlayError(Exception):
"""A user-facing problem; ``code`` is stable, ``detail`` is for the log."""
def __init__(self, code: str, detail: str = ""):
super().__init__(detail or code)
self.code = code
def _dir() -> Path:
return Path(settings.overlay_dir)
# ── keys ─────────────────────────────────────────────────────────────────
def valid_public_key(value: str) -> bool:
if not _B64_KEY.match(value or ""):
return False
try:
return len(base64.b64decode(value, validate=True)) == 32
except ValueError:
return False
def _raw_b64(data: bytes) -> str:
return base64.b64encode(data).decode()
def generate_private_key() -> str:
key = X25519PrivateKey.generate()
return _raw_b64(key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()))
def public_from_private(private_b64: str) -> str:
key = X25519PrivateKey.from_private_bytes(base64.b64decode(private_b64))
return _raw_b64(key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw))
def hub_private_key() -> str:
"""The hub's private key, created on first use."""
path = _dir() / "hub.key"
if not path.exists():
secure_files.write_private(path, generate_private_key())
return path.read_text().strip()
def hub_public_key() -> str:
return public_from_private(hub_private_key())
def peer_psk(peer_id) -> str:
path = _dir() / f"psk-{peer_id}"
if not path.exists():
secure_files.write_private(path, _raw_b64(secrets.token_bytes(32)))
return path.read_text().strip()
def delete_peer_secret(peer_id) -> None:
(_dir() / f"psk-{peer_id}").unlink(missing_ok=True)
# ── addressing ───────────────────────────────────────────────────────────
def parse_subnet(value: str) -> ipaddress.IPv4Network:
try:
net = ipaddress.ip_network(value.strip(), strict=True)
except ValueError:
raise OverlayError("subnet", "kein gültiges Netz") from None
if not isinstance(net, ipaddress.IPv4Network) or not net.is_private or not (16 <= net.prefixlen <= 29):
raise OverlayError("subnet", "ein privates IPv4-Netz zwischen /16 und /29 ist nötig")
return net
def hub_address(net: ipaddress.IPv4Network) -> ipaddress.IPv4Address:
return net.network_address + 1
def next_free_ip(net: ipaddress.IPv4Network, taken: set[str]) -> str:
hub = hub_address(net)
for candidate in net.hosts():
if candidate != hub and str(candidate) not in taken:
return str(candidate)
raise OverlayError("full", "Im Overlay-Netz ist keine Adresse mehr frei")
def proxy_for(base_url: str) -> str | None:
"""The hub's proxy if this firewall address is inside the overlay subnet, else None (direct)."""
if _active_network is None:
return None
try:
host = urlsplit(base_url).hostname
return settings.overlay_proxy_url if host and ipaddress.ip_address(host) in _active_network else None
except ValueError:
return None
# ── settings, hub config ─────────────────────────────────────────────────
async def get_settings(db: AsyncSession) -> OverlaySettings:
row = await db.get(OverlaySettings, SINGLETON_ID)
if row is None:
row = OverlaySettings(id=SINGLETON_ID, enabled=False, subnet="10.99.0.0/24", listen_port=51820)
db.add(row)
await db.flush()
return row
def endpoint_with_port(cfg: OverlaySettings) -> str | None:
if not cfg.endpoint:
return None
return cfg.endpoint if re.search(r":\d+$", cfg.endpoint) else f"{cfg.endpoint}:{cfg.listen_port}"
async def sync(db: AsyncSession) -> None:
"""Make hub.json match the database (call after every change and at startup) and refresh the proxy rule."""
global _active_network
cfg = await get_settings(db)
net = parse_subnet(cfg.subnet)
_active_network = net if cfg.enabled else None
peers = (await db.scalars(select(OverlayPeer).order_by(OverlayPeer.ip))).all()
config = {
"enabled": bool(cfg.enabled), "address": f"{hub_address(net)}/{net.prefixlen}", "subnet": str(net),
"listen_port": cfg.listen_port,
"private_key": hub_private_key() if cfg.enabled else None,
"peers": [{"public_key": p.public_key, "psk": peer_psk(p.id), "ip": p.ip} for p in peers if p.public_key] if cfg.enabled else [],
}
_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = _dir() / "hub.json.tmp"
secure_files.write_private(tmp, json.dumps(config))
os.replace(tmp, _dir() / "hub.json")
# ── live status (written by the hub) ─────────────────────────────────────
def read_status() -> dict | None:
try:
data = json.loads((_dir() / "status.json").read_text())
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def hub_state(status: dict | None, now: float | None = None) -> str:
"""'running' | 'stale' (the hub stopped reporting) | 'absent' (never reported: container not running)"""
if not status:
return "absent"
now = now or datetime.now(timezone.utc).timestamp()
return "running" if now - float(status.get("updated_at", 0)) < 30 else "stale"
def peer_status(status: dict | None, public_key: str | None, now: float | None = None) -> dict:
now = now or datetime.now(timezone.utc).timestamp()
entry = next((p for p in (status or {}).get("peers", []) if p.get("public_key") == public_key), None) if public_key else None
handshake = int(entry.get("handshake_at", 0)) if entry else 0
return {
"connected": bool(handshake and now - handshake < CONNECTED_WITHIN and hub_state(status, now) == "running"),
"last_handshake_at": datetime.fromtimestamp(handshake, timezone.utc) if handshake else None,
"rx_bytes": int(entry.get("rx", 0)) if entry else 0, "tx_bytes": int(entry.get("tx", 0)) if entry else 0,
"remote_endpoint": entry.get("endpoint") if entry else None,
}
def instructions(cfg: OverlaySettings, peer: OverlayPeer) -> dict:
net = parse_subnet(cfg.subnet)
return {
"hub_public_key": hub_public_key(), "endpoint": endpoint_with_port(cfg),
"tunnel_address": f"{peer.ip}/32", "allowed_ips": f"{hub_address(net)}/32", "psk": peer_psk(peer.id),
"keepalive": KEEPALIVE, "hub_ip": str(hub_address(net)), "host_url": f"https://{peer.ip}",
}
+10 -4
View File
@@ -6,6 +6,7 @@ from sqlalchemy import text
from app.models.policy import RuleRevision from app.models.policy import RuleRevision
from app.services.rule_deploy_service import _build_payload from app.services.rule_deploy_service import _build_payload
from app.services import variables_service
RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log") RULE_FIELDS = ("description", "interface", "direction", "ipprotocol", "protocol", "action", "source_net", "source_port", "destination_net", "destination_port", "enabled", "log")
@@ -45,13 +46,17 @@ def ensure_editable(rule):
def preview(rule): def preview(rule):
payload = _build_payload(rule)
deployments = {d.host_id: d for d in rule.deployments} deployments = {d.host_id: d for d in rule.deployments}
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments) blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
rows = [] rows = []
for host in hosts: for host in hosts:
dep = deployments.get(host.id) dep = deployments.get(host.id)
try:
payload = _build_payload(rule, 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 before = dep.last_payload if dep else None
action = "create" if not dep or not dep.opnsense_rule_uuid else "update" action = "create" if not dep or not dep.opnsense_rule_uuid else "update"
if before == payload and dep and dep.status in ("success", "reverted"): if before == payload and dep and dep.status in ("success", "reverted"):
@@ -78,6 +83,8 @@ def preview(rule):
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.") warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
if any(r["action"] == "retained" for r in rows): if any(r["action"] == "retained" for r in rows):
warnings.append("Frühere Ziele behalten ihre Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.") warnings.append("Frühere Ziele behalten ihre Regel, 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): if any(r["action"] == "disabled" for r in rows):
warnings.append("Deaktivierte Hosts werden übersprungen.") warnings.append("Deaktivierte Hosts werden übersprungen.")
if any(d.status == "error" for d in rule.deployments): if any(d.status == "error" for d in rule.deployments):
@@ -89,7 +96,7 @@ def preview(rule):
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])} d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
return {"token": token, "hosts": rows, "warnings": warnings, 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."} "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
@@ -98,12 +105,11 @@ def workflow(rule, deployments):
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
active = [h for h in hosts if h.enabled] active = [h for h in hosts if h.enabled]
by_host = {d.host_id: d for d in deployments} by_host = {d.host_id: d for d in deployments}
payload = _build_payload(rule)
target_ids = {h.id for h in hosts} target_ids = {h.id for h in hosts}
counts = { counts = {
"targets": len(hosts), "targets": len(hosts),
"active_targets": len(active), "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, rule, h)),
"pending": sum(d.status == "pending" for d in deployments), "pending": sum(d.status == "pending" for d in deployments),
"errors": sum(d.status in ("error", "revert_failed") 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), "retained": sum(d.host_id not in target_ids for d in deployments),
@@ -21,7 +21,7 @@ from app.models.host import OpnsenseHost
from app.models.port_forward import PortForwardRule from app.models.port_forward import PortForwardRule
from app.models.port_forward_deployment import PortForwardDeployment from app.models.port_forward_deployment import PortForwardDeployment
from app.services.audit_service import tracked_rollback 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.deploy_flow import push_to_host
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
@@ -30,7 +30,12 @@ def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _build_payload(rule: PortForwardRule) -> dict: def _build_payload(rule: PortForwardRule, host: OpnsenseHost | None = None) -> dict:
payload = _base_payload(rule)
return variables_service.resolve(payload, host) if host is not None else payload
def _base_payload(rule: PortForwardRule) -> dict:
return { return {
"disabled": "0" if rule.enabled else "1", "disabled": "0" if rule.enabled else "1",
"interface": rule.interface, "interface": rule.interface,
@@ -47,7 +52,7 @@ def _build_payload(rule: PortForwardRule) -> dict:
async def _deploy_to_host(db: AsyncSession, rule: PortForwardRule, host: OpnsenseHost) -> PortForwardDeployment: async def _deploy_to_host(db: AsyncSession, rule: PortForwardRule, host: OpnsenseHost) -> PortForwardDeployment:
payload = _build_payload(rule) payload = _build_payload(rule, host)
existing = await db.scalar( existing = await db.scalar(
select(PortForwardDeployment).where( select(PortForwardDeployment).where(
PortForwardDeployment.port_forward_id == rule.id, PortForwardDeployment.host_id == host.id PortForwardDeployment.port_forward_id == rule.id, PortForwardDeployment.host_id == host.id
@@ -90,6 +95,7 @@ async def deploy_port_forward(db: AsyncSession, rule: PortForwardRule, hosts: li
from app.services.host_maintenance_service import ensure_no_maintenance from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
await ensure_no_maintenance(db, hosts) await ensure_no_maintenance(db, hosts)
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
deployments = [await _deploy_to_host(db, rule, host) for host in hosts] deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
await db.flush() await db.flush()
for host, deployment in zip(hosts, deployments): for host, deployment in zip(hosts, deployments):
@@ -8,6 +8,7 @@ import json
from app.models.policy import PortForwardRevision from app.models.policy import PortForwardRevision
from app.services.port_forward_deploy_service import _build_payload from app.services.port_forward_deploy_service import _build_payload
from app.services import variables_service
from app.services.policy_service import effective_hosts from app.services.policy_service import effective_hosts
PORT_FORWARD_FIELDS = ( PORT_FORWARD_FIELDS = (
@@ -30,13 +31,17 @@ def revision(db, rule, note):
def preview(rule): def preview(rule):
payload = _build_payload(rule)
deployments = {d.host_id: d for d in rule.deployments} deployments = {d.host_id: d for d in rule.deployments}
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments) blocked = any(d.status in ("pending", "revert_failed") for d in rule.deployments)
rows = [] rows = []
for host in hosts: for host in hosts:
dep = deployments.get(host.id) dep = deployments.get(host.id)
try:
payload = _build_payload(rule, 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 before = dep.last_payload if dep else None
action = "create" if not dep or not dep.opnsense_port_forward_uuid else "update" action = "create" if not dep or not dep.opnsense_port_forward_uuid else "update"
if before == payload and dep and dep.status in ("success", "reverted"): if before == payload and dep and dep.status in ("success", "reverted"):
@@ -63,6 +68,8 @@ def preview(rule):
warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.") warnings.append("Offene Bestätigung oder fehlgeschlagener Rollback: Push gesperrt.")
if any(r["action"] == "retained" for r in rows): if any(r["action"] == "retained" for r in rows):
warnings.append("Frühere Ziele behalten diese Regel, bis du sie hier gezielt entfernst. Diese Löschung hat keinen automatischen Rollback.") warnings.append("Frühere Ziele behalten diese Regel, 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): if any(r["action"] == "disabled" for r in rows):
warnings.append("Deaktivierte Hosts werden übersprungen.") warnings.append("Deaktivierte Hosts werden übersprungen.")
if any(d.status == "error" for d in rule.deployments): if any(d.status == "error" for d in rule.deployments):
@@ -73,7 +80,7 @@ def preview(rule):
d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])} d.host.base_url, d.host.enabled, d.host.verify_tls) for d in rule.deployments], key=lambda row: row[0])}
token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest() token = hashlib.sha256(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()
return {"token": token, "hosts": rows, "warnings": warnings, 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."} "basis": "Zuletzt von OPNexus gespeicherter Deploy-Stand; kein Live-Abgleich."}
@@ -81,12 +88,11 @@ def workflow(rule, deployments):
hosts = effective_hosts(rule) hosts = effective_hosts(rule)
active = [h for h in hosts if h.enabled] active = [h for h in hosts if h.enabled]
by_host = {d.host_id: d for d in deployments} by_host = {d.host_id: d for d in deployments}
payload = _build_payload(rule)
target_ids = {h.id for h in hosts} target_ids = {h.id for h in hosts}
counts = { counts = {
"targets": len(hosts), "targets": len(hosts),
"active_targets": len(active), "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, rule, h)),
"pending": sum(d.status == "pending" for d in deployments), "pending": sum(d.status == "pending" for d in deployments),
"errors": sum(d.status in ("error", "revert_failed") 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), "retained": sum(d.host_id not in target_ids for d in deployments),
+20
View File
@@ -0,0 +1,20 @@
"""HTTP CONNECT through the overlay hub's proxy, for the few places that open a raw TLS connection."""
import asyncio
from urllib.parse import urlsplit
async def open_tunnel(proxy_url: str, host: str, port: int, timeout: float = 8.0):
"""(reader, writer) connected to host:port through the proxy; TLS can be started on top with ``writer.start_tls``."""
proxy = urlsplit(proxy_url)
reader, writer = await asyncio.wait_for(asyncio.open_connection(proxy.hostname, proxy.port or 8888), timeout)
try:
writer.write(f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n".encode())
await writer.drain()
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout)
status = int(head.split(b" ", 2)[1])
if status != 200:
raise OSError(f"Der Overlay-Proxy lehnt die Verbindung ab (HTTP {status})")
except Exception:
writer.close()
raise
return reader, writer
+9 -3
View File
@@ -27,7 +27,7 @@ from app.models.host import OpnsenseHost
from app.models.rule import FirewallRule from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment from app.models.rule_deployment import RuleDeployment
from app.services.audit_service import tracked_rollback 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.deploy_flow import push_to_host
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
@@ -36,7 +36,12 @@ def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _build_payload(rule: FirewallRule) -> dict: def _build_payload(rule: FirewallRule, host: OpnsenseHost | None = None) -> dict:
payload = _base_payload(rule)
return variables_service.resolve(payload, host) if host is not None else payload
def _base_payload(rule: FirewallRule) -> dict:
return { return {
"enabled": "1" if rule.enabled else "0", "enabled": "1" if rule.enabled else "0",
"action": rule.action, "action": rule.action,
@@ -55,7 +60,7 @@ def _build_payload(rule: FirewallRule) -> dict:
async def _deploy_to_host(db: AsyncSession, rule: FirewallRule, host: OpnsenseHost) -> RuleDeployment: async def _deploy_to_host(db: AsyncSession, rule: FirewallRule, host: OpnsenseHost) -> RuleDeployment:
payload = _build_payload(rule) payload = _build_payload(rule, host)
existing = await db.scalar( existing = await db.scalar(
select(RuleDeployment).where(RuleDeployment.rule_id == rule.id, RuleDeployment.host_id == host.id) select(RuleDeployment).where(RuleDeployment.rule_id == rule.id, RuleDeployment.host_id == host.id)
) )
@@ -98,6 +103,7 @@ async def deploy_rule(db: AsyncSession, rule: FirewallRule, hosts: list[Opnsense
from app.services.host_maintenance_service import ensure_no_maintenance from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
await ensure_no_maintenance(db, hosts) await ensure_no_maintenance(db, hosts)
variables_service.ensure_resolvable(_build_payload, rule, hosts) # before anything is written
deployments = [await _deploy_to_host(db, rule, host) for host in hosts] deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
await db.flush() await db.flush()
for host, deployment in zip(hosts, deployments): for host, deployment in zip(hosts, deployments):
+116
View File
@@ -0,0 +1,116 @@
"""Rule usage from pf's counters: which rules never match anything?
``sample_host`` reads the per-rule packet counters (``diagnostics/firewall/pfStatistics/rules``) and the
configured rules (``searchRule``) and updates one row per rule: the time of the last observed hit.
* A counter that grew since the last sample is a hit "now" (at most one sampling interval late).
* A counter that is *lower* than before means pf reloaded its ruleset (an apply or a reboot), which
zeroes everything. Whatever it shows above zero was hit since the reload, so that also counts as a hit now;
zero tells nothing, the old ``last_hit_at`` stays.
* A rule is called unused only after OPNexus has watched it for ``UNUSED_AFTER_DAYS`` without a hit.
A new rule, or a brand-new installation, is "still collecting data" instead of a false alarm.
Only rules with a UUID label are tied to a configured rule (rules created in the rule editor or API; OPNsense's
built-in automatic rules carry hash labels and are not user rules).
"""
import logging
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.host import OpnsenseHost
from app.models.rule_hit import RuleHit
from app.services import opnsense_client as client
from app.services.opnsense_client import OpnsenseAPIError
logger = logging.getLogger(__name__)
UNUSED_AFTER_DAYS = 30
_LABEL = re.compile(r'label "([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"')
def parse_counters(stats: dict) -> dict[str, dict]:
"""{rule uuid: {packets, bytes, evaluations}} summed over every pf line a rule expands to."""
lines = ((stats or {}).get("rules") or {}).get("filter rules") or {}
result: dict[str, dict] = {}
for text, values in lines.items():
match = _LABEL.search(text)
if not match or not isinstance(values, dict):
continue
entry = result.setdefault(match.group(1).lower(), {"packets": 0, "bytes": 0, "evaluations": 0})
for key in entry:
entry[key] += int(values.get(key) or 0)
return result
def configured_rules(search: dict) -> dict[str, dict]:
rows = (search or {}).get("rows") or []
return {
str(r["uuid"]).lower(): {"description": r.get("description") or "", "enabled": r.get("enabled") in ("1", 1, True)}
for r in rows if r.get("uuid") and not r.get("is_automatic")
}
async def sample_host(db: AsyncSession, host: OpnsenseHost, now: datetime | None = None) -> int:
now = now or datetime.now(timezone.utc)
counters = parse_counters(await client.get_pf_rule_statistics(host))
rules = configured_rules(await client.search_rules(host, all_rows=True))
rows = {r.rule_uuid: r for r in (await db.scalars(select(RuleHit).where(RuleHit.host_id == host.id))).all()}
for uuid_, meta in rules.items():
counter = counters.get(uuid_, {"packets": 0, "bytes": 0})
row = rows.get(uuid_)
if row is None:
row = RuleHit(host_id=host.id, rule_uuid=uuid_, first_seen_at=now, last_packets=0, last_bytes=0, last_sampled_at=now)
db.add(row)
if counter["packets"] > 0:
row.last_hit_at = now # already matching when first seen
elif counter["packets"] > row.last_packets or (counter["packets"] < row.last_packets and counter["packets"] > 0):
row.last_hit_at = now
row.description = meta["description"][:255]
row.enabled = meta["enabled"]
row.last_packets, row.last_bytes, row.last_sampled_at = counter["packets"], counter["bytes"], now
for uuid_, row in rows.items(): # rule deleted on the firewall
if uuid_ not in rules:
await db.delete(row)
await db.commit()
return len(rules)
async def sample_all(db: AsyncSession) -> int:
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)))).all()
sampled = 0
for host in hosts:
try:
await sample_host(db, host)
sampled += 1
except OpnsenseAPIError as exc:
logger.warning("Regelnutzung von %s nicht lesbar: %s", host.name, exc)
except Exception:
logger.exception("Regelnutzung von %s fehlgeschlagen", host.name)
return sampled
def classify(row: RuleHit, now: datetime) -> str:
"""'used' | 'unused' | 'collecting' | 'disabled'"""
if not row.enabled:
return "disabled"
if row.last_hit_at is not None and now - row.last_hit_at < timedelta(days=UNUSED_AFTER_DAYS):
return "used"
watched = now - row.first_seen_at
if watched < timedelta(days=UNUSED_AFTER_DAYS):
return "used" if row.last_hit_at is not None else "collecting"
return "unused"
async def unused_summary(db: AsyncSession, host_id) -> dict | None:
"""For the security check: None while nothing is known yet, else {"ready": bool, "rules": [names]}."""
rows = (await db.scalars(select(RuleHit).where(RuleHit.host_id == host_id))).all()
if not rows:
return None
now = datetime.now(timezone.utc)
states = [(r, classify(r, now)) for r in rows]
unused = [r.description or r.rule_uuid for r, s in states if s == "unused"]
watching = [s for _, s in states if s == "collecting"]
return {"unused": unused, "collecting": len(watching)}
+109
View File
@@ -0,0 +1,109 @@
"""Minimal S3-compatible object client (AWS Signature V4) for offsite backups.
Just what is needed — PUT, GET, DELETE and a connection test — so no AWS SDK is pulled
into the image. Works with AWS S3, MinIO, Backblaze B2, Wasabi, Cloudflare R2, Hetzner
Object Storage... Path-style addressing (``https://endpoint/bucket/key``) is the default
because it works everywhere; virtual-hosted style is available for providers that need it.
"""
import hashlib
import hmac
from datetime import datetime, timezone
from urllib.parse import quote, urlsplit
import httpx
from app.config import settings
class S3Error(Exception):
pass
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _hmac(key: bytes, message: str) -> bytes:
return hmac.new(key, message.encode(), hashlib.sha256).digest()
def _quote(path: str) -> str:
return quote(path, safe="/-_.~")
def sign(*, method: str, host: str, path: str, query: str, payload_hash: str, access_key: str, secret_key: str,
region: str, now: datetime, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
"""SigV4 headers for one request (the algorithm of the AWS docs, service ``s3``)."""
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
day = now.strftime("%Y%m%d")
headers = {"host": host, "x-amz-content-sha256": payload_hash, "x-amz-date": amz_date}
headers.update({k.lower(): v.strip() for k, v in (extra_headers or {}).items()})
names = sorted(headers)
canonical_headers = "".join(f"{n}:{headers[n]}\n" for n in names)
signed = ";".join(names)
canonical_request = "\n".join([method, _quote(path), query, canonical_headers, signed, payload_hash])
scope = f"{day}/{region}/s3/aws4_request"
to_sign = "\n".join(["AWS4-HMAC-SHA256", amz_date, scope, _sha256(canonical_request.encode())])
key = _hmac(_hmac(_hmac(_hmac(("AWS4" + secret_key).encode(), day), region), "s3"), "aws4_request")
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
headers["authorization"] = f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed}, Signature={signature}"
return headers
class S3Client:
def __init__(self, *, endpoint: str, region: str, bucket: str, access_key: str, secret_key: str, path_style: bool = True):
parts = urlsplit(endpoint)
if parts.scheme not in ("http", "https") or not parts.hostname:
raise S3Error("Ungültiger Endpunkt")
if parts.scheme == "http" and not settings.allow_insecure_offsite:
# Credentials and (encrypted, but still) backups must not travel in the clear.
raise S3Error("Der Endpunkt muss https:// sein")
self.scheme, self.netloc = parts.scheme, parts.netloc
self.region, self.bucket, self.path_style = region or "us-east-1", bucket, path_style
self.access_key, self.secret_key = access_key, secret_key
def _target(self, key: str) -> tuple[str, str, str]:
"""(host header, URL path, URL)"""
if self.path_style:
host, path = self.netloc, f"/{self.bucket}/{key}"
else:
host, path = f"{self.bucket}.{self.netloc}", f"/{key}"
return host, path, f"{self.scheme}://{host}{_quote(path)}"
async def _request(self, method: str, key: str, body: bytes = b"") -> httpx.Response:
host, path, url = self._target(key)
headers = sign(
method=method, host=host, path=path, query="", payload_hash=_sha256(body), access_key=self.access_key,
secret_key=self.secret_key, region=self.region, now=datetime.now(timezone.utc),
)
try:
async with httpx.AsyncClient(timeout=60) as client:
return await client.request(method, url, content=body if method == "PUT" else None, headers=headers)
except httpx.HTTPError as exc:
raise S3Error(f"Verbindung fehlgeschlagen ({type(exc).__name__})") from None
@staticmethod
def _check(response: httpx.Response, what: str) -> None:
if response.status_code >= 300:
raise S3Error(f"{what}: HTTP {response.status_code}")
async def put(self, key: str, data: bytes) -> None:
self._check(await self._request("PUT", key, data), "Hochladen")
async def get(self, key: str) -> bytes:
response = await self._request("GET", key)
self._check(response, "Herunterladen")
return response.content
async def delete(self, key: str) -> None:
response = await self._request("DELETE", key)
if response.status_code not in (200, 204, 404):
self._check(response, "Löschen")
async def test(self, key: str) -> None:
"""Write, read back and delete a tiny object: proves credentials, bucket and permissions."""
probe = b"opnexus connection test"
await self.put(key, probe)
if await self.get(key) != probe:
raise S3Error("Der Speicher lieferte etwas anderes zurück")
await self.delete(key)
+21 -8
View File
@@ -18,6 +18,8 @@ from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import ExtensionOID, NameOID from cryptography.x509.oid import ExtensionOID, NameOID
from app.services import overlay_service, proxy_connect
MAX_PEM_BYTES = 32 * 1024 MAX_PEM_BYTES = 32 * 1024
@@ -90,6 +92,23 @@ def verify_argument(verify_tls: bool, tls_ca_pem: str | None):
return True return True
async def _open_tls(base_url: str, parsed, ctx: ssl.SSLContext, timeout: float):
"""TLS connection to the firewall: directly, or through the overlay hub's proxy for overlay addresses."""
proxy = overlay_service.proxy_for(base_url)
port = parsed.port or 443
if proxy is None:
return await asyncio.wait_for(
asyncio.open_connection(parsed.hostname, port, ssl=ctx, server_hostname=parsed.hostname), timeout
)
reader, writer = await proxy_connect.open_tunnel(proxy, parsed.hostname, port, timeout)
try:
await asyncio.wait_for(writer.start_tls(ctx, server_hostname=parsed.hostname), timeout)
except Exception:
writer.close()
raise
return reader, writer
async def probe(base_url: str, timeout: float = 8.0) -> dict: async def probe(base_url: str, timeout: float = 8.0) -> dict:
"""Fetch the certificate a firewall presents, *without* trusting it. The """Fetch the certificate a firewall presents, *without* trusting it. The
caller must show the fingerprint to a human to compare with the firewall.""" caller must show the fingerprint to a human to compare with the firewall."""
@@ -100,10 +119,7 @@ async def probe(base_url: str, timeout: float = 8.0) -> dict:
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
try: try:
_, writer = await asyncio.wait_for( _, writer = await _open_tls(base_url, parsed, ctx, timeout)
asyncio.open_connection(parsed.hostname, parsed.port or 443, ssl=ctx, server_hostname=parsed.hostname),
timeout,
)
except (OSError, ssl.SSLError, asyncio.TimeoutError) as exc: except (OSError, ssl.SSLError, asyncio.TimeoutError) as exc:
raise TlsPinError(f"Verbindung fehlgeschlagen: {exc}") from exc raise TlsPinError(f"Verbindung fehlgeschlagen: {exc}") from exc
try: try:
@@ -122,10 +138,7 @@ async def verify_matches(base_url: str, pem: str, timeout: float = 8.0) -> None:
presents anything else.""" presents anything else."""
parsed = urlparse(base_url) parsed = urlparse(base_url)
try: try:
_, writer = await asyncio.wait_for( _, writer = await _open_tls(base_url, parsed, pinned_context(pem), timeout)
asyncio.open_connection(parsed.hostname, parsed.port or 443, ssl=pinned_context(pem), server_hostname=parsed.hostname),
timeout,
)
writer.close() writer.close()
except ssl.SSLError as exc: except ssl.SSLError as exc:
raise TlsPinError("Das Zertifikat der Firewall passt nicht zum festgesetzten Zertifikat") from exc raise TlsPinError("Das Zertifikat der Firewall passt nicht zum festgesetzten Zertifikat") from exc
+78
View File
@@ -0,0 +1,78 @@
"""Site variables: ``{{NAME}}`` placeholders in centrally authored objects, filled per host.
A rule, alias or NAT entry can say ``{{LAN_NET}}`` instead of a literal network; every host that object is
deployed to supplies its own value (Host page > Variables). Resolution happens when the payload for one host is
built, so preview, deploy, the change counters and the audit all see the host's real values.
Fail closed: a placeholder without a value for a target host is never pushed. Preview marks that host
``missing_variable``, the deploy is refused before anything is written, and as a last line of defence
``opnsense_client`` refuses to send any payload that still contains a placeholder.
"""
import re
from typing import Callable
from app.models.host import OpnsenseHost
NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,39}$")
VALUE_RE = re.compile(r"^[\w .:/,@+\-]{1,255}$") # networks, addresses, hostnames, names, lists: no braces, quotes or newlines
PLACEHOLDER = re.compile(r"\{\{\s*([A-Z][A-Z0-9_]{0,39})\s*\}\}")
class MissingVariables(Exception):
"""One or more hosts lack values for placeholders the object uses. ``missing`` maps host name -> names."""
def __init__(self, missing: dict[str, list[str]]):
self.missing = missing
super().__init__("; ".join(f"{host}: {', '.join(names)}" for host, names in missing.items()))
def names_in(value) -> set[str]:
return set(PLACEHOLDER.findall(value)) if isinstance(value, str) else set()
def names_in_payload(payload: dict) -> set[str]:
found: set[str] = set()
for value in payload.values():
found |= names_in(value)
return found
def host_values(host: OpnsenseHost) -> dict[str, str]:
return {v.name: v.value for v in host.variables}
def resolve(payload: dict, host: OpnsenseHost) -> dict:
"""The payload with this host's values filled in; raises MissingVariables if one is not defined."""
values = host_values(host)
missing = sorted(n for n in names_in_payload(payload) if n not in values)
if missing:
raise MissingVariables({host.name: missing})
return {k: PLACEHOLDER.sub(lambda m: values[m.group(1)], v) if isinstance(v, str) else v for k, v in payload.items()}
def ensure_resolvable(builder: Callable, entity, hosts: list[OpnsenseHost]) -> None:
"""Raise MissingVariables (listing every affected host) before anything is written."""
missing: dict[str, list[str]] = {}
for host in hosts:
try:
builder(entity, host)
except MissingVariables as exc:
missing.update(exc.missing)
if missing:
raise MissingVariables(missing)
def payload_or_none(builder: Callable, entity, host: OpnsenseHost):
try:
return builder(entity, host)
except MissingVariables:
return None
def preview_row_missing(host: OpnsenseHost, names: list[str], dep) -> dict:
return {
"host_id": str(host.id), "host_name": host.name, "action": "missing_variable",
"status": dep.status if dep else None,
"message": "Variable fehlt: " + ", ".join(names), "can_remove": False,
"can_retry_rollback": False, "changes": [],
}
+2 -2
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung"). Versioning scheme: see CHANGELOG.md ("Versionierung").
""" """
APP_VERSION = "1.18.0" APP_VERSION = "1.23.0"
APP_CODENAME = "Kestrel" APP_CODENAME = "Harrier"
@@ -0,0 +1,39 @@
"""Audit-Export: Syslog und Webhook.
Revision ID: 0010
Revises: 0009
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
if sa.inspect(op.get_bind()).has_table("audit_export_settings"):
return
op.create_table(
"audit_export_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("syslog_enabled", sa.Boolean(), nullable=False),
sa.Column("syslog_host", sa.String(length=255), nullable=True),
sa.Column("syslog_port", sa.Integer(), nullable=False),
sa.Column("syslog_protocol", sa.String(length=3), nullable=False),
sa.Column("webhook_enabled", sa.Boolean(), nullable=False),
sa.Column("webhook_url", sa.String(length=500), nullable=True),
sa.Column("webhook_signing_secret_set", sa.Boolean(), nullable=False),
sa.Column("cursor_created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cursor_id", sa.String(length=36), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(length=300), nullable=True),
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS audit_export_settings")
@@ -0,0 +1,59 @@
"""Geplante Backups mit Aufbewahrung und verschlüsseltem Offsite-Ziel; Benachrichtigung bei Backup-Problemen.
Revision ID: 0011
Revises: 0010
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS backup_failed_enabled boolean NOT NULL DEFAULT true")
if not inspector.has_table("backup_settings"):
op.create_table(
"backup_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("scheduled_enabled", sa.Boolean(), nullable=False),
sa.Column("interval_hours", sa.Integer(), nullable=False),
sa.Column("keep_count", sa.Integer(), nullable=False),
sa.Column("offsite_enabled", sa.Boolean(), nullable=False),
sa.Column("s3_endpoint", sa.String(length=255), nullable=True),
sa.Column("s3_region", sa.String(length=64), nullable=False),
sa.Column("s3_bucket", sa.String(length=255), nullable=True),
sa.Column("s3_prefix", sa.String(length=255), nullable=False),
sa.Column("s3_access_key", sa.String(length=255), nullable=True),
sa.Column("s3_path_style", sa.Boolean(), nullable=False),
sa.Column("s3_secret_set", sa.Boolean(), nullable=False),
sa.Column("passphrase_set", sa.Boolean(), nullable=False),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_offsite_ok_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_offsite_error", sa.String(length=300), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
if not inspector.has_table("backup_uploads"):
op.create_table(
"backup_uploads",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("snapshot_id", sa.String(length=64), nullable=False),
sa.Column("remote_key", sa.String(length=400), nullable=False),
sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("host_id", "snapshot_id", name="uq_backup_upload"),
)
op.create_index("ix_backup_uploads_host_id", "backup_uploads", ["host_id"])
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS backup_uploads")
op.execute("DROP TABLE IF EXISTS backup_settings")
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS backup_failed_enabled")
@@ -0,0 +1,40 @@
"""Regelnutzung: wann hat eine Regel zuletzt etwas getroffen.
Revision ID: 0012
Revises: 0011
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
if sa.inspect(op.get_bind()).has_table("rule_hits"):
return
op.create_table(
"rule_hits",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("rule_uuid", sa.String(length=36), nullable=False),
sa.Column("description", sa.String(length=255), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_sampled_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_hit_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_packets", sa.BigInteger(), nullable=False),
sa.Column("last_bytes", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("host_id", "rule_uuid", name="uq_rule_hit"),
)
op.create_index("ix_rule_hits_host_id", "rule_hits", ["host_id"])
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS rule_hits")
+57
View File
@@ -0,0 +1,57 @@
"""Single Sign-on (OIDC): Einstellungen, Anmeldeversuche, Herkunft der Konten.
Revision ID: 0013
Revises: 0012
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0013"
down_revision = "0012"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
op.execute("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS auth_source varchar(10) NOT NULL DEFAULT 'local'")
op.execute("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS oidc_subject varchar(600)")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_admin_users_oidc_subject ON admin_users (oidc_subject)")
if not inspector.has_table("oidc_settings"):
op.create_table(
"oidc_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("display_name", sa.String(length=60), nullable=False),
sa.Column("issuer", sa.String(length=500), nullable=True),
sa.Column("client_id", sa.String(length=255), nullable=True),
sa.Column("client_secret_set", sa.Boolean(), nullable=False),
sa.Column("scopes", sa.String(length=255), nullable=False),
sa.Column("groups_claim", sa.String(length=64), nullable=False),
sa.Column("admin_group", sa.String(length=255), nullable=True),
sa.Column("operator_group", sa.String(length=255), nullable=True),
sa.Column("read_only_group", sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
if not inspector.has_table("oidc_login_states"):
op.create_table(
"oidc_login_states",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("state_hash", sa.String(length=64), nullable=False),
sa.Column("nonce", sa.String(length=64), nullable=False),
sa.Column("code_verifier", sa.String(length=128), nullable=False),
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_oidc_login_states_state_hash", "oidc_login_states", ["state_hash"], unique=True)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS oidc_login_states")
op.execute("DROP TABLE IF EXISTS oidc_settings")
op.execute("DROP INDEX IF EXISTS ix_admin_users_oidc_subject")
op.execute("ALTER TABLE admin_users DROP COLUMN IF EXISTS oidc_subject")
op.execute("ALTER TABLE admin_users DROP COLUMN IF EXISTS auth_source")
@@ -0,0 +1,28 @@
"""Aufgabentrennung: dürfen Operatoren selbst ausrollen?
Revision ID: 0014
Revises: 0013
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade() -> None:
if sa.inspect(op.get_bind()).has_table("governance_settings"):
return
op.create_table(
"governance_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("operators_may_deploy", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS governance_settings")
@@ -0,0 +1,48 @@
"""Management-Overlay (WireGuard-Hub) für Firewalls hinter NAT.
Revision ID: 0015
Revises: 0014
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("overlay_settings"):
op.create_table(
"overlay_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("endpoint", sa.String(length=255), nullable=True),
sa.Column("subnet", sa.String(length=18), nullable=False),
sa.Column("listen_port", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
if not inspector.has_table("overlay_peers"):
op.create_table(
"overlay_peers",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("ip", sa.String(length=15), nullable=False),
sa.Column("public_key", sa.String(length=44), nullable=True),
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("ip"),
sa.UniqueConstraint("public_key"),
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS overlay_peers")
op.execute("DROP TABLE IF EXISTS overlay_settings")
@@ -0,0 +1,34 @@
"""Standort-Variablen: Werte für {{NAME}}-Platzhalter je Host.
Revision ID: 0016
Revises: 0015
Create Date: 2026-10-20
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0016"
down_revision = "0015"
branch_labels = None
depends_on = None
def upgrade() -> None:
if sa.inspect(op.get_bind()).has_table("host_variables"):
return
op.create_table(
"host_variables",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=41), nullable=False),
sa.Column("value", sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("host_id", "name", name="uq_host_variable"),
)
op.create_index("ix_host_variables_host_id", "host_variables", ["host_id"])
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS host_variables")
@@ -0,0 +1,20 @@
"""Benachrichtigung bei kritischen/hohen Sicherheitsbefunden (standardmäßig aus).
Revision ID: 0017
Revises: 0016
Create Date: 2026-10-20
"""
from alembic import op
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS compliance_alerts_enabled boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS compliance_alerts_enabled")
+1
View File
@@ -12,3 +12,4 @@ pytest-asyncio==1.4.0
respx==0.23.1 respx==0.23.1
cryptography==50.0.2 cryptography==50.0.2
alembic==1.20.0 alembic==1.20.0
PyJWT==2.10.1
+1
View File
@@ -20,6 +20,7 @@ os.environ["WIREGUARD_KEY_SECRET_DIR"] = "/tmp/opnexus-test-wireguard-secrets"
os.environ["CERT_KEY_SECRET_DIR"] = "/tmp/opnexus-test-cert-key-secrets" os.environ["CERT_KEY_SECRET_DIR"] = "/tmp/opnexus-test-cert-key-secrets"
os.environ["ACME_SECRET_DIR"] = "/tmp/opnexus-test-acme-secrets" os.environ["ACME_SECRET_DIR"] = "/tmp/opnexus-test-acme-secrets"
os.environ["TOTP_SECRET_DIR"] = "/tmp/opnexus-test-totp-secrets" os.environ["TOTP_SECRET_DIR"] = "/tmp/opnexus-test-totp-secrets"
os.environ["OVERLAY_DIR"] = "/tmp/opnexus-test-overlay"
os.environ.setdefault("ADMIN_USERNAME", "admin") os.environ.setdefault("ADMIN_USERNAME", "admin")
# Force-set rather than setdefault: docker-compose's backend service sets # Force-set rather than setdefault: docker-compose's backend service sets
# ADMIN_PASSWORD="" (present-but-empty) by default, which setdefault would # ADMIN_PASSWORD="" (present-but-empty) by default, which setdefault would
+239
View File
@@ -0,0 +1,239 @@
"""Audit export: cursor semantics, wire formats (syslog UDP/TCP, signed webhook), admin-only settings."""
import asyncio
import hashlib
import hmac
import json
import socket
import threading
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from app.models.audit_export import AuditExportSettings
from app.services import audit_export_service as svc
from app.services import notification_secret_store
from tests.test_api_tokens import _login
async def _event(n: int, *, age_s: float = 60, status="accepted", details=None) -> AuditEvent:
async with SessionLocal() as db:
event = AuditEvent(
created_at=datetime.now(timezone.utc) - timedelta(seconds=age_s + (10 - n) * 0.001), correlation_id="c", actor="admin", kind="rule",
action="deploy", status=status, entity_name=f"rule-{n}", message=f"m{n}", details=details or {},
)
db.add(event)
await db.commit()
return event
async def _configure(**fields):
async with SessionLocal() as db:
row = await svc.get_settings(db)
for key, value in fields.items():
setattr(row, key, value)
await db.commit()
async def _run(lag=0):
async with SessionLocal() as db:
return await svc.run_once(db, lag_seconds=lag)
async def _state():
async with SessionLocal() as db:
return await svc.get_settings(db)
class _UdpReceiver:
def __init__(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("127.0.0.1", 0))
self.sock.settimeout(2)
self.port = self.sock.getsockname()[1]
def receive(self, n):
return [self.sock.recvfrom(65535)[0].decode() for _ in range(n)]
class _TcpReceiver:
def __init__(self):
self.server = socket.socket()
self.server.bind(("127.0.0.1", 0))
self.server.listen(1)
self.port = self.server.getsockname()[1]
self.data = b""
self.thread = threading.Thread(target=self._serve, daemon=True)
self.thread.start()
def _serve(self):
conn, _ = self.server.accept()
while chunk := conn.recv(65535):
self.data += chunk
conn.close()
@pytest.fixture(autouse=True)
async def _reset(tmp_path):
async with SessionLocal() as db:
await db.execute(AuditExportSettings.__table__.delete())
await db.commit()
yield
@pytest.mark.asyncio
async def test_idle_when_nothing_is_enabled():
await _event(1)
assert await _run() == 0
assert (await _state()).cursor_created_at is None
@pytest.mark.asyncio
async def test_first_round_starts_from_now_without_replaying_history():
await _event(1, age_s=3600)
udp = _UdpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port)
assert await _run() == 0 # sets the cursor
assert (await _state()).cursor_created_at is not None
await asyncio.sleep(0.05)
await _event(2, age_s=0)
assert await _run() == 1
(line,) = udp.receive(1)
assert '"entity_name": "rule-2"' in line and "rule-1" not in line
@pytest.mark.asyncio
async def test_syslog_udp_line_is_rfc5424_with_severity_by_status_and_no_details():
udp = _UdpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1, status="accepted", details={"after_payload": {"password": "hunter2"}})
await _event(2, status="denied")
assert await _run() == 2
ok, bad = udp.receive(2)
assert ok.startswith("<134>1 ") and bad.startswith("<132>1 ") # local0.info / local0.warning
assert " opnexus - AUDIT - {" in ok
body = json.loads(ok.split(" - AUDIT - ", 1)[1])
assert body["actor"] == "admin" and body["kind"] == "rule" and "details" not in body
assert "hunter2" not in ok
@pytest.mark.asyncio
async def test_syslog_tcp_uses_octet_counting():
tcp = _TcpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=tcp.port, syslog_protocol="tcp",
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
assert await _run() == 1
tcp.thread.join(timeout=3)
length, _, rest = tcp.data.partition(b" ")
assert int(length) == len(rest) and rest.startswith(b"<134>1 ")
@pytest.mark.asyncio
async def test_failure_keeps_the_cursor_and_the_next_round_retries(respx_mock):
await _configure(webhook_enabled=True, webhook_url="https://hook.test/secret-token",
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
route = respx_mock.post("https://hook.test/secret-token").mock(return_value=httpx.Response(500))
assert await _run() == 0
state = await _state()
assert state.cursor_id is None and "HTTP 500" in state.last_error and "secret-token" not in state.last_error
route.mock(return_value=httpx.Response(200))
assert await _run() == 1
state = await _state()
assert state.last_error is None and state.last_success_at is not None and state.cursor_id is not None
assert await _run() == 0 # nothing new
@pytest.mark.asyncio
async def test_webhook_batch_is_signed(respx_mock):
notification_secret_store.save_secret(svc.SECRET_NAME, "s3cret")
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
await _event(2)
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
assert await _run() == 2
request = route.calls.last.request
assert request.headers["X-OPNexus-Signature"] == "sha256=" + hmac.new(b"s3cret", request.content, hashlib.sha256).hexdigest()
payload = json.loads(request.content)
assert [e["entity_name"] for e in payload["events"]] == ["rule-1", "rule-2"] and payload["source"] == "opnexus"
@pytest.mark.asyncio
async def test_missing_signing_secret_fails_closed(respx_mock):
notification_secret_store.delete_secret(svc.SECRET_NAME)
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
assert await _run() == 0 # respx would fail the test on any request: nothing was sent
assert "nicht lesbar" in (await _state()).last_error
@pytest.mark.asyncio
async def test_batches_are_ordered_and_resume_without_gaps_or_duplicates(respx_mock, monkeypatch):
monkeypatch.setattr(svc, "BATCH", 2)
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
for n in range(1, 6):
await _event(n)
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
assert [await _run(), await _run(), await _run(), await _run()] == [2, 2, 1, 0]
names = [e["entity_name"] for call in route.calls for e in json.loads(call.request.content)["events"]]
assert names == [f"rule-{n}" for n in range(1, 6)]
@pytest.mark.asyncio
async def test_very_fresh_events_wait_for_the_lag():
udp = _UdpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1, age_s=0.2)
assert await _run(lag=3) == 0
assert await _run(lag=0) == 1
# ── Settings API ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_settings_roundtrip_hides_the_secret_and_enabling_starts_from_now(client):
got = (await client.get("/api/settings/audit-export")).json()
assert got["syslog_enabled"] is False and got["webhook_signing_secret_set"] is False
put = await client.put("/api/settings/audit-export", json={
"syslog_enabled": True, "syslog_host": "127.0.0.1", "syslog_port": 5514, "syslog_protocol": "tcp",
"webhook_signing_secret": "shh"})
assert put.status_code == 200, put.text
body = put.json()
assert body["syslog_protocol"] == "tcp" and body["webhook_signing_secret_set"] is True and "shh" not in put.text
assert (await _state()).cursor_created_at is not None
cleared = (await client.put("/api/settings/audit-export", json={"clear_webhook_signing_secret": True, "syslog_enabled": False})).json()
assert cleared["webhook_signing_secret_set"] is False
assert (await _state()).cursor_created_at is None
@pytest.mark.asyncio
async def test_settings_validation(client):
for body in ({"syslog_protocol": "carrier-pigeon"}, {"syslog_port": 0}, {"webhook_url": "x" * 501}):
assert (await client.put("/api/settings/audit-export", json=body)).status_code == 422
@pytest.mark.asyncio
async def test_test_endpoint_needs_a_channel_and_reports_delivery_failures(client, respx_mock):
assert (await client.post("/api/settings/audit-export/test")).status_code == 502
await client.put("/api/settings/audit-export", json={"webhook_enabled": True, "webhook_url": "https://hook.test/in"})
respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
assert (await client.post("/api/settings/audit-export/test")).json() == {"success": True}
@pytest.mark.asyncio
async def test_only_admins_may_read_or_change_it(client):
for role in ("operator", "read_only"):
name = f"exp-{role}"
assert (await client.post("/api/users", json={"username": name, "password": "export-password-1", "role": role})).status_code == 201
user = await _login(name, "export-password-1")
try:
assert (await user.get("/api/settings/audit-export")).status_code == 403
assert (await user.put("/api/settings/audit-export", json={"syslog_enabled": True})).status_code == 403
finally:
await user.aclose()
+435
View File
@@ -0,0 +1,435 @@
"""Scheduled backups, retention, encrypted offsite copy: crypto, S3 signing, schedule logic, API."""
import io
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.backup_settings import BackupSettings, BackupUpload
from app.services import backup_crypto, backup_schedule_service as svc, backup_store, notification_checks, notification_secret_store
from app.services import s3_client
from tests.test_api_tokens import _login
from tests.test_rules import _create_host
CONFIG = b"<?xml version='1.0'?><opnsense><system><hostname>fw</hostname></system></opnsense>"
# ── encryption ───────────────────────────────────────────────────────────
def test_encryption_roundtrip_and_every_failure_mode():
blob = backup_crypto.encrypt(CONFIG, "correct horse battery")
assert blob.startswith(backup_crypto.MAGIC) and CONFIG not in blob
assert backup_crypto.decrypt(blob, "correct horse battery") == CONFIG
assert backup_crypto.encrypt(CONFIG, "correct horse battery") != blob # fresh salt and nonce
with pytest.raises(backup_crypto.DecryptError):
backup_crypto.decrypt(blob, "wrong passphrase!")
tampered = bytearray(blob)
tampered[-1] ^= 1
with pytest.raises(backup_crypto.DecryptError):
backup_crypto.decrypt(bytes(tampered), "correct horse battery")
relabelled = bytearray(blob)
relabelled[len(backup_crypto.MAGIC)] ^= 1 # salt is authenticated header data
with pytest.raises(backup_crypto.DecryptError):
backup_crypto.decrypt(bytes(relabelled), "correct horse battery")
for junk in (b"", b"OPXBAK1", b"not a backup at all, really not" * 3):
with pytest.raises(backup_crypto.DecryptError):
backup_crypto.decrypt(junk, "x" * 12)
def test_decrypt_cli_reads_stdin_and_the_environment():
blob = backup_crypto.encrypt(CONFIG, "cli passphrase 123")
ok = subprocess.run([sys.executable, "-m", "app.backup_decrypt"], input=blob, capture_output=True,
env={**os.environ, "BACKUP_PASSPHRASE": "cli passphrase 123"})
assert ok.returncode == 0 and ok.stdout == CONFIG
bad = subprocess.run([sys.executable, "-m", "app.backup_decrypt"], input=blob, capture_output=True,
env={**os.environ, "BACKUP_PASSPHRASE": "nope nope nope!"})
assert bad.returncode == 1 and bad.stdout == b""
# ── SigV4 (examples from the AWS S3 documentation) ───────────────────────
_AK, _SK = "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
_EMPTY = s3_client._sha256(b"")
def test_sigv4_matches_the_aws_get_object_example():
headers = s3_client.sign(
method="GET", host="examplebucket.s3.amazonaws.com", path="/test.txt", query="", payload_hash=_EMPTY,
access_key=_AK, secret_key=_SK, region="us-east-1", now=datetime(2013, 5, 24, tzinfo=timezone.utc),
extra_headers={"Range": "bytes=0-9"},
)
assert headers["authorization"].endswith("Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41")
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date" in headers["authorization"]
def test_sigv4_matches_the_aws_put_object_example_with_an_escaped_key():
body = b"Welcome to Amazon S3."
headers = s3_client.sign(
method="PUT", host="examplebucket.s3.amazonaws.com", path="/test$file.text", query="", payload_hash=s3_client._sha256(body),
access_key=_AK, secret_key=_SK, region="us-east-1", now=datetime(2013, 5, 24, tzinfo=timezone.utc),
extra_headers={"Date": "Fri, 24 May 2013 00:00:00 GMT", "x-amz-storage-class": "REDUCED_REDUNDANCY"},
)
assert headers["authorization"].endswith("Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd")
def _s3(**kw):
return s3_client.S3Client(endpoint="https://s3.test", region="eu-1", bucket="bk", access_key="AK", secret_key="SK", **kw)
@pytest.mark.asyncio
async def test_s3_client_requests_and_errors(respx_mock):
put = respx_mock.put("https://s3.test/bk/a/b%20c.opxbak").mock(return_value=httpx.Response(200))
await _s3().put("a/b c.opxbak", b"data")
request = put.calls.last.request
assert request.headers["authorization"].startswith("AWS4-HMAC-SHA256 Credential=AK/") and "/eu-1/s3/aws4_request" in request.headers["authorization"]
assert request.headers["x-amz-content-sha256"] == s3_client._sha256(b"data") and request.content == b"data"
respx_mock.put("https://bk.s3.test/x").mock(return_value=httpx.Response(403))
with pytest.raises(s3_client.S3Error, match="HTTP 403"):
await _s3(path_style=False).put("x", b"1")
respx_mock.delete("https://s3.test/bk/gone").mock(return_value=httpx.Response(404))
await _s3().delete("gone") # deleting what is not there is fine
respx_mock.put("https://s3.test/bk/net").mock(side_effect=httpx.ConnectError("boom"))
with pytest.raises(s3_client.S3Error, match="Verbindung"):
await _s3().put("net", b"1")
def test_s3_endpoint_must_be_https_unless_the_lab_switch_is_on(monkeypatch):
with pytest.raises(s3_client.S3Error):
s3_client.S3Client(endpoint="http://minio.lan:9000", region="r", bucket="b", access_key="a", secret_key="s")
for bad in ("ftp://x", "minio", ""):
with pytest.raises(s3_client.S3Error):
s3_client.S3Client(endpoint=bad, region="r", bucket="b", access_key="a", secret_key="s")
monkeypatch.setattr(s3_client.settings, "allow_insecure_offsite", True)
s3_client.S3Client(endpoint="http://minio.lan:9000", region="r", bucket="b", access_key="a", secret_key="s")
@pytest.mark.asyncio
async def test_s3_connection_test_proves_write_read_delete(respx_mock):
store = {}
respx_mock.put(url__regex=r"https://s3.test/bk/probe").mock(side_effect=lambda r: store.update(v=r.content) or httpx.Response(200))
respx_mock.get(url__regex=r"https://s3.test/bk/probe").mock(side_effect=lambda r: httpx.Response(200, content=store["v"]))
gone = respx_mock.delete(url__regex=r"https://s3.test/bk/probe").mock(return_value=httpx.Response(204))
await _s3().test("probe")
assert gone.called
respx_mock.get(url__regex=r"https://s3.test/bk/probe").mock(return_value=httpx.Response(200, content=b"other"))
with pytest.raises(s3_client.S3Error):
await _s3().test("probe")
# ── schedule, retention, offsite ─────────────────────────────────────────
def _mock_fw(respx_mock, content=CONFIG):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
return respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=content))
async def _cfg(**fields):
async with SessionLocal() as db:
row = await svc.get_settings(db)
for k, v in fields.items():
setattr(row, k, v)
await db.commit()
async def _run(force=False):
async with SessionLocal() as db:
return await svc.run_due(db, force=force)
def _age(host_id, snapshot_id, hours):
import app.services.backup_store as bs
path = bs._host_dir(host_id) / snapshot_id
t = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp()
os.utime(path, (t, t))
@pytest.mark.asyncio
async def test_a_backup_is_taken_when_due_and_not_before(client, respx_mock):
download = _mock_fw(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
assert (await _run())["taken"] == 1 and len(backup_store.list_snapshots(host["id"])) == 1
assert (await _run())["taken"] == 0 # fresh, nothing due
first = backup_store.list_snapshots(host["id"])[0]["id"]
_age(host["id"], first, 25)
assert (await _run())["taken"] == 1 and download.call_count == 2
assert (await _run(force=True))["taken"] == 1 # "run now" ignores the interval
@pytest.mark.asyncio
async def test_disabled_schedule_takes_nothing_and_disabled_hosts_are_skipped(client, respx_mock):
_mock_fw(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
await _cfg(scheduled_enabled=False)
assert (await _run())["taken"] == 0
await _cfg(scheduled_enabled=True)
await client.patch(f"/api/hosts/{host['id']}", json={"enabled": False})
assert (await _run())["taken"] == 0
@pytest.mark.asyncio
async def test_retention_keeps_only_the_newest_n(client, respx_mock):
_mock_fw(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
await _cfg(keep_count=3)
for _ in range(6):
backup_store.save_snapshot(host["id"], CONFIG)
result = await _run()
kept = backup_store.list_snapshots(host["id"])
assert len(kept) == 3 and result["pruned"] >= 3
@pytest.mark.asyncio
async def test_failure_is_counted_a_garbage_answer_is_not_stored_and_others_continue(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
ok = await _create_host(client, "fw-ok", "https://ok.test")
bad = await _create_host(client, "fw-bad", "https://bad.test")
junk = await _create_host(client, "fw-junk", "https://junk.test")
respx_mock.get("https://ok.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=CONFIG))
respx_mock.get("https://bad.test/api/core/backup/download/this").mock(return_value=httpx.Response(500))
respx_mock.get("https://junk.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=b"<html>login</html>"))
result = await _run()
assert (result["taken"], result["failed"]) == (1, 2)
assert backup_store.list_snapshots(ok["id"]) and not backup_store.list_snapshots(bad["id"]) and not backup_store.list_snapshots(junk["id"])
async def _offsite_on(passphrase="offsite passphrase 1"):
notification_secret_store.save_secret(svc.S3_SECRET, "SK")
notification_secret_store.save_secret(svc.PASSPHRASE, passphrase)
await _cfg(offsite_enabled=True, s3_endpoint="https://s3.test", s3_bucket="bk", s3_access_key="AK", s3_secret_set=True, passphrase_set=True, s3_prefix="/opnexus/")
@pytest.mark.asyncio
async def test_offsite_upload_is_encrypted_once_only_and_named_by_host(client, respx_mock):
_mock_fw(respx_mock)
host = await _create_host(client, "fw 1/main", "https://fw1.test")
await _offsite_on()
puts = respx_mock.put(url__regex=r"https://s3\.test/bk/opnexus/.*").mock(return_value=httpx.Response(200))
result = await _run()
assert result["uploaded"] == 1 and puts.call_count == 1
request = puts.calls.last.request
assert re.fullmatch(rf"/bk/opnexus/fw-1-main-{host['id'][:8]}/\d{{8}}T\d+\.opxbak", request.url.path)
assert CONFIG not in request.content and backup_crypto.decrypt(request.content, "offsite passphrase 1") == CONFIG
assert (await _run())["uploaded"] == 0 and puts.call_count == 1 # ledger: nothing twice
async with SessionLocal() as db:
assert len((await db.scalars(select(BackupUpload))).all()) == 1
cfg = await svc.get_settings(db)
assert cfg.last_offsite_error is None and cfg.last_offsite_ok_at is not None
@pytest.mark.asyncio
async def test_offsite_failure_is_recorded_and_retried_next_round(client, respx_mock):
_mock_fw(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
await _offsite_on()
route = respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(503))
await _run()
async with SessionLocal() as db:
assert "HTTP 503" in (await svc.get_settings(db)).last_offsite_error
assert (await db.scalars(select(BackupUpload))).all() == []
route.mock(return_value=httpx.Response(200))
assert (await _run())["uploaded"] == 1
async with SessionLocal() as db:
assert (await svc.get_settings(db)).last_offsite_error is None
@pytest.mark.asyncio
async def test_incomplete_offsite_config_and_lost_passphrase_fail_closed(client, respx_mock):
_mock_fw(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
await _cfg(offsite_enabled=True)
await _run()
async with SessionLocal() as db:
assert "unvollständig" in (await svc.get_settings(db)).last_offsite_error
await _offsite_on()
notification_secret_store.delete_secret(svc.PASSPHRASE) # respx would fail on any upload: nothing leaves unencrypted
await _run()
async with SessionLocal() as db:
assert "Passphrase" in (await svc.get_settings(db)).last_offsite_error
@pytest.mark.asyncio
async def test_pruned_snapshots_leave_the_ledger(client, respx_mock):
_mock_fw(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
await _offsite_on()
respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(200))
await _cfg(keep_count=3)
for _ in range(4):
backup_store.save_snapshot(host["id"], CONFIG)
await _run()
async with SessionLocal() as db:
names = {r.snapshot_id for r in (await db.scalars(select(BackupUpload))).all()}
assert names <= {s["id"] for s in backup_store.list_snapshots(host["id"])}
# ── notifications ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_stale_backup_problem_after_three_missed_intervals(client, respx_mock):
_mock_fw(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
async with SessionLocal() as db:
assert await notification_checks.check_backup_stale(db) == {} # brand-new host: not yet
from app.models.host import OpnsenseHost
async with SessionLocal() as db:
row = await db.get(OpnsenseHost, __import__("uuid").UUID(host["id"]))
row.created_at = datetime.now(timezone.utc) - timedelta(days=5)
await db.commit()
async with SessionLocal() as db:
assert list(await notification_checks.check_backup_stale(db)) == [host["id"]]
name = backup_store.save_snapshot(host["id"], CONFIG)
async with SessionLocal() as db:
assert await notification_checks.check_backup_stale(db) == {}
_age(host["id"], name, 80)
async with SessionLocal() as db:
assert list(await notification_checks.check_backup_stale(db)) == [host["id"]]
await _cfg(scheduled_enabled=False)
async with SessionLocal() as db:
assert await notification_checks.check_backup_stale(db) == {}
@pytest.mark.asyncio
async def test_offsite_problem_follows_the_last_error():
await _cfg(offsite_enabled=True, last_offsite_error="HTTP 503")
async with SessionLocal() as db:
assert (await notification_checks.check_backup_offsite(db)) == {"offsite": {"message": "HTTP 503"}}
await _cfg(last_offsite_error=None)
async with SessionLocal() as db:
assert await notification_checks.check_backup_offsite(db) == {}
# ── API ──────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_settings_roundtrip_never_returns_secrets(client):
put = await client.put("/api/settings/backups", json={
"interval_hours": 12, "keep_count": 10, "offsite_enabled": True, "s3_endpoint": "https://s3.test/", "s3_bucket": "bk",
"s3_access_key": "AK", "s3_secret_key": "TOPSECRET", "passphrase": "a long passphrase here"})
assert put.status_code == 200, put.text
body = put.json()
assert body["s3_endpoint"] == "https://s3.test" and body["s3_secret_set"] and body["passphrase_set"]
assert "TOPSECRET" not in put.text and "passphrase here" not in put.text
assert notification_secret_store.read_secret(svc.S3_SECRET) == "TOPSECRET"
cleared = (await client.put("/api/settings/backups", json={"clear_s3_secret": True})).json()
assert cleared["s3_secret_set"] is False and notification_secret_store.read_secret(svc.S3_SECRET) is None
@pytest.mark.asyncio
async def test_settings_validation(client):
for body in ({"interval_hours": 0}, {"keep_count": 2}, {"passphrase": "short"}, {"s3_region": ""}):
assert (await client.put("/api/settings/backups", json=body)).status_code == 422, body
@pytest.mark.asyncio
async def test_status_is_visible_to_everyone_settings_only_to_admins_run_now_to_operators(client, respx_mock):
_mock_fw(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
for role in ("read_only", "operator"):
name = f"bk-{role}"
assert (await client.post("/api/users", json={"username": name, "password": "backup-password-1", "role": role})).status_code == 201
user = await _login(name, "backup-password-1")
try:
status = (await user.get("/api/backups/status")).json()
assert "s3_endpoint" not in status and status["interval_hours"] == 24
assert (await user.get("/api/settings/backups")).status_code == 403
assert (await user.put("/api/settings/backups", json={"keep_count": 5})).status_code == 403
assert (await user.post("/api/settings/backups/test-offsite")).status_code == 403
expected = 403 if role == "read_only" else 200
assert (await user.post("/api/backups/run")).status_code == expected
finally:
await user.aclose()
@pytest.mark.asyncio
async def test_test_offsite_reports_the_reason(client, respx_mock):
resp = await client.post("/api/settings/backups/test-offsite")
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "OFFSITE_TEST_FAILED"
await _offsite_on()
respx_mock.route(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(403))
resp = await client.post("/api/settings/backups/test-offsite")
assert resp.status_code == 502 and "403" in resp.json()["detail"]["params"]["reason"]
respx_mock.clear()
store = {}
respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(side_effect=lambda r: store.update(v=r.content) or httpx.Response(200))
respx_mock.get(url__regex=r"https://s3\.test/bk/.*").mock(side_effect=lambda r: httpx.Response(200, content=store["v"]))
respx_mock.delete(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(204))
assert (await client.post("/api/settings/backups/test-offsite")).json() == {"success": True}
@pytest.mark.asyncio
async def test_settings_changes_are_audited_without_secrets(client):
from app.models.audit import AuditEvent
await client.put("/api/settings/backups", json={"s3_secret_key": "LEAKME-1234"})
async with SessionLocal() as db:
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "settings"))).all()
assert rows and "LEAKME" not in json.dumps([r.details for r in rows]) + "".join(r.message or "" for r in rows)
# ── config diff ──────────────────────────────────────────────────────────
def _xml(*, hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
return (f"<opnsense><revision><time>{revision}</time></revision><system><hostname>{hostname}</hostname>"
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey></user>"
f"<dnsserver>{dns}</dnsserver></system><cert><crt>{cert}</crt><keylength>{keylength}</keylength></cert></opnsense>").encode()
@pytest.mark.asyncio
async def test_diff_shows_changes_and_hides_every_secret(client):
host = await _create_host(client, "fw1", "https://fw1.test")
backup_store.save_snapshot(host["id"], _xml())
backup_store.save_snapshot(host["id"], _xml(hostname="fw-new", password="SUPERSECRET2", dns="9.9.9.9", cert="B" * 200, revision="r2", keylength="2048"))
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
text = "\n".join(body["lines"])
assert not body["identical"] and body["added"] >= 4 and body["removed"] >= 4
assert "- <hostname>fw</hostname>" in text and "+ <hostname>fw-new</hostname>" in text
assert "9.9.9.9" in text and "<keylength>2048</keylength>" in text # ordinary values stay readable
for secret in ("SUPERSECRET2", "KEY-SUPERSECRET2", "p1", "A" * 50, "B" * 50):
assert secret not in text, secret
assert "‹sha256:" in text # a changed secret is still visible as a change
assert "r1" not in text and "r2" not in text # the volatile revision block is ignored
@pytest.mark.asyncio
async def test_identical_backups_and_explicit_selection(client):
host = await _create_host(client, "fw1", "https://fw1.test")
first = backup_store.save_snapshot(host["id"], _xml(revision="r1"))
second = backup_store.save_snapshot(host["id"], _xml(revision="r2"))
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()["identical"] is True
third = backup_store.save_snapshot(host["id"], _xml(dns="8.8.8.8"))
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": first, "b": third})).json()
assert (body["a"], body["b"]) == (first, third) and not body["identical"]
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()["a"] == second # default: previous vs newest
@pytest.mark.asyncio
async def test_diff_errors(client):
import uuid
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/backup-diff")).status_code == 404
host = await _create_host(client, "fw1", "https://fw1.test")
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).status_code == 404 # nothing to compare
one = backup_store.save_snapshot(host["id"], _xml())
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).status_code == 404 # only one
bad = backup_store.save_snapshot(host["id"], b"<html>not a config</html>")
resp = await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": one, "b": bad})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "BACKUP_UNLESBAR"
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": "../../etc/passwd", "b": one})).status_code == 404
xxe = b'<?xml version="1.0"?><!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><opnsense>&e;</opnsense>'
evil = backup_store.save_snapshot(host["id"], xxe)
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": one, "b": evil})).status_code == 422
@pytest.mark.asyncio
async def test_diff_is_capped(client):
host = await _create_host(client, "fw1", "https://fw1.test")
many = "".join(f"<dns{i}>1.1.1.{i % 250}</dns{i}>" for i in range(5000))
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many}</system></opnsense>".encode())
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many.replace('1.1.1', '2.2.2')}</system></opnsense>".encode())
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
assert body["truncated"] is True and len(body["lines"]) == 3000
+151
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
import httpx import httpx
import pytest import pytest
from sqlalchemy import select
from app.services import compliance_service as cs from app.services import compliance_service as cs
from tests.test_api_tokens import _bearer, _make from tests.test_api_tokens import _bearer, _make
@@ -158,6 +159,8 @@ def _mock_host(respx_mock, xml, firmware_status="none"):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []})) respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=xml)) respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=xml))
respx_mock.get(url__regex=r".*/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": firmware_status})) respx_mock.get(url__regex=r".*/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": firmware_status}))
respx_mock.get(url__regex=r".*/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
respx_mock.get(url__regex=r".*/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -212,3 +215,151 @@ async def test_metrics_expose_the_score(client, respx_mock):
text = (await client.get("/api/metrics")).text text = (await client.get("/api/metrics")).text
assert f'opnexus_host_compliance_score{{host="fw1",cluster=""}} {result["score"]}' in text assert f'opnexus_host_compliance_score{{host="fw1",cluster=""}} {result["score"]}' in text
assert 'opnexus_host_compliance_findings{severity="critical",host="fw1",cluster=""} 1' in text assert 'opnexus_host_compliance_findings{severity="critical",host="fw1",cluster=""} 1' in text
# ── rule usage (pf counters) ─────────────────────────────────────────────
from app.database import SessionLocal # noqa: E402
from app.models.host import OpnsenseHost # noqa: E402
from app.models.rule_hit import RuleHit # noqa: E402
from app.services import rule_hit_service as rh # noqa: E402
U1, U2, U3 = "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"
def _pf(**packets):
"""pfStatistics/rules shaped like the real answer: one entry per pf line, label = rule uuid."""
lines = {f'@{i} pass in quick on vtnet0 inet all flags S/SA keep state label "{u}"': {"evaluations": 10, "packets": p, "bytes": p * 100}
for i, (u, p) in enumerate(packets.items())}
lines['@99 pass out log quick on vtnet0 inet6 proto udp from fe80::/10 to any keep state label "2f2c37025cd02b5cd29fd330f8e48954"'] = {"evaluations": 5, "packets": 9, "bytes": 9}
return {"rules": {"filter rules": lines}}
def _search(*uuids, disabled=()):
rows = [{"uuid": u, "description": f"rule {u[0]}", "enabled": "0" if u in disabled else "1"} for u in uuids]
rows.append({"uuid": "autoautoauto", "description": "Default deny", "enabled": "1", "is_automatic": True})
return {"rows": rows}
def test_pf_counters_are_tied_to_rule_uuids_and_summed_over_expanded_lines():
stats = _pf(**{U1: 5})
stats["rules"]["filter rules"][f'@7 pass in quick on vtnet0 inet6 all label "{U1}"'] = {"evaluations": 2, "packets": 3, "bytes": 30}
got = rh.parse_counters(stats)
assert got[U1] == {"packets": 8, "bytes": 530, "evaluations": 12}
assert "2f2c37025cd02b5cd29fd330f8e48954" not in got # md5 labels are automatic rules, not user rules
assert rh.parse_counters({}) == {} and rh.parse_counters({"rules": None}) == {}
def test_configured_rules_skip_automatic_ones():
assert set(rh.configured_rules(_search(U1, U2, disabled=(U2,)))) == {U1, U2}
assert rh.configured_rules(_search(U1, U2, disabled=(U2,)))[U2]["enabled"] is False
async def _sample(host_id, stats, search, at):
async with SessionLocal() as db:
host = await db.get(OpnsenseHost, __import__("uuid").UUID(host_id))
from unittest.mock import AsyncMock, patch
with patch.object(rh.client, "get_pf_rule_statistics", AsyncMock(return_value=stats)), \
patch.object(rh.client, "search_rules", AsyncMock(return_value=search)):
return await rh.sample_host(db, host, now=at)
async def _rows(host_id):
async with SessionLocal() as db:
return {r.rule_uuid: r for r in (await db.scalars(select(RuleHit))).all()}
@pytest.mark.asyncio
async def test_last_hit_tracking_survives_counter_resets(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
day0 = datetime(2026, 1, 1, tzinfo=timezone.utc)
day = lambda n: day0 + timedelta(days=n) # noqa: E731
await _sample(host["id"], _pf(**{U1: 0, U2: 7, U3: 0}), _search(U1, U2, U3), day(0))
rows = await _rows(host["id"])
assert rows[U1].last_hit_at is None and rows[U2].last_hit_at == day(0) # already matching when first seen
await _sample(host["id"], _pf(**{U1: 0, U2: 7, U3: 4}), _search(U1, U2, U3), day(1))
rows = await _rows(host["id"])
assert rows[U2].last_hit_at == day(0) and rows[U3].last_hit_at == day(1) # U2 flat, U3 grew
# pf reloaded (counters restart): U2 is lower but above zero -> hit since the reload; U1 at zero tells nothing
await _sample(host["id"], _pf(**{U1: 0, U2: 2, U3: 0}), _search(U1, U2, U3), day(2))
rows = await _rows(host["id"])
assert rows[U2].last_hit_at == day(2) and rows[U3].last_hit_at == day(1) and rows[U1].last_hit_at is None
# a rule that disappeared from the firewall is forgotten
await _sample(host["id"], _pf(**{U1: 0, U2: 2}), _search(U1, U2), day(3))
assert set(await _rows(host["id"])) == {U1, U2}
@pytest.mark.asyncio
async def test_unused_only_after_the_observation_window(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
now = datetime.now(timezone.utc)
await _sample(host["id"], _pf(**{U1: 0, U2: 5}), _search(U1, U2, U3, disabled=(U3,)), now - timedelta(days=10))
async with SessionLocal() as db:
states = {r.rule_uuid: rh.classify(r, now) for r in (await db.scalars(select(RuleHit))).all()}
summary = await rh.unused_summary(db, __import__("uuid").UUID(host["id"]))
assert states == {U1: "collecting", U2: "used", U3: "disabled"}
assert summary == {"unused": [], "collecting": 1}
await _sample(host["id"], _pf(**{U1: 0, U2: 5}), _search(U1, U2, U3, disabled=(U3,)), now - timedelta(days=1))
async with SessionLocal() as db:
row = (await db.scalars(select(RuleHit).where(RuleHit.rule_uuid == U1))).one()
row.first_seen_at = now - timedelta(days=45) # watched long enough
await db.commit()
summary = await rh.unused_summary(db, __import__("uuid").UUID(host["id"]))
assert summary["unused"] == ["rule 1"]
body = (await client.get(f"/api/hosts/{host['id']}/rule-hits")).json()
assert body[0]["state"] == "unused" and body[0]["rule_uuid"] == U1 # unused rules first
def test_unused_finding_is_unknown_while_collecting_and_a_low_finding_when_found():
xml = _config()
ids = lambda f: {x["id"]: x for x in f} # noqa: E731
none_yet = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1))
assert "rules_unused" not in none_yet
collecting = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": [], "collecting": 3}))
assert collecting["rules_unused"]["passed"] is None
clean = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": [], "collecting": 0}))
assert clean["rules_unused"]["passed"] is True
found = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": ["old vpn rule"], "collecting": 0}))
assert found["rules_unused"]["passed"] is False and found["rules_unused"]["severity"] == "low"
assert found["rules_unused"]["params"] == {"count": 1, "examples": ["old vpn rule"]}
# ── notifications ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_critical_and_high_findings_become_problems_that_resolve_when_fixed(client, respx_mock):
from app.database import SessionLocal
from app.services import notification_checks
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL"),
ssh="<ssh><enabled>enabled</enabled><permitrootlogin>1</permitrootlogin><passwordauth>1</passwordauth></ssh>"))
host = await _create_host(client, "fw1", "https://fw1.test")
async with SessionLocal() as db:
assert await notification_checks.check_compliance(db) == {} # nothing checked yet
await client.post(f"/api/hosts/{host['id']}/compliance/check")
async with SessionLocal() as db:
problems = await notification_checks.check_compliance(db)
assert set(problems) == {f"{host['id']}:wan_any_any", f"{host['id']}:ssh_hardened"}
wan = problems[f"{host['id']}:wan_any_any"]
assert wan["host_name"] == "fw1" and wan["schweregrad"] == "kritisch" and wan["beispiele"] == "Allow ALL" and wan["befund"].startswith("Keine Allow-all")
# The firewall is fixed: next check, the problems are gone (-> "resolved").
respx_mock.clear()
_mock_host(respx_mock, _config())
await client.post(f"/api/hosts/{host['id']}/compliance/check")
async with SessionLocal() as db:
assert await notification_checks.check_compliance(db) == {}
@pytest.mark.asyncio
async def test_alerts_are_off_by_default_and_unreachable_hosts_raise_none(client, respx_mock):
from app.database import SessionLocal
from app.services import notification_checks
assert (await client.get("/api/notifications/settings")).json()["compliance_alerts_enabled"] is False
assert (await client.put("/api/notifications/settings", json={"compliance_alerts_enabled": True})).json()["compliance_alerts_enabled"] is True
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(500))
await client.post(f"/api/hosts/{host['id']}/compliance/check")
async with SessionLocal() as db:
assert await notification_checks.check_compliance(db) == {}
+142
View File
@@ -0,0 +1,142 @@
"""Full OPNexus backup: round trip, integrity, refusal of hostile or newer archives."""
import io
import json
import os
import tarfile
from pathlib import Path
import pytest
from app import full_backup as fb
from app.config import settings
from app.services import backup_crypto
PASS = "full backup passphrase 1"
old_mtime = 1_700_000_000 # Nov 2023
@pytest.fixture
def data(tmp_path, monkeypatch):
"""Point every data directory at a scratch tree with recognisable content."""
for name, attr in fb._DATA_DIRS.items():
path = tmp_path / name
path.mkdir()
monkeypatch.setattr(settings, attr, str(path))
(tmp_path / "opnsense_secrets" / "host-1.secret").write_text("API-SECRET-1")
(tmp_path / "totp_secrets").joinpath("u.secret").write_text("TOTP-SECRET")
sub = tmp_path / "opnsense_backups" / "host-1"
sub.mkdir()
(sub / "20260101T000000.xml").write_text("<opnsense/>")
os.utime(sub / "20260101T000000.xml", (old_mtime, old_mtime))
return tmp_path
def _dump(path: Path) -> None:
path.write_bytes(b"PGDMP-fake-database")
def _create(**kw):
return fb.create(PASS, dump=_dump, revision="0009", **kw)
def test_roundtrip_restores_every_secret_and_the_database(data):
blob = _create()
assert b"API-SECRET-1" not in blob and b"TOTP-SECRET" not in blob and b"PGDMP" not in blob
# Wipe the data, then restore.
for f in data.rglob("*"):
if f.is_file():
f.unlink()
seen = {}
result = fb.restore(blob, PASS, restore_db=lambda p: seen.update(dump=p.read_bytes()), head="0012")
assert seen["dump"] == b"PGDMP-fake-database"
assert (data / "opnsense_secrets" / "host-1.secret").read_text() == "API-SECRET-1"
assert (data / "totp_secrets" / "u.secret").read_text() == "TOTP-SECRET"
assert (data / "opnsense_backups" / "host-1" / "20260101T000000.xml").read_text() == "<opnsense/>"
assert result["files_restored"] == 3 and result["db_revision"] == "0009"
mode = (data / "opnsense_secrets" / "host-1.secret").stat().st_mode & 0o777
assert mode == 0o600
# file times survive: stored config backups are aged by their mtime
assert abs((data / "opnsense_backups" / "host-1" / "20260101T000000.xml").stat().st_mtime - old_mtime) < 2
def test_info_shows_only_the_manifest_and_needs_the_passphrase(data):
blob = _create()
manifest = fb.info(blob, PASS)
assert manifest["format"] == 1 and manifest["db_revision"] == "0009" and "opnsense_secrets" in manifest["data"]
with pytest.raises(fb.FullBackupError, match="Passphrase"):
fb.info(blob, "wrong passphrase 123")
def test_short_passphrase_is_refused(data):
with pytest.raises(fb.FullBackupError, match="mindestens"):
fb.create("short", dump=_dump, revision="0009")
def test_tampered_or_foreign_files_are_refused(data):
blob = bytearray(_create())
blob[-5] ^= 0xFF
with pytest.raises(fb.FullBackupError):
fb.restore(bytes(blob), PASS, restore_db=lambda p: None, head="0012")
for junk in (b"", b"not a backup"):
with pytest.raises(fb.FullBackupError):
fb.restore(junk, PASS, restore_db=lambda p: None, head="0012")
# A valid encrypted file that is not a full backup (a single config backup, say).
with pytest.raises(fb.FullBackupError, match="Archiv unlesbar"):
fb.restore(backup_crypto.encrypt(b"<opnsense/>", PASS), PASS, restore_db=lambda p: None, head="0012")
def test_a_backup_from_a_newer_version_is_refused_before_anything_is_touched(data):
blob = fb.create(PASS, dump=_dump, revision="0020")
touched = []
with pytest.raises(fb.FullBackupError, match="neueren"):
fb.restore(blob, PASS, restore_db=lambda p: touched.append(p), head="0012")
assert touched == []
(data / "totp_secrets" / "u.secret").write_text("UNCHANGED")
assert (data / "totp_secrets" / "u.secret").read_text() == "UNCHANGED"
def _hostile(member_name: str, kind=tarfile.REGTYPE, link=""):
plain = io.BytesIO()
with tarfile.open(fileobj=plain, mode="w:gz") as tar:
manifest = json.dumps({"format": 1, "db_revision": "0001"}).encode()
info = tarfile.TarInfo("manifest.json"); info.size = len(manifest)
tar.addfile(info, io.BytesIO(manifest))
dump = tarfile.TarInfo("database.dump"); dump.size = 1
tar.addfile(dump, io.BytesIO(b"x"))
evil = tarfile.TarInfo(member_name); evil.type = kind; evil.linkname = link
evil.size = 4 if kind == tarfile.REGTYPE else 0
tar.addfile(evil, io.BytesIO(b"evil") if kind == tarfile.REGTYPE else None)
return backup_crypto.encrypt(plain.getvalue(), PASS)
@pytest.mark.parametrize("name,kind,link", [
("../../etc/cron.d/x", tarfile.REGTYPE, ""),
("/etc/passwd", tarfile.REGTYPE, ""),
("data/opnsense_secrets/../../escape", tarfile.REGTYPE, ""),
("data/opnsense_secrets/link", tarfile.SYMTYPE, "/etc/shadow"),
("data/opnsense_secrets/dev", tarfile.CHRTYPE, ""),
])
def test_hostile_archives_are_rejected_before_the_database_is_touched(data, name, kind, link):
touched = []
with pytest.raises(fb.FullBackupError, match="Unzulässig"):
fb.restore(_hostile(name, kind, link), PASS, restore_db=lambda p: touched.append(p), head="0012")
assert touched == [] and not (data.parent / "escape").exists()
def test_unknown_directories_in_the_archive_are_ignored(data, tmp_path):
blob = _hostile("data/not_a_known_volume/file")
fb.restore(blob, PASS, restore_db=lambda p: None, head="0012")
assert not (tmp_path / "not_a_known_volume").exists()
def test_command_line_reads_the_passphrase_from_the_environment(data, monkeypatch, capsys):
blob = _create()
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", PASS)
class _In: # sys.stdin stand-in with a binary buffer
buffer = io.BytesIO(blob)
monkeypatch.setattr("sys.stdin", _In)
assert fb._main(["full_backup", "info"]) == 0
assert '"format": 1' in capsys.readouterr().out
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", "")
assert fb._main(["full_backup", "create"]) == 1
assert fb._main(["full_backup", "bogus"]) == 2
+344
View File
@@ -0,0 +1,344 @@
"""OIDC single sign-on against a fake identity provider (respx): the happy path and every way a forged or
confused login could succeed."""
import base64
import hashlib
import json
import time
from urllib.parse import parse_qs, urlsplit
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt.algorithms import RSAAlgorithm
from sqlalchemy import select, update
from app.database import SessionLocal
from app.models.oidc import OidcLoginState
from app.models.user import AdminUser
from app.services import notification_secret_store, oidc_service
from tests.test_api_tokens import _login
ISSUER = "https://idp.test/realm"
CLIENT = "opnexus-client"
def _keypair(kid):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
jwk = json.loads(RSAAlgorithm.to_jwk(key.public_key()))
jwk.update(kid=kid, use="sig", alg="RS256")
return pem, jwk
KEY, JWK = _keypair("k1")
OTHER_KEY, _ = _keypair("k1") # same kid, different key: a forger
@pytest.fixture(autouse=True)
async def _fresh():
oidc_service.clear_caches()
notification_secret_store.delete_secret(oidc_service.SECRET_NAME)
async with SessionLocal() as db:
await db.execute(OidcLoginState.__table__.delete())
await db.execute(AdminUser.__table__.delete().where(AdminUser.auth_source == "oidc"))
await db.commit()
yield
async def _configure(client, **extra):
body = {"enabled": True, "display_name": "Authentik", "issuer": ISSUER, "client_id": CLIENT, "client_secret": "s3cret",
"admin_group": "opnexus-admins", "operator_group": "opnexus-ops", "read_only_group": "opnexus-viewers", **extra}
resp = await client.put("/api/settings/oidc", json=body)
assert resp.status_code == 200, resp.text
def _mock_idp(respx_mock, *, userinfo=None):
respx_mock.get(f"{ISSUER}/.well-known/openid-configuration").mock(return_value=httpx.Response(200, json={
"issuer": ISSUER, "authorization_endpoint": f"{ISSUER}/auth", "token_endpoint": f"{ISSUER}/token",
"jwks_uri": f"{ISSUER}/jwks", **({"userinfo_endpoint": f"{ISSUER}/userinfo"} if userinfo else {}),
"token_endpoint_auth_methods_supported": ["client_secret_basic"]}))
respx_mock.get(f"{ISSUER}/jwks").mock(return_value=httpx.Response(200, json={"keys": [JWK]}))
if userinfo is not None:
respx_mock.get(f"{ISSUER}/userinfo").mock(return_value=httpx.Response(200, json=userinfo))
def _claims(sent_nonce, **over):
now = int(time.time())
base = {"iss": ISSUER, "aud": CLIENT, "sub": "user-1", "exp": now + 300, "iat": now, "nonce": sent_nonce,
"preferred_username": "alice", "groups": ["opnexus-admins"]}
base.update(over)
return {k: v for k, v in base.items() if v is not None}
def _id_token(claims, key=KEY, alg="RS256", kid="k1"):
return jwt.encode(claims, key, algorithm=alg, headers={"kid": kid})
async def _start(anon, respx_mock, **idp):
_mock_idp(respx_mock, **idp)
resp = await anon.get("/api/auth/oidc/start", params={"origin": "http://test"})
assert resp.status_code == 302, resp.text
location = urlsplit(resp.headers["location"])
assert f"{location.scheme}://{location.netloc}{location.path}" == f"{ISSUER}/auth"
return {k: v[0] for k, v in parse_qs(location.query).items()}
async def _callback(anon, respx_mock, query, *, claims=None, token_response=None, **token_kw):
"""Drive the callback: the fake token endpoint answers with an ID token for the nonce of this attempt."""
seen = {}
def token(request):
seen["form"] = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
seen["auth"] = request.headers.get("authorization")
if token_response is not None:
return httpx.Response(200, json=token_response)
return httpx.Response(200, json={"id_token": _id_token(claims if claims is not None else _claims(query["nonce"]), **token_kw), "access_token": "at"})
respx_mock.post(f"{ISSUER}/token").mock(side_effect=token)
resp = await anon.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
return resp, seen
def _error(resp):
assert resp.status_code == 302, resp.text
loc = resp.headers["location"]
assert loc.startswith("/login?sso_error="), loc
return loc.split("=", 1)[1]
async def _users(prefix):
async with SessionLocal() as db:
return {u.username: u for u in (await db.scalars(select(AdminUser).where(AdminUser.auth_source == "oidc"))).all()}
@pytest.mark.asyncio
async def test_login_creates_the_account_with_the_role_of_the_group_and_a_working_session(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
assert query["code_challenge_method"] == "S256" and query["redirect_uri"] == "http://test/api/auth/oidc/callback"
assert query["client_id"] == CLIENT and "openid" in query["scope"]
resp, seen = await _callback(anon_client, respx_mock, query)
assert resp.status_code == 302 and resp.headers["location"] == "/dashboard"
# The provider got the PKCE verifier matching the challenge, the redirect URI and the client secret (HTTP basic).
digest = base64.urlsafe_b64encode(hashlib.sha256(seen["form"]["code_verifier"].encode()).digest()).rstrip(b"=").decode()
assert digest == query["code_challenge"] and seen["form"]["redirect_uri"] == query["redirect_uri"]
assert seen["auth"] == "Basic " + base64.b64encode(f"{CLIENT}:s3cret".encode()).decode()
me = (await anon_client.get("/api/auth/me")).json()
assert me == {"authenticated": True, "username": "alice", "role": "admin"}
user = (await _users("alice"))["alice"]
assert user.auth_source == "oidc" and user.oidc_subject == f"{ISSUER}|user-1" and user.last_login_at is not None
@pytest.mark.asyncio
async def test_role_follows_the_groups_on_every_login_and_the_most_privileged_wins(client, anon_client, respx_mock):
await _configure(client)
for groups, expected in ((["opnexus-viewers"], "read_only"), (["opnexus-viewers", "opnexus-ops"], "operator"),
(["OPNEXUS-ADMINS", "x"], "admin"), (["opnexus-ops"], "operator")):
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=groups))
assert resp.headers["location"] == "/dashboard"
assert (await _users("alice"))["alice"].role == expected, groups
assert len(await _users("alice")) == 1 # same identity, same account
@pytest.mark.asyncio
async def test_without_a_mapped_group_nobody_gets_in_and_no_account_is_created(client, anon_client, respx_mock):
await _configure(client)
for groups in ([], ["random"], None):
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=groups))
assert _error(resp) == "no_role"
assert await _users("alice") == {}
assert (await anon_client.get("/api/auth/me")).json() == {"authenticated": False}
@pytest.mark.asyncio
async def test_groups_can_come_from_userinfo_but_only_for_the_same_subject(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock, userinfo={"sub": "user-1", "groups": ["opnexus-ops"]})
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=None))
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "operator"
respx_mock.clear()
query = await _start(anon_client, respx_mock, userinfo={"sub": "someone-else", "groups": ["opnexus-admins"]})
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups=None, sub="user-2"))
assert _error(resp) == "no_role"
@pytest.mark.asyncio
async def test_group_claim_may_be_a_string(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], groups="foo, opnexus-viewers bar"))
assert resp.headers["location"] == "/dashboard" and (await _users("alice"))["alice"].role == "read_only"
@pytest.mark.parametrize("label,over", [
("wrong audience", {"aud": "someone-elses-client"}),
("wrong issuer", {"iss": "https://evil.test/realm"}),
("expired", {"exp": int(time.time()) - 3600}),
("no expiry", {"exp": None}),
("wrong nonce", {"nonce": "not-the-one-we-sent"}),
("no nonce", {"nonce": None}),
("multi audience, wrong azp", {"aud": [CLIENT, "other"], "azp": "other"}),
])
@pytest.mark.asyncio
async def test_forged_or_confused_id_tokens_are_rejected(client, anon_client, respx_mock, label, over):
await _configure(client)
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], **over))
assert _error(resp) == "token", label
assert await _users("alice") == {}
@pytest.mark.asyncio
async def test_signature_attacks_are_rejected(client, anon_client, respx_mock):
await _configure(client)
public_pem = serialization.load_pem_public_key # noqa: F841 (documenting the confusion attack below)
cases = {
"signed with another key": dict(key=OTHER_KEY),
"unknown key id": dict(kid="nope"),
"alg none": None,
"hmac with the public key as secret": "hs",
}
for label, spec in cases.items():
query = await _start(anon_client, respx_mock)
claims = _claims(query["nonce"])
if spec is None:
token = jwt.encode(claims, None, algorithm="none", headers={"kid": "k1"})
elif spec == "hs":
token = jwt.encode(claims, json.dumps(JWK), algorithm="HS256", headers={"kid": "k1"})
else:
token = _id_token(claims, **spec)
resp, _ = await _callback(anon_client, respx_mock, query, token_response={"id_token": token})
assert _error(resp) == "token", label
oidc_service.clear_caches()
respx_mock.clear()
assert await _users("alice") == {}
@pytest.mark.asyncio
async def test_a_state_works_exactly_once_and_unknown_or_expired_ones_fail(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
first, _ = await _callback(anon_client, respx_mock, query)
assert first.headers["location"] == "/dashboard"
replay = await anon_client.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
assert _error(replay) == "state"
forged = await anon_client.get("/api/auth/oidc/callback", params={"code": "abc", "state": "made-up"})
assert _error(forged) == "state"
query = await _start(anon_client, respx_mock)
async with SessionLocal() as db:
from datetime import datetime, timedelta, timezone
await db.execute(update(OidcLoginState).values(created_at=datetime.now(timezone.utc) - timedelta(minutes=30)))
await db.commit()
expired, _ = await _callback(anon_client, respx_mock, query)
assert _error(expired) == "state"
@pytest.mark.asyncio
async def test_provider_errors_and_missing_parameters(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
denied = await anon_client.get("/api/auth/oidc/callback", params={"error": "access_denied", "state": query["state"]})
assert _error(denied) == "provider"
assert _error(await anon_client.get("/api/auth/oidc/callback")) == "provider"
query = await _start(anon_client, respx_mock)
respx_mock.post(f"{ISSUER}/token").mock(return_value=httpx.Response(400, json={"error": "invalid_grant"}))
bad = await anon_client.get("/api/auth/oidc/callback", params={"code": "abc", "state": query["state"]})
assert _error(bad) == "token"
@pytest.mark.asyncio
async def test_an_sso_identity_never_takes_over_a_local_account(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
resp, _ = await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username="admin", sub="evil-1"))
assert resp.headers["location"] == "/dashboard"
users = await _users("admin")
assert "admin" not in users and any(n.startswith("admin~sso") for n in users)
async with SessionLocal() as db:
local = (await db.scalars(select(AdminUser).where(AdminUser.username == "admin"))).one()
assert local.auth_source == "local" and local.oidc_subject is None and local.role == "admin"
# A second identity with the same preferred name gets yet another account.
query = await _start(anon_client, respx_mock)
await _callback(anon_client, respx_mock, query, claims=_claims(query["nonce"], preferred_username="admin", sub="evil-2"))
assert len([n for n in await _users("admin") if n.startswith("admin~sso")]) == 2
@pytest.mark.asyncio
async def test_sso_accounts_have_no_usable_password_and_cannot_be_managed_locally(client, anon_client, respx_mock):
await _configure(client)
query = await _start(anon_client, respx_mock)
await _callback(anon_client, respx_mock, query)
for password in ("", "!oidc", "alice", "!"):
resp = await anon_client.post("/api/auth/login", json={"username": "alice", "password": password or "x"})
assert resp.status_code == 401
listing = {u["username"]: u for u in (await client.get("/api/users")).json()}
uid = listing["alice"]["id"]
assert listing["alice"]["auth_source"] == "oidc"
for path, body, method in ((f"/api/users/{uid}/role", {"role": "read_only"}, "PATCH"),
(f"/api/users/{uid}/reset-password", {"new_password": "a-new-password-1"}, "POST")):
resp = await client.request(method, path, json=body)
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "OIDC_MANAGED_USER"
assert (await client.delete(f"/api/users/{uid}")).status_code == 204 # deleting is fine; they can sign in again
@pytest.mark.asyncio
async def test_start_refuses_foreign_origins_and_disabled_sso(client, anon_client, respx_mock):
assert _error(await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})) == "disabled"
await _configure(client)
_mock_idp(respx_mock)
for origin in ("http://evil.test", "http://test/path", "http://user@test", "javascript:alert(1)", "http://test?x=1"):
assert _error(await anon_client.get("/api/auth/oidc/start", params={"origin": origin})) == "disabled", origin
await client.put("/api/settings/oidc", json={"enabled": False})
assert _error(await anon_client.get("/api/auth/oidc/start", params={"origin": "http://test"})) == "disabled"
@pytest.mark.asyncio
async def test_public_config_reveals_only_the_button_text(client, anon_client):
assert (await anon_client.get("/api/auth/oidc/config")).json() == {"enabled": False, "name": None}
await _configure(client)
assert (await anon_client.get("/api/auth/oidc/config")).json() == {"enabled": True, "name": "Authentik"}
await client.put("/api/settings/oidc", json={"admin_group": "", "operator_group": "", "read_only_group": ""})
assert (await anon_client.get("/api/auth/oidc/config")).json()["enabled"] is False # no group mapping = nobody could enter
@pytest.mark.asyncio
async def test_settings_are_admin_only_hide_the_secret_and_validate(client):
await _configure(client)
got = (await client.get("/api/settings/oidc")).json()
assert got["client_secret_set"] is True and "s3cret" not in json.dumps(got)
assert notification_secret_store.read_secret(oidc_service.SECRET_NAME) == "s3cret"
assert (await client.put("/api/settings/oidc", json={"issuer": "http://idp.test"})).status_code == 422
assert (await client.put("/api/settings/oidc", json={"clear_client_secret": True})).json()["client_secret_set"] is False
for role in ("operator", "read_only"):
name = f"sso-{role}"
assert (await client.post("/api/users", json={"username": name, "password": "sso-password-123", "role": role})).status_code == 201
user = await _login(name, "sso-password-123")
try:
assert (await user.get("/api/settings/oidc")).status_code == 403
assert (await user.put("/api/settings/oidc", json={"enabled": False})).status_code == 403
assert (await user.post("/api/settings/oidc/test")).status_code == 403
finally:
await user.aclose()
@pytest.mark.asyncio
async def test_connection_test(client, respx_mock):
assert (await client.post("/api/settings/oidc/test")).status_code == 422
await _configure(client)
_mock_idp(respx_mock)
assert (await client.post("/api/settings/oidc/test")).json()["authorization_endpoint"] == f"{ISSUER}/auth"
respx_mock.clear()
respx_mock.get(f"{ISSUER}/.well-known/openid-configuration").mock(return_value=httpx.Response(200, json={"issuer": "https://other.test"}))
resp = await client.post("/api/settings/oidc/test")
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "OIDC_TEST_FAILED"
@pytest.mark.asyncio
async def test_failed_callbacks_are_throttled(client, anon_client, respx_mock):
await _configure(client)
results = [_error(await anon_client.get("/api/auth/oidc/callback", params={"code": "x", "state": f"s{i}"})) for i in range(12)]
assert "throttled" in results
+170
View File
@@ -0,0 +1,170 @@
"""The operator role: rolls out and operates, but cannot change access or destinations."""
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from tests.test_api_tokens import _bearer, _login
PW = "operator-password-1"
async def _operator(client, name="oper"):
resp = await client.post("/api/users", json={"username": name, "password": PW, "role": "operator"})
assert resp.status_code == 201, resp.text
assert resp.json()["role"] == "operator"
return await _login(name, PW)
@pytest.mark.asyncio
async def test_operator_can_deploy_but_not_manage_access(client):
op = await _operator(client)
try:
assert (await op.post("/api/groups", json={"name": "ops-group"})).status_code == 201
for method, path, body in [
("POST", "/api/users", {"username": "x", "password": "x" * 12, "role": "admin"}),
("PATCH", "/api/users/00000000-0000-0000-0000-000000000000/role", {"role": "admin"}),
("POST", "/api/hosts", {}),
("PUT", "/api/notifications/settings", {}),
("POST", "/api/system/updater/update", {}),
]:
resp = await op.request(method, path, json=body)
assert resp.status_code == 403, (path, resp.text)
assert resp.json()["detail"]["code"] == "ADMIN_REQUIRED"
finally:
await op.aclose()
@pytest.mark.asyncio
async def test_operator_keeps_self_service(client):
op = await _operator(client, "selfserve")
try:
assert (await op.put("/api/settings", json={"theme": "dark"})).status_code == 200
resp = await op.post("/api/auth/change-password", json={"current_password": PW, "new_password": "another-password-1"})
assert resp.status_code == 200, resp.text
assert (await op.get("/api/users")).status_code == 200
finally:
await op.aclose()
@pytest.mark.asyncio
async def test_operator_tokens_are_capped_at_deploy_and_inherit_the_limits(client):
op = await _operator(client, "tokop")
try:
denied = await op.post("/api/tokens", json={"name": "a", "scope": "admin", "current_password": PW})
assert denied.status_code == 403 and denied.json()["detail"]["code"] == "API_TOKEN_SCOPE_TOO_HIGH"
made = await op.post("/api/tokens", json={"name": "ci", "scope": "deploy", "current_password": PW})
assert made.status_code == 201, made.text
finally:
await op.aclose()
async with _bearer(made.json()["token"]) as api:
assert (await api.post("/api/groups", json={"name": "from-op-token"})).status_code == 201
assert (await api.post("/api/users", json={"username": "y", "password": "y" * 12})).status_code == 403
@pytest.mark.asyncio
async def test_operator_cannot_download_raw_backups(client, respx_mock):
import httpx
from tests.test_rules import _create_host
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
op = await _operator(client, "nobackup")
try:
resp = await op.get(f"/api/hosts/{host['id']}/backups/anything.xml")
assert resp.status_code == 403
finally:
await op.aclose()
@pytest.mark.asyncio
async def test_refused_operator_writes_are_audited(client):
op = await _operator(client, "audited")
try:
await op.post("/api/users", json={"username": "z", "password": "z" * 12})
finally:
await op.aclose()
async with SessionLocal() as db:
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.actor == "audited", AuditEvent.status == "denied"))).all()
assert rows and rows[0].kind == "user"
@pytest.mark.asyncio
async def test_demoting_an_admin_to_operator_takes_effect_at_once(client):
resp = await client.post("/api/users", json={"username": "wasadmin", "password": PW, "role": "admin"})
uid = resp.json()["id"]
user = await _login("wasadmin", PW)
try:
assert (await user.post("/api/groups", json={"name": "g1"})).status_code == 201
assert (await client.patch(f"/api/users/{uid}/role", json={"role": "operator"})).status_code == 200
assert (await user.post("/api/users", json={"username": "w", "password": "w" * 12})).status_code == 403
finally:
await user.aclose()
# ── separation of duties ─────────────────────────────────────────────────
@pytest.mark.parametrize("method,path,rollout", [
("POST", "/api/rules/abc/deploy", True),
("POST", "/api/aliases/abc/deploy", True),
("POST", "/api/port-forward/abc/deploy", True),
("POST", "/api/rules/abc/deployments/def/remove", True),
("POST", "/api/rules/abc/deployments/def/retry-rollback", True),
("POST", "/api/hosts/abc/maintenance/execute", True),
("POST", "/api/hosts/abc/maintenance/carp-execute", True),
("POST", "/api/hosts/abc/maintenance/schedule", True),
("POST", "/api/hosts/abc/network/changes", True),
("POST", "/api/rules/abc/preview", False),
("POST", "/api/rules", False),
("PUT", "/api/rules/abc", False),
("POST", "/api/rules/abc/confirm", False),
("GET", "/api/rules/abc/deploy", False),
("POST", "/api/hosts/abc/network/changes/preview", False),
])
def test_what_counts_as_a_rollout(method, path, rollout):
from app.services import api_token_service
assert api_token_service.is_rollout(method, path) is rollout
@pytest.mark.asyncio
async def test_with_separation_of_duties_operators_prepare_but_only_admins_deploy(client):
op = await _operator(client, "dutyop")
try:
assert (await op.get("/api/settings/governance")).json() == {"operators_may_deploy": True}
assert (await op.put("/api/settings/governance", json={"operators_may_deploy": False})).status_code == 403
assert (await client.put("/api/settings/governance", json={"operators_may_deploy": False})).json() == {"operators_may_deploy": False}
# Preparing still works ...
assert (await op.post("/api/groups", json={"name": "prepared"})).status_code == 201
# ... rolling out does not, whatever the target (the 403 comes before any lookup).
for path in ("/api/rules/00000000-0000-0000-0000-000000000000/deploy", "/api/hosts/00000000-0000-0000-0000-000000000000/maintenance/execute"):
resp = await op.post(path, json={})
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "DEPLOY_NEEDS_ADMIN", path
# An admin is not affected (404 = got past the gate and looked for the rule).
assert (await client.post("/api/rules/00000000-0000-0000-0000-000000000000/deploy", json={})).status_code in (404, 422)
# Switching it back lets the operator through the gate again.
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
assert (await op.post("/api/rules/00000000-0000-0000-0000-000000000000/deploy", json={})).status_code in (404, 422)
finally:
await op.aclose()
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
@pytest.mark.asyncio
async def test_the_refusal_is_audited_and_covers_operator_tokens(client):
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
op = await _operator(client, "dutytok")
try:
token = (await op.post("/api/tokens", json={"name": "ci", "scope": "deploy", "current_password": PW})).json()["token"]
finally:
await op.aclose()
await client.put("/api/settings/governance", json={"operators_may_deploy": False})
try:
async with _bearer(token) as api:
resp = await api.post("/api/rules/00000000-0000-0000-0000-000000000000/deploy", json={})
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "DEPLOY_NEEDS_ADMIN"
finally:
await client.put("/api/settings/governance", json={"operators_may_deploy": True})
async with SessionLocal() as db:
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.status == "denied", AuditEvent.actor.like("dutytok%")))).all()
assert rows and rows[0].kind == "rule" and "dutytok (API: ci)" in {r.actor for r in rows}
+439
View File
@@ -0,0 +1,439 @@
"""Management overlay: keys, addressing, hub config, the hub's proxy, and routing requests through it."""
import asyncio
import ipaddress
import json
import os
import ssl
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from app.config import settings
from app.overlay_hub import Hub, parse_dump, wg_conf
from app.services import overlay_service as svc
from tests.test_api_tokens import _login
@pytest.fixture(autouse=True)
def _clean_overlay():
root = Path(settings.overlay_dir)
if root.exists():
for f in root.iterdir():
f.unlink()
svc._active_network = None
yield
svc._active_network = None
# ── keys and addresses ───────────────────────────────────────────────────
def test_keys_are_valid_wireguard_keys_and_the_hub_key_is_stable():
private = svc.generate_private_key()
public = svc.public_from_private(private)
assert svc.valid_public_key(public) and len(public) == 44 and public != private
assert svc.hub_public_key() == svc.hub_public_key()
assert oct((Path(settings.overlay_dir) / "hub.key").stat().st_mode & 0o777) == "0o600"
# RFC 7748 section 6.1 vector (Alice): the standard X25519 public key for a given private key
alice_priv = bytes.fromhex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a")
import base64
assert svc.public_from_private(base64.b64encode(alice_priv).decode()) == base64.b64encode(
bytes.fromhex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")).decode()
@pytest.mark.parametrize("value,ok", [
("A" * 43 + "=", True), ("", False), ("short", False), ("A" * 44, False), ("A" * 42 + "==", False), ("!" * 43 + "=", False),
])
def test_public_key_validation(value, ok):
assert svc.valid_public_key(value) is ok
def test_subnets():
assert str(svc.parse_subnet("10.99.0.0/24")) == "10.99.0.0/24"
for bad in ("8.8.8.0/24", "10.99.0.5/24", "10.0.0.0/8", "10.99.0.0/30", "fd00::/64", "nonsense", ""):
with pytest.raises(svc.OverlayError):
svc.parse_subnet(bad)
def test_allocation_skips_the_hub_and_taken_addresses_and_runs_out():
net = svc.parse_subnet("10.99.0.0/29") # .1 hub, .2-.6 usable
assert str(svc.hub_address(net)) == "10.99.0.1"
taken: set[str] = set()
for expected in ("10.99.0.2", "10.99.0.3", "10.99.0.4", "10.99.0.5", "10.99.0.6"):
ip = svc.next_free_ip(net, taken)
assert ip == expected
taken.add(ip)
with pytest.raises(svc.OverlayError):
svc.next_free_ip(net, taken)
taken.discard("10.99.0.3")
assert svc.next_free_ip(net, taken) == "10.99.0.3" # reuses holes
def test_proxy_routing_decision():
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
assert svc.proxy_for("https://10.99.0.2") == settings.overlay_proxy_url
assert svc.proxy_for("https://10.99.0.2:8443/x") == settings.overlay_proxy_url
for direct in ("https://10.98.0.2", "https://fw.example.com", "https://[fd00::1]", "not a url", "https://"):
assert svc.proxy_for(direct) is None
svc._active_network = None
assert svc.proxy_for("https://10.99.0.2") is None
# ── API ──────────────────────────────────────────────────────────────────
async def _enable(client, **extra):
resp = await client.put("/api/overlay/settings", json={"enabled": True, "endpoint": "vpn.example.com", **extra})
assert resp.status_code == 200, resp.text
return resp.json()
def _hub_json():
return json.loads((Path(settings.overlay_dir) / "hub.json").read_text())
@pytest.mark.asyncio
async def test_enabling_needs_an_endpoint_and_writes_the_hub_config(client):
assert (await client.put("/api/overlay/settings", json={"enabled": True})).status_code == 422
body = await _enable(client)
assert body["enabled"] and body["hub_public_key"] == svc.hub_public_key() and body["hub_address"] == "10.99.0.1"
assert body["hub_state"] == "absent" # no hub container reporting
config = _hub_json()
assert config["enabled"] and config["address"] == "10.99.0.1/24" and config["listen_port"] == 51820 and config["peers"] == []
assert config["private_key"] == svc.hub_private_key()
assert svc._active_network == ipaddress.ip_network("10.99.0.0/24")
assert oct((Path(settings.overlay_dir) / "hub.json").stat().st_mode & 0o777) == "0o600"
await client.put("/api/overlay/settings", json={"enabled": False})
off = _hub_json()
assert off["enabled"] is False and off["private_key"] is None and off["peers"] == [] and svc._active_network is None
@pytest.mark.asyncio
async def test_peer_lifecycle_and_hub_config(client):
await _enable(client, listen_port=51999)
created = (await client.post("/api/overlay/peers", json={"name": "branch-1"})).json()
assert created["ip"] == "10.99.0.2"
ins = created["instructions"]
assert ins["tunnel_address"] == "10.99.0.2/32" and ins["allowed_ips"] == "10.99.0.1/32" and ins["endpoint"] == "vpn.example.com:51999"
assert ins["host_url"] == "https://10.99.0.2" and ins["keepalive"] == 25 and svc.valid_public_key(ins["hub_public_key"]) and ins["psk"]
again = (await client.get(f"/api/overlay/peers/{created['id']}/instructions")).json()
assert again["psk"] == ins["psk"] # stable, so the admin can look it up again
second = (await client.post("/api/overlay/peers", json={"name": "branch-2"})).json()
assert second["ip"] == "10.99.0.3" and second["instructions"]["psk"] != ins["psk"]
assert (await client.post("/api/overlay/peers", json={"name": "branch-1"})).status_code == 409
# A peer without a public key is not in the hub config yet.
assert _hub_json()["peers"] == []
key = svc.public_from_private(svc.generate_private_key())
assert (await client.patch(f"/api/overlay/peers/{created['id']}", json={"public_key": key})).status_code == 200
peers = _hub_json()["peers"]
assert peers == [{"public_key": key, "psk": ins["psk"], "ip": "10.99.0.2"}]
# Deleting removes it from the config and the PSK from disk.
assert (await client.delete(f"/api/overlay/peers/{created['id']}")).status_code == 204
assert _hub_json()["peers"] == [] and not (Path(settings.overlay_dir) / f"psk-{created['id']}").exists()
assert (await client.delete(f"/api/overlay/peers/{created['id']}")).status_code == 404
# The freed address is reused.
assert (await client.post("/api/overlay/peers", json={"name": "branch-3"})).json()["ip"] == "10.99.0.2"
@pytest.mark.asyncio
async def test_peer_key_validation_and_host_link(client, respx_mock):
import httpx
from tests.test_rules import _create_host
await _enable(client)
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
other = (await client.post("/api/overlay/peers", json={"name": "q"})).json()
for bad in ("nonsense", "A" * 44, svc.hub_public_key()):
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": bad})).status_code == 422, bad
good = svc.public_from_private(svc.generate_private_key())
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": good})).status_code == 200
assert (await client.patch(f"/api/overlay/peers/{other['id']}", json={"public_key": good})).status_code == 409 # one key, one peer
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"host_id": host["id"]})).status_code == 200
listing = (await client.get("/api/overlay")).json()["peers"]
assert next(p for p in listing if p["id"] == peer["id"])["host_name"] == "fw1"
import uuid
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"host_id": str(uuid.uuid4())})).status_code == 404
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"clear_host": True})).status_code == 200
assert (await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": ""})).status_code == 200 # clearing is allowed
assert _hub_json()["peers"] == []
@pytest.mark.asyncio
async def test_subnet_changes_only_without_peers(client):
await _enable(client)
assert (await client.put("/api/overlay/settings", json={"subnet": "172.30.5.0/24"})).json()["hub_address"] == "172.30.5.1"
assert (await client.put("/api/overlay/settings", json={"subnet": "8.8.8.0/24"})).status_code == 422
peer = (await client.post("/api/overlay/peers", json={"name": "x"})).json()
assert peer["ip"] == "172.30.5.2"
resp = await client.put("/api/overlay/settings", json={"subnet": "10.50.0.0/24"})
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "OVERLAY_SUBNET_LOCKED"
assert (await client.put("/api/overlay/settings", json={"subnet": "172.30.5.0/24"})).status_code == 200 # same value is no change
@pytest.mark.asyncio
async def test_live_status_from_the_hubs_report(client):
await _enable(client)
key = svc.public_from_private(svc.generate_private_key())
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
await client.patch(f"/api/overlay/peers/{peer['id']}", json={"public_key": key})
status_file = Path(settings.overlay_dir) / "status.json"
now = time.time()
status_file.write_text(json.dumps({"updated_at": now, "up": True, "error": None,
"peers": [{"public_key": key, "endpoint": "203.0.113.9:4000", "handshake_at": int(now) - 30, "rx": 1000, "tx": 2000}]}))
data = (await client.get("/api/overlay")).json()
assert data["settings"]["hub_state"] == "running"
p = data["peers"][0]
assert p["connected"] is True and p["rx_bytes"] == 1000 and p["tx_bytes"] == 2000 and p["remote_endpoint"] == "203.0.113.9:4000"
status_file.write_text(json.dumps({"updated_at": now, "up": True, "peers": [{"public_key": key, "handshake_at": int(now) - 900, "rx": 1, "tx": 1}]}))
assert (await client.get("/api/overlay")).json()["peers"][0]["connected"] is False # handshake too old
status_file.write_text(json.dumps({"updated_at": now - 600, "up": True, "peers": [{"public_key": key, "handshake_at": int(now), "rx": 1, "tx": 1}]}))
data = (await client.get("/api/overlay")).json()
assert data["settings"]["hub_state"] == "stale" and data["peers"][0]["connected"] is False # hub stopped reporting
status_file.write_text("not json")
assert (await client.get("/api/overlay")).json()["settings"]["hub_state"] == "absent"
@pytest.mark.asyncio
async def test_only_admins_write_or_read_the_psk(client):
await _enable(client)
peer = (await client.post("/api/overlay/peers", json={"name": "p"})).json()
for role in ("operator", "read_only"):
name = f"ov-{role}"
assert (await client.post("/api/users", json={"username": name, "password": "overlay-password-1", "role": role})).status_code == 201
user = await _login(name, "overlay-password-1")
try:
assert (await user.get("/api/overlay")).status_code == 200 # status is for everybody ...
assert "psk" not in (await user.get("/api/overlay")).text
assert (await user.get(f"/api/overlay/peers/{peer['id']}/instructions")).status_code == 403 # ... the pre-shared key is not
for method, path, body in (("PUT", "/api/overlay/settings", {"enabled": False}), ("POST", "/api/overlay/peers", {"name": "z"}),
("PATCH", f"/api/overlay/peers/{peer['id']}", {"clear_host": True}), ("DELETE", f"/api/overlay/peers/{peer['id']}", None)):
assert (await user.request(method, path, json=body)).status_code == 403, (role, path)
finally:
await user.aclose()
# ── the hub ──────────────────────────────────────────────────────────────
def test_wg_config_and_dump_parsing():
conf = wg_conf({"private_key": "PRIV", "listen_port": 51820, "peers": [{"public_key": "PUB1", "psk": "PSK1", "ip": "10.99.0.2"}]})
assert conf == "[Interface]\nPrivateKey = PRIV\nListenPort = 51820\n\n[Peer]\nPublicKey = PUB1\nPresharedKey = PSK1\nAllowedIPs = 10.99.0.2/32\n"
dump = "hubpriv\thubpub\t51820\toff\n" + "PUB1\tpsk\t198.51.100.7:5000\t10.99.0.2/32\t1700000000\t111\t222\t25\n" + "PUB2\tpsk\t(none)\t10.99.0.3/32\t0\t0\t0\t25\n"
assert parse_dump(dump) == [
{"public_key": "PUB1", "endpoint": "198.51.100.7:5000", "handshake_at": 1700000000, "rx": 111, "tx": 222},
{"public_key": "PUB2", "endpoint": None, "handshake_at": 0, "rx": 0, "tx": 0}]
assert parse_dump("only-interface-line") == []
class _FakeRunner:
def __init__(self, existing=False, fail=None):
self.calls, self.existing, self.fail = [], existing, fail
def __call__(self, *cmd, stdin=None):
self.calls.append(cmd)
if cmd[:3] == ("ip", "link", "show"):
return (0 if self.existing else 1), ""
if cmd[:2] == ("iptables", "-C"):
return 1, ""
if self.fail and cmd[0] == self.fail:
return 1, "boom"
if cmd[:2] == ("wg", "show"):
return 0, "hubpriv\thubpub\t51820\toff\nPUB1\tpsk\t(none)\t10.99.0.2/32\t0\t0\t0\t25\n"
return 0, ""
def _config(**over):
return {"enabled": True, "address": "10.99.0.1/24", "subnet": "10.99.0.0/24", "listen_port": 51820, "private_key": "PRIV",
"peers": [{"public_key": "PUB1", "psk": "PSK1", "ip": "10.99.0.2"}], **over}
def test_hub_applies_the_interface_peers_and_the_inbound_drop_rule(tmp_path):
runner = _FakeRunner()
hub = Hub(tmp_path, runner)
assert hub.apply(_config()) is None
cmds = [c[:3] for c in runner.calls]
assert ("ip", "link", "add") in cmds and ("ip", "address", "replace") in cmds and ("wg", "syncconf", "wg0") in cmds
assert any(c[0] == "iptables" and c[1] == "-I" and "-i" in c and "NEW" in c and c[-1] == "DROP" for c in runner.calls)
assert not list(tmp_path.glob(".wg0.conf")) # the private key file does not linger
assert hub.subnet == ipaddress.ip_network("10.99.0.0/24")
# An existing interface is reused, not re-created.
runner2 = _FakeRunner(existing=True)
Hub(tmp_path, runner2).apply(_config())
assert ("ip", "link", "add", "wg0", "type", "wireguard") not in runner2.calls
def test_hub_takes_the_interface_down_when_disabled_and_reports_errors(tmp_path):
runner = _FakeRunner(existing=True)
hub = Hub(tmp_path, runner)
assert hub.apply(_config(enabled=False)) is None and ("ip", "link", "del", "wg0") in runner.calls and hub.subnet is None
failing = Hub(tmp_path, _FakeRunner(fail="wg"))
assert "wg syncconf" in failing.apply(_config())
assert "ip link add" in (Hub(tmp_path, _FakeRunner(fail="ip")).apply(_config()) or "")
def test_hub_tick_applies_on_change_and_reports_status(tmp_path):
runner = _FakeRunner()
hub = Hub(tmp_path, runner)
assert hub.tick(None) is None # no config yet: still reports
status = json.loads((tmp_path / "status.json").read_text())
assert status["up"] is True and status["peers"][0]["public_key"] == "PUB1" and abs(status["updated_at"] - time.time()) < 5
(tmp_path / "hub.json").write_text(json.dumps(_config()))
hub.tick(None)
applied = len([c for c in runner.calls if c[:2] == ("wg", "syncconf")])
assert applied == 1
hub.tick(None)
assert len([c for c in runner.calls if c[:2] == ("wg", "syncconf")]) == 1 # unchanged file: no re-apply
os.utime(tmp_path / "hub.json", (time.time() + 10, time.time() + 10))
hub.tick(None)
assert len([c for c in runner.calls if c[:2] == ("wg", "syncconf")]) == 2
async def _serve_echo(prefix=b"echo:"):
async def handle(reader, writer):
while data := await reader.read(1024):
writer.write(prefix + data)
await writer.drain()
writer.close()
server = await asyncio.start_server(handle, "127.0.0.1", 0)
return server, server.sockets[0].getsockname()[1]
async def _proxy(subnet="127.0.0.0/8"):
hub = Hub(Path(tempfile.mkdtemp()), _FakeRunner())
hub.subnet = ipaddress.ip_network(subnet)
server = await asyncio.start_server(hub.handle_client, "127.0.0.1", 0)
return server, server.sockets[0].getsockname()[1]
async def _connect(proxy_port, target):
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
writer.write(f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n".encode())
await writer.drain()
return reader, writer, await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5)
@pytest.mark.asyncio
async def test_proxy_relays_connections_into_the_overlay_subnet():
echo, echo_port = await _serve_echo()
proxy, proxy_port = await _proxy()
try:
reader, writer, head = await _connect(proxy_port, f"127.0.0.1:{echo_port}")
assert head.startswith(b"HTTP/1.1 200")
writer.write(b"hello")
await writer.drain()
assert await asyncio.wait_for(reader.readexactly(10), 5) == b"echo:hello"
writer.close()
finally:
proxy.close(); echo.close()
@pytest.mark.asyncio
async def test_proxy_is_not_an_open_proxy():
echo, echo_port = await _serve_echo()
proxy, proxy_port = await _proxy(subnet="10.99.0.0/24") # loopback is outside this overlay
try:
for target in (f"127.0.0.1:{echo_port}", "8.8.8.8:443", "example.com:443", "10.99.0.2:0", "10.99.0.2:99999", "10.99.0.2:abc", "nonsense"):
_, writer, head = await _connect(proxy_port, target)
assert head.startswith(b"HTTP/1.1 403"), target
writer.close()
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
writer.write(b"GET http://10.99.0.2/ HTTP/1.1\r\nHost: 10.99.0.2\r\n\r\n") # plain proxying is not offered either
assert (await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5)).startswith(b"HTTP/1.1 403")
writer.close()
finally:
proxy.close(); echo.close()
@pytest.mark.asyncio
async def test_proxy_reports_unreachable_firewalls_and_survives_garbage():
proxy, proxy_port = await _proxy()
free = socket_free_port()
try:
_, writer, head = await _connect(proxy_port, f"127.0.0.1:{free}")
assert head.startswith(b"HTTP/1.1 502")
writer.close()
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
writer.write(b"\x00\xff" * 100 + b"\r\n\r\n")
await writer.drain()
await asyncio.sleep(0.2)
writer.close()
_, writer, head = await _connect(proxy_port, f"127.0.0.1:{free}") # still serving
assert head.startswith(b"HTTP/1.1 502")
writer.close()
finally:
proxy.close()
def socket_free_port() -> int:
import socket
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
# ── requests to overlay firewalls go through the proxy ───────────────────
def test_clients_for_overlay_hosts_use_the_proxy_and_others_do_not(monkeypatch):
from app.models.host import OpnsenseHost
from app.services import opnsense_client, opnsense_secret_store
import uuid
monkeypatch.setattr(opnsense_secret_store, "read_secret", lambda host_id: "secret")
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
overlay = OpnsenseHost(id=uuid.uuid4(), name="o", base_url="https://10.99.0.2", api_key="k", verify_tls=False)
direct = OpnsenseHost(id=uuid.uuid4(), name="d", base_url="https://10.10.10.2", api_key="k", verify_tls=False)
assert opnsense_client._client(overlay)._mounts, "the overlay client must have a proxy mount"
assert not opnsense_client._client(direct)._mounts
def _self_signed(cn="fw.test"):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1)).not_valid_after(datetime.now(timezone.utc) + timedelta(days=30))
.sign(key, hashes.SHA256()))
return cert, key
@pytest.mark.asyncio
async def test_tls_probe_and_pin_check_work_through_the_proxy(tmp_path, monkeypatch):
from app.services import tls_pin_service
cert, key = _self_signed()
(tmp_path / "c.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
(tmp_path / "k.pem").write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(tmp_path / "c.pem", tmp_path / "k.pem")
fw = await asyncio.start_server(lambda r, w: w.close(), "127.0.0.1", 0, ssl=ctx)
fw_port = fw.sockets[0].getsockname()[1]
proxy, proxy_port = await _proxy()
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{proxy_port}")
svc._active_network = ipaddress.ip_network("127.0.0.0/8")
try:
info = await tls_pin_service.probe(f"https://127.0.0.1:{fw_port}")
assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
await tls_pin_service.verify_matches(f"https://127.0.0.1:{fw_port}", info["pem"])
other, _ = _self_signed("someone-else")
with pytest.raises(tls_pin_service.TlsPinError):
await tls_pin_service.verify_matches(f"https://127.0.0.1:{fw_port}", other.public_bytes(serialization.Encoding.PEM).decode())
# The proxy refusing (target outside the overlay) surfaces as a connection error, not a hang.
svc._active_network = ipaddress.ip_network("127.0.0.0/8")
hub_only = await _proxy(subnet="10.99.0.0/24")
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{hub_only[1]}")
with pytest.raises(tls_pin_service.TlsPinError):
await tls_pin_service.probe(f"https://127.0.0.1:{fw_port}")
hub_only[0].close()
finally:
proxy.close(); fw.close()
@pytest.mark.asyncio
async def test_a_dead_proxy_is_a_clean_error(monkeypatch):
from app.services import tls_pin_service
monkeypatch.setattr(settings, "overlay_proxy_url", f"http://127.0.0.1:{socket_free_port()}")
svc._active_network = ipaddress.ip_network("10.99.0.0/24")
with pytest.raises(tls_pin_service.TlsPinError):
await tls_pin_service.probe("https://10.99.0.2")
+213
View File
@@ -0,0 +1,213 @@
"""Site variables: {{NAME}} placeholders filled per host, failing closed when a value is missing."""
import json
import httpx
import pytest
from app.models.host import OpnsenseHost
from app.models.host_variable import HostVariable
from app.services import variables_service as vs
from tests.test_aliases import _alias_payload
from tests.test_port_forward import _pf_payload
from tests.test_rules import _create_host, _rule_payload
def _host(name="fw", **values):
host = OpnsenseHost(name=name, base_url="https://x", api_key="k")
host.variables = [HostVariable(name=n, value=v) for n, v in values.items()]
return host
# ── unit ─────────────────────────────────────────────────────────────────
def test_placeholders_are_found_and_resolved():
payload = {"source_net": "{{LAN_NET}}", "destination_net": "any", "description": "ssh to {{ SITE }} ({{LAN_NET}})", "enabled": "1", "port": 22}
assert vs.names_in_payload(payload) == {"LAN_NET", "SITE"}
resolved = vs.resolve(payload, _host(LAN_NET="192.168.7.0/24", SITE="Nord"))
assert resolved == {"source_net": "192.168.7.0/24", "destination_net": "any", "description": "ssh to Nord (192.168.7.0/24)", "enabled": "1", "port": 22}
assert payload["source_net"] == "{{LAN_NET}}" # the original is untouched
def test_a_missing_value_raises_naming_host_and_variable():
with pytest.raises(vs.MissingVariables) as exc:
vs.resolve({"a": "{{A}}", "b": "{{B}}"}, _host("fw-nord", A="1"))
assert exc.value.missing == {"fw-nord": ["B"]}
@pytest.mark.parametrize("text", ["lowercase {{lan}}", "{LAN_NET}", "{{ }}", "{{1ABC}}", "$LAN_NET", "{{LAN-NET}}"])
def test_things_that_are_not_placeholders_are_left_alone(text):
assert vs.names_in(text) == set()
assert vs.resolve({"x": text}, _host())["x"] == text
def test_values_are_inserted_literally_not_interpreted():
out = vs.resolve({"x": "{{A}}"}, _host(A="\\1 $& {{B}}"))
assert out["x"] == "\\1 $& {{B}}" # no regex-group or second-level expansion
def test_value_and_name_validation():
assert vs.NAME_RE.match("LAN_NET2") and not vs.NAME_RE.match("lan") and not vs.NAME_RE.match("2LAN") and not vs.NAME_RE.match("A" * 41)
for ok in ("192.168.7.0/24", "fw-nord.example.com", "10.0.0.1,10.0.0.2", "Filiale Nord", "a@b+c"):
assert vs.VALUE_RE.match(ok)
for bad in ("", "{{X}}", "a\nb", 'a"b', "a;b", "a" * 256, "<x>"):
assert not vs.VALUE_RE.match(bad)
# ── API ──────────────────────────────────────────────────────────────────
async def _mock_hosts(respx_mock, *bases):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
# multi-host deploys compare what the interface name means on every target
respx_mock.get(url__regex=r".*/firewall/filter/getRule").mock(
return_value=httpx.Response(200, json={"rule": {"interface": {"lan": {"value": "LAN", "selected": 0}}}}))
respx_mock.get(url__regex=r".*/firewall/alias/searchItem.*").mock(return_value=httpx.Response(200, json={"rows": []}))
def _bodies(route):
return [json.loads(call.request.content) for call in route.calls]
@pytest.mark.asyncio
async def test_variables_api_roundtrip_validation_and_usage_discovery(client, respx_mock):
await _mock_hosts(respx_mock)
h1 = await _create_host(client, "fw1", "https://fw1.test")
h2 = await _create_host(client, "fw2", "https://fw2.test")
assert (await client.get(f"/api/hosts/{h1['id']}/variables")).json() == {"variables": []}
await client.post("/api/rules", json=_rule_payload([h1["id"]], description="ssh {{SITE}}", source_net="{{LAN_NET}}"))
await client.post("/api/aliases", json=_alias_payload([h2["id"]], content="{{DNS_IP}}"))
# fw1 uses LAN_NET and SITE, nothing yet defined -> both missing; fw2's alias is not fw1's business.
got = (await client.get(f"/api/hosts/{h1['id']}/variables")).json()["variables"]
assert [(v["name"], v["value"], v["missing"]) for v in got] == [("LAN_NET", None, True), ("SITE", None, True)]
assert got[0]["used_by"] == [{"kind": "rule", "id": got[0]["used_by"][0]["id"], "label": "ssh {{SITE}}"}]
put = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24", "EXTRA": "x"}})
assert put.status_code == 200, put.text
by_name = {v["name"]: v for v in put.json()["variables"]}
assert by_name["LAN_NET"]["missing"] is False and by_name["SITE"]["missing"] is True and by_name["EXTRA"]["used_by"] == []
# PUT replaces the whole set.
put = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"SITE": "Nord"}})
assert {v["name"] for v in put.json()["variables"]} == {"LAN_NET", "SITE"} # LAN_NET only listed because the rule still uses it
assert next(v for v in put.json()["variables"] if v["name"] == "LAN_NET")["value"] is None
for bad in ({"lan": "1"}, {"OK": "bad;value"}, {"OK": ""}, {"OK": "{{X}}"}):
resp = await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": bad})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "VARIABLE_INVALID", bad
import uuid
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/variables")).status_code == 404
@pytest.mark.asyncio
async def test_each_host_gets_its_own_values_in_preview_and_deploy(client, respx_mock):
await _mock_hosts(respx_mock)
h1 = await _create_host(client, "fw1", "https://fw1.test")
h2 = await _create_host(client, "fw2", "https://fw2.test")
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24", "SITE": "Nord"}})
await client.put(f"/api/hosts/{h2['id']}/variables", json={"variables": {"LAN_NET": "10.20.0.0/16", "SITE": "Sued"}})
rule = (await client.post("/api/rules", json=_rule_payload([h1["id"], h2["id"]], description="ssh {{SITE}}", source_net="{{LAN_NET}}"))).json()
preview = (await client.get(f"/api/rules/{rule['id']}/preview")).json()
rows = {r["host_name"]: r for r in preview["hosts"]}
after = lambda r: {c["field"]: c["after"] for c in r["changes"]} # noqa: E731
assert after(rows["fw1"])["source_net"] == "192.168.7.0/24" and after(rows["fw1"])["description"] == "ssh Nord"
assert after(rows["fw2"])["source_net"] == "10.20.0.0/16" and after(rows["fw2"])["description"] == "ssh Sued"
assert preview["can_deploy"] is True
routes = {b: respx_mock.post(f"https://{b}.test/api/firewall/filter/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": f"u-{b}"})) for b in ("fw1", "fw2")}
for b in ("fw1", "fw2"):
respx_mock.post(f"https://{b}.test/api/firewall/filter/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
resp = await client.post(f"/api/rules/{rule['id']}/deploy", json={"preview_token": preview["token"]})
assert resp.status_code == 200, resp.text
sent = {b: _bodies(r)[0]["rule"] for b, r in routes.items()}
assert (sent["fw1"]["source_net"], sent["fw1"]["description"]) == ("192.168.7.0/24", "ssh Nord")
assert (sent["fw2"]["source_net"], sent["fw2"]["description"]) == ("10.20.0.0/16", "ssh Sued")
assert "{{" not in json.dumps(sent)
# The centrally stored rule keeps its placeholders; the deployment stores what was really sent.
assert (await client.get("/api/rules")).json()[0]["source_net"] == "{{LAN_NET}}"
# Changing a host's value shows up as a change for that host only.
await client.put(f"/api/hosts/{h2['id']}/variables", json={"variables": {"LAN_NET": "10.30.0.0/16", "SITE": "Sued"}})
for r in routes.values():
pass
await client.post(f"/api/rules/{rule['id']}/confirm")
again = {r["host_name"]: r["action"] for r in (await client.get(f"/api/rules/{rule['id']}/preview")).json()["hosts"]}
assert again == {"fw1": "unchanged", "fw2": "update"}
@pytest.mark.asyncio
async def test_a_missing_value_blocks_the_whole_deploy_before_anything_is_written(client, respx_mock):
await _mock_hosts(respx_mock)
h1 = await _create_host(client, "fw1", "https://fw1.test")
h2 = await _create_host(client, "fw2", "https://fw2.test")
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"LAN_NET": "192.168.7.0/24"}})
rule = (await client.post("/api/rules", json=_rule_payload([h1["id"], h2["id"]], source_net="{{LAN_NET}}"))).json()
preview = (await client.get(f"/api/rules/{rule['id']}/preview")).json()
rows = {r["host_name"]: r for r in preview["hosts"]}
assert rows["fw2"]["action"] == "missing_variable" and "LAN_NET" in rows["fw2"]["message"] and rows["fw1"]["action"] == "create"
assert preview["can_deploy"] is False and any("Variable" in w for w in preview["warnings"])
# respx would fail on any unmocked firewall call: nothing may be sent, not even to the host that has its value.
resp = await client.post(f"/api/rules/{rule['id']}/deploy", json={"preview_token": preview["token"]})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "VARIABLES_MISSING"
assert "fw2: LAN_NET" in resp.json()["detail"]["params"]["details"] and "fw1" not in resp.json()["detail"]["params"]["details"]
assert (await client.get("/api/rules")).json()[0]["deployments"] == []
@pytest.mark.asyncio
async def test_aliases_and_port_forwards_use_variables_too(client, respx_mock):
await _mock_hosts(respx_mock)
h1 = await _create_host(client, "fw1", "https://fw1.test")
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"DNS_IP": "10.9.9.9", "WEB": "10.7.0.20"}})
alias = (await client.post("/api/aliases", json=_alias_payload([h1["id"]], content="{{DNS_IP}}"))).json()
pf = (await client.post("/api/port-forward", json=_pf_payload([h1["id"]], target="{{WEB}}"))).json()
a_prev = (await client.get(f"/api/aliases/{alias['id']}/preview")).json()
assert {c["field"]: c["after"] for c in a_prev["hosts"][0]["changes"]}["content"] == "10.9.9.9"
p_prev = (await client.get(f"/api/port-forward/{pf['id']}/preview")).json()
assert {c["field"]: c["after"] for c in p_prev["hosts"][0]["changes"]}["target"] == "10.7.0.20"
await client.put(f"/api/hosts/{h1['id']}/variables", json={"variables": {"DNS_IP": "10.9.9.9"}})
p_prev = (await client.get(f"/api/port-forward/{pf['id']}/preview")).json()
assert p_prev["hosts"][0]["action"] == "missing_variable"
@pytest.mark.asyncio
async def test_workflow_state_does_not_crash_on_missing_values(client, respx_mock):
await _mock_hosts(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
await client.post("/api/rules", json=_rule_payload([host["id"]], source_net="{{NOPE}}"))
listing = (await client.get("/api/rules")).json()
assert listing[0]["workflow_state"] == "draft" and listing[0]["workflow_counts"]["changes"] == 1
@pytest.mark.asyncio
async def test_the_client_refuses_any_payload_with_an_unresolved_placeholder(client, respx_mock):
from app.models.host import OpnsenseHost as H
from app.services import opnsense_client, opnsense_secret_store
import uuid
await _mock_hosts(respx_mock)
created = await _create_host(client, "fw1", "https://fw1.test")
from app.database import SessionLocal
async with SessionLocal() as db:
host = await db.get(H, uuid.UUID(created["id"]))
with pytest.raises(opnsense_client.OpnsenseAPIError, match="Variable"):
await opnsense_client.add_rule(host, {"source_net": "{{LAN_NET}}", "action": "pass"})
with pytest.raises(opnsense_client.OpnsenseAPIError, match="Variable"):
await opnsense_client.add_rule(host, {"nested": "x {{ A_B }} y"})
@pytest.mark.asyncio
async def test_variables_follow_the_host_and_operators_may_set_them(client, respx_mock):
from tests.test_api_tokens import _login
await _mock_hosts(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
await client.put(f"/api/hosts/{host['id']}/variables", json={"variables": {"A": "1"}})
assert (await client.post("/api/users", json={"username": "varop", "password": "variables-pass-1", "role": "operator"})).status_code == 201
op = await _login("varop", "variables-pass-1")
try:
assert (await op.put(f"/api/hosts/{host['id']}/variables", json={"variables": {"A": "2"}})).status_code == 200
finally:
await op.aclose()
assert (await client.post("/api/users", json={"username": "varro", "password": "variables-pass-1", "role": "read_only"})).status_code == 201
ro = await _login("varro", "variables-pass-1")
try:
assert (await ro.get(f"/api/hosts/{host['id']}/variables")).status_code == 200
assert (await ro.put(f"/api/hosts/{host['id']}/variables", json={"variables": {}})).status_code == 403
finally:
await ro.aclose()
await client.delete(f"/api/hosts/{host['id']}") # variables go with the host
from app.database import SessionLocal
from sqlalchemy import select
async with SessionLocal() as db:
assert (await db.scalars(select(HostVariable))).all() == []
+26
View File
@@ -109,6 +109,7 @@ services:
ACME_SECRET_DIR: /data/acme-secrets ACME_SECRET_DIR: /data/acme-secrets
TOTP_SECRET_DIR: /data/totp-secrets TOTP_SECRET_DIR: /data/totp-secrets
NOTIFICATION_SECRET_DIR: /data/notification-secrets NOTIFICATION_SECRET_DIR: /data/notification-secrets
OVERLAY_DIR: /data/overlay
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-} FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-}
@@ -132,6 +133,8 @@ services:
- acme_secrets:/data/acme-secrets - acme_secrets:/data/acme-secrets
- totp_secrets:/data/totp-secrets - totp_secrets:/data/totp-secrets
- notification_secrets:/data/notification-secrets - notification_secrets:/data/notification-secrets
# Management-Overlay: Konfiguration für den wg-hub und dessen Status.
- overlay_state:/data/overlay
# Gleiches Volume wie der postgres-backup-Service: hier landet das # Gleiches Volume wie der postgres-backup-Service: hier landet das
# automatische Sicherheits-Backup vor einer Schema-Migration. # automatische Sicherheits-Backup vor einer Schema-Migration.
- postgres_backups:/backups - postgres_backups:/backups
@@ -200,6 +203,28 @@ services:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
# ── Optional: Management-Overlay (WireGuard-Hub) ─────────────────────
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=overlay in
# .env und `docker compose up -d`. Damit erreicht OPNexus auch Firewalls hinter
# NAT/CGNAT: sie wählen sich per WireGuard hier ein (UDP-Port ${OVERLAY_PORT:-51820}
# muss vom Internet erreichbar sein). Gleiches Image wie das Backend, mit
# NET_ADMIN nur für die WireGuard-Schnittstelle. Einrichtung: Handbuch,
# "Firewalls hinter NAT".
wg-hub:
image: ${OPNEXUS_IMAGE_REPO:-local}/opnexus-backend:${OPNEXUS_VERSION:-latest}
profiles: ["overlay"]
restart: unless-stopped
logging: *default-logging
command: ["python", "-m", "app.overlay_hub"]
cap_add:
- NET_ADMIN
environment:
OVERLAY_DIR: /data/overlay
volumes:
- overlay_state:/data/overlay
ports:
- "${OVERLAY_PORT:-51820}:51820/udp"
# ── Optional: Updates aus dem Webinterface ────────────────────────── # ── Optional: Updates aus dem Webinterface ──────────────────────────
# Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=updater in # Standardmäßig NICHT gestartet. Aktivieren mit COMPOSE_PROFILES=updater in
# .env und `docker compose up -d`. Dies ist der einzige Container mit # .env und `docker compose up -d`. Dies ist der einzige Container mit
@@ -239,5 +264,6 @@ volumes:
acme_secrets: acme_secrets:
totp_secrets: totp_secrets:
notification_secrets: notification_secrets:
overlay_state:
caddy_data: caddy_data:
caddy_config: caddy_config:
+166 -6
View File
@@ -215,10 +215,47 @@ Alle Einstellungen stehen in der `.env` neben der `docker-compose.yml`. Nach ein
## Benutzer, Rollen und Anmeldung ## Benutzer, Rollen und Anmeldung
**Rollen.** Es gibt zwei: `admin` (voller Zugriff, inklusive Benutzerverwaltung) und **Rollen.** Es gibt drei:
`read_only`. Ein Nur-Lesen-Benutzer sieht alles, kann aber nichts ändern: alle
Schreibaktionen sind in der Oberfläche gesperrt und werden vom Backend mit 403 | Rolle | Darf |
abgelehnt. Nur das eigene Passwort sowie Farbschema und Sprache bleiben änderbar. |---|---|
| `admin` | alles, inklusive Benutzer, Einstellungen, Hosts anlegen/löschen, OPNexus-Update, Roh-Download von Konfigurationssicherungen |
| `operator` | ausrollen und betreiben: Regeln, Aliase, NAT, VPN, Zertifikate, Wartung, Sicherungen, Sicherheitsprüfung. **Nicht:** Benutzer, Einstellungen (Benachrichtigungen, Backups, Audit-Export, Logs), Hosts anlegen/ändern/löschen, OPNexus-Update, Roh-Backups herunterladen |
| `read_only` | sieht alles, ändert nichts |
Ein Nur-Lesen-Benutzer kann nichts ändern: alle Schreibaktionen sind in der
Oberfläche gesperrt und werden vom Backend mit 403 abgelehnt. Ein Operator bekommt bei
den Admin-Aktionen ebenfalls 403 („erfordert die Rolle Admin“); der Versuch steht im
Audit. Nur das eigene Passwort sowie Farbschema und Sprache bleiben für jede Rolle änderbar.
**Single Sign-on (OIDC).** Unter Einstellungen › Single Sign-on (OIDC) verbindest du einen
Identitätsanbieter (Authentik, Keycloak, Entra ID, Google, …). Auf der Anmeldeseite erscheint dann ein
zusätzlicher Knopf („Mit … anmelden“). Du brauchst beim Anbieter einen Client mit dem Ablauf
*Authorization Code* (OPNexus nutzt zusätzlich PKCE) und trägst dort die angezeigte
Weiterleitungs-URI ein (`https://<dein-opnexus>/api/auth/oidc/callback`). In OPNexus gibst du
Aussteller-URL, Client-ID, Client-Geheimnis und die **Gruppennamen** an, die die Rollen Admin, Operator
und Nur-Lesen bekommen. „Anbieter testen“ prüft Discovery und Signaturschlüssel.
- **Rolle aus Gruppen, bei jeder Anmeldung:** gilt die höchste passende Rolle; wer in keiner der Gruppen ist,
kommt nicht hinein (es wird auch kein Konto angelegt). Wird jemand aus der Gruppe entfernt, endet der Zugang
bei der nächsten Anmeldung (laufende Sitzungen enden nach ihrer Laufzeit; beim Anbieter gesperrte Personen
daher zusätzlich in der Benutzerliste löschen).
- **Konten:** SSO-Benutzer werden bei der ersten Anmeldung angelegt (Kennzeichen „SSO“). Rolle und Passwort
ändern sich nur beim Anbieter; die Zwei-Faktor-Anmeldung übernimmt der Anbieter. Ein SSO-Konto wird nie mit
einem lokalen Konto gleichen Namens verknüpft: bei einem Namenskonflikt bekommt es ein Suffix (`~sso`).
- **Lokale Konten bleiben:** Die Anmeldung mit Benutzername und Passwort funktioniert weiter, etwa für den
Notfall, wenn der Anbieter ausfällt.
- **Geprüft** werden Signatur (nur asymmetrische Verfahren, nie `none` oder HMAC), Aussteller, Zielgruppe, Ablauf
und Nonce des ID-Tokens; jeder Anmeldeversuch (State) gilt genau einmal und 10 Minuten. Gruppen kommen aus dem
ID-Token, ersatzweise aus dem Userinfo-Endpunkt. Aussteller und Endpunkte müssen `https://` sein. Das ist
gegen einen nachgebauten Anbieter getestet (inklusive gefälschter Token), noch nicht gegen jeden echten
Anbieter: melde, was bei deinem nicht klappt.
**Aufgabentrennung.** Einstellungen › Aufgabentrennung: Schaltest du „Operatoren dürfen Änderungen selbst
ausrollen“ aus, bereiten Operatoren Änderungen nur vor (anlegen, bearbeiten, Vorschau), und **nur ein Admin
rollt aus**: ausrollen, ein Deployment entfernen oder zurückrollen, Wartung ausführen oder planen,
Netzwerkänderungen anwenden. Die Ablehnung („nur ein Administrator“) steht im Audit. Das ist ein Zwei-Personen-Schritt
(Vorbereiten und Ausrollen durch verschiedene Personen), keine Freigabe-Warteschlange.
**Benutzerverwaltung** (System › Benutzer, nur Admins): Benutzer anlegen, Rolle **Benutzerverwaltung** (System › Benutzer, nur Admins): Benutzer anlegen, Rolle
ändern, Passwort zurücksetzen, Zwei-Faktor eines anderen Benutzers zurücksetzen, ändern, Passwort zurücksetzen, Zwei-Faktor eines anderen Benutzers zurücksetzen,
@@ -303,6 +340,47 @@ ist der Schritt, der das Festsetzen absichert.
zuerst die Deployments (Regel löschen oder früheres Ziel unter „Prüfen & Push“ zuerst die Deployments (Regel löschen oder früheres Ziel unter „Prüfen & Push“
bereinigen). bereinigen).
### Firewalls hinter NAT (Management-Tunnel)
OPNexus muss die API einer Firewall erreichen können. Hat die Firewall keine öffentliche Adresse (hinter NAT,
CGNAT, Mobilfunk-Router), baust du mit dem **Management-Tunnel** (System › Management-Tunnel) einen
WireGuard-Hub auf dem OPNexus-Server auf: Die Firewall wählt sich **nach außen** dort ein, OPNexus erreicht
ihre API durch den Tunnel. An der Firewall muss nichts aus dem Internet erreichbar sein. Der Tunnel ist
nur für die Verwaltung gedacht; Nutzverkehr läuft nicht darüber.
**Voraussetzungen:** der Hub ist ein optionaler Container. In `.env` `COMPOSE_PROFILES=overlay` setzen (bei
mehreren Profilen kommagetrennt), `docker compose up -d`, und der UDP-Port `OVERLAY_PORT` (Standard 51820)
des Servers muss aus dem Internet erreichbar sein (Port-Weiterleitung am Router des OPNexus-Servers). Der Hub
läuft mit dem Image des Backends und braucht nur die Berechtigung `NET_ADMIN` für die WireGuard-Schnittstelle;
das Linux-Kernelmodul `wireguard` muss auf dem Server vorhanden sein (jeder aktuelle Kernel).
**Einrichten:** Im Management-Tunnel einschalten, die öffentliche Adresse (DNS-Name oder IP) des Servers
eintragen, speichern. Dann pro Firewall **„Firewall hinzufügen“**: OPNexus vergibt eine Tunnel-Adresse (z. B.
`10.99.0.2`) und einen Pre-Shared Key und zeigt eine Anleitung mit kopierbaren Werten:
1. In OPNsense (VPN › WireGuard) eine **lokale Instanz** anlegen, Schlüsselpaar erzeugen lassen, die
Tunnel-Adresse eintragen.
2. Den **Hub als Gegenstelle** eintragen (öffentlicher Schlüssel, Pre-Shared Key, Endpunkt, erlaubte IP
`10.99.0.1/32`, Keepalive 25).
3. Eine **Firewall-Regel** auf der WireGuard-Schnittstellengruppe: TCP von der Hub-Adresse zu „Diese
Firewall“, Port der Weboberfläche (443).
4. Den **öffentlichen Schlüssel** der neuen Instanz bei „Schlüssel / Host“ in OPNexus eintragen.
5. Die Firewall wie gewohnt als **Host** anlegen, aber mit der Tunnel-Adresse (`https://10.99.0.2`). Der
Status im Management-Tunnel wechselt auf „verbunden“.
Ab dann funktioniert alles wie bei jeder anderen Firewall (Regeln, Wartung, Logs, Sicherungen,
Sicherheitslage). Das Festsetzen des Zertifikats funktioniert auch durch den Tunnel.
**Sicherheit:** Der Hub nimmt von den Firewalls **keine neuen Verbindungen** an (iptables `INPUT … NEW`
verworfen), eine kompromittierte Außenstelle erreicht also weder OPNexus noch andere Außenstellen. Der
Proxy, über den OPNexus die Firewalls erreicht, ist nur im Compose-Netz erreichbar und verbindet
ausschließlich in das Tunnel-Netz. Schlüssel liegen als Dateien im Volume `overlay_state` (dort auch der
private Schlüssel des Hubs; die Vollsicherung enthält ihn). Je Firewall gibt es einen eigenen
Pre-Shared Key; „Löschen“ entfernt sie sofort aus dem Hub. Hosts im Tunnel-Netz werden nur über IP-Adressen
angesprochen (keine DNS-Namen). Nicht enthalten: Die Einrichtung auf der Firewall übernimmt OPNexus nicht
(bei einer Firewall hinter NAT kann es sie ja nicht erreichen); bei einer erreichbaren Firewall kannst du
alternativ unter VPN › WireGuard einen Tunnel mit denselben Werten verteilen.
--- ---
## Regeln sicher ausrollen ## Regeln sicher ausrollen
@@ -446,6 +524,27 @@ automatischen und konfigurierten.
- Die NAT-Drift-Erkennung vergleicht die Knoten eines Clusters und meldet - Die NAT-Drift-Erkennung vergleicht die Knoten eines Clusters und meldet
Abweichungen. Abweichungen.
### Standort-Variablen
Mit **Variablen** pflegst du ein Objekt einmal und rollst es auf Firewalls mit unterschiedlichen Netzen aus.
Schreibe `{{NAME}}` (Großbuchstaben, Ziffern und `_`, beginnend mit einem Buchstaben) in ein Feld einer
**Regel**, eines **Alias**, einer **Portweiterleitung**, einer **1:1-NAT-** oder **Outbound-NAT-Regel**, etwa als
Quelle `{{LAN_NET}}` oder als Beschreibung `Zugriff {{SITE}}`. Den Wert legst du je Firewall auf der Host-Seite
unter **Standort-Variablen** fest (`192.168.7.0/24`, `Filiale Nord`, …). Die Seite listet automatisch alle
Platzhalter, die die für diesen Host bestimmten Objekte verwenden, und zeigt, welche noch ohne Wert sind.
- **Je Host aufgelöst:** Vorschau, Ausrollen, die Änderungszähler und das Audit sehen die echten Werte des
jeweiligen Hosts; ändert sich ein Wert, erscheint nur bei diesem Host eine Änderung. Das zentral gespeicherte
Objekt behält seine Platzhalter.
- **Fehlt ein Wert, wird nichts gesendet:** Die Vorschau markiert den Host mit „Variable fehlt“, das Ausrollen
wird abgelehnt, bevor irgendein Host beschickt wurde, und als letzte Absicherung sendet OPNexus keine Anfrage
an eine Firewall, die noch einen Platzhalter enthält.
- **Werte** dürfen Buchstaben, Ziffern und `. : / , @ + - _` sowie Leerzeichen enthalten (höchstens 255 Zeichen);
sie werden wörtlich eingesetzt, nie als Muster oder weiterer Platzhalter ausgewertet.
- **Nicht** unterstützt: Platzhalter in VPN-Tunneln und in der Policy-Hierarchie (dort stehen die Werte direkt im
Objekt); Variablen für Gerätegruppen (Vorgabewerte) gibt es nicht, jeder Host hat seine eigenen. Operatoren dürfen
Variablen pflegen; ob sie auch ausrollen dürfen, regelt die [Aufgabentrennung](#benutzer-rollen-und-anmeldung).
--- ---
## Netzwerk und Host-System ## Netzwerk und Host-System
@@ -707,6 +806,28 @@ Auf der Host-Detailseite sicherst du die Konfiguration einer Firewall
(**Jetzt sichern**) und siehst die lokal gespeicherten Sicherungen. Das ist nicht (**Jetzt sichern**) und siehst die lokal gespeicherten Sicherungen. Das ist nicht
die Sicherung von OPNexus selbst, siehe [Sichern und Wiederherstellen](#sichern-und-wiederherstellen). die Sicherung von OPNexus selbst, siehe [Sichern und Wiederherstellen](#sichern-und-wiederherstellen).
**Geplant und ausgelagert** (Einstellungen › Backups): OPNexus sichert jede Firewall
automatisch (Standard: täglich) und behält die neuesten (Standard 30 je Firewall). Optional wird jede
Sicherung **auf deinem Server mit einer Passphrase verschlüsselt** (AES-256-GCM, Schlüssel per scrypt
aus der Passphrase) und auf **S3-kompatiblen Speicher** hochgeladen (AWS S3, MinIO, Backblaze B2,
Wasabi, Cloudflare R2, Hetzner Object Storage …; Endpunkt muss `https://` sein). „Ziel testen“
schreibt, liest und löscht ein Prüfobjekt. Jede Sicherung geht nur einmal hoch, gelöscht wird
offsite nie (dafür die Lifecycle-Regeln des Buckets nutzen). **Ohne die Passphrase ist eine
Offsite-Kopie unlesbar** – bewahre sie außerhalb von OPNexus auf. Entschlüsseln:
```bash
docker compose exec -T -e BACKUP_PASSPHRASE='…' backend python -m app.backup_decrypt < datei.opxbak > config.xml
```
Die Benachrichtigung „Kein aktuelles Backup“ meldet sich, wenn ein Host drei Intervalle (mindestens
6 Stunden) ohne Sicherung war, „Offsite-Backup fehlgeschlagen“ bei Problemen mit dem Ziel. Die
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele geprüft, aber noch nicht gegen einen
laufenden S3-Anbieter; „Ziel testen“ zeigt dir sofort, ob dein Anbieter funktioniert.
**Änderungen ansehen:** Der Knopf „Änderungen“ neben einer Sicherung zeigt, was sich gegenüber der
vorherigen geändert hat. Geheimnisse (Passwörter, Schlüssel, Tokens, Zertifikate) erscheinen nur als
Hash: ein geändertes Geheimnis zeigt sich als geänderter Hash, der Wert verlässt den Server nie.
**Wiederherstellen** prüft eine gespeicherte Sicherung als Vorschau (Format, **Wiederherstellen** prüft eine gespeicherte Sicherung als Vorschau (Format,
Metadaten, geheime Felder werden unkenntlich gemacht) und übergibt dann an das Metadaten, geheime Felder werden unkenntlich gemacht) und übergibt dann an das
OPNsense-Webinterface. **OPNexus schreibt dabei nichts auf die Firewall.** Du OPNsense-Webinterface. **OPNexus schreibt dabei nichts auf die Firewall.** Du
@@ -719,6 +840,17 @@ Sicherung) zeigt die Vorschau eine Warnung.
## Überwachung: Dashboard, Logs, Audit, Benachrichtigungen ## Überwachung: Dashboard, Logs, Audit, Benachrichtigungen
### Regelnutzung
Auf der Seite **Sicherheitslage** zeigt die Tabelle „Regelnutzung“, wann jede Regel
zuletzt Verkehr getroffen hat. OPNexus liest dafür die Paketzähler der Firewall. Weil diese
bei jeder Änderung und jedem Neustart von vorn beginnen, merkt sich OPNexus den Zeitpunkt, an dem
ein Zähler zuletzt gewachsen ist (Auflösung: eine Prüfung, also bis zu 6 Stunden). Eine Regel gilt
als **ungenutzt**, wenn OPNexus sie mindestens 30 Tage beobachtet hat, ohne einen Treffer zu sehen;
bis dahin steht sie auf „Daten werden gesammelt“, damit eine neue Installation keinen Fehlalarm
auslöst. Nur Regeln aus dem Regel-Editor bzw. der API werden erfasst (die automatischen Regeln von
OPNsense nicht). Ungenutzte Regeln fließen als Befund „Regeln in Benutzung“ in die Sicherheitslage ein.
### Dashboard ### Dashboard
Die Startseite (**Übersicht › Dashboard**) zeigt: offene Aktionen (ausstehende Die Startseite (**Übersicht › Dashboard**) zeigt: offene Aktionen (ausstehende
@@ -762,6 +894,17 @@ vorhanden. Einträge bleiben auch nach dem Löschen einer Regel oder eines Hosts
erhalten; ein Datenbank-Trigger verhindert Änderungen und Löschen. Passwörter, erhalten; ein Datenbank-Trigger verhindert Änderungen und Löschen. Passwörter,
API-Secrets und Konfigurationsdateien werden nicht protokolliert. API-Secrets und Konfigurationsdateien werden nicht protokolliert.
### Audit-Export
Unter **Einstellungen › Audit-Export** leitest du das Audit (wer hat wann was mit welchem
Ergebnis geändert) an einen **Syslog-Server** (RFC 5424 über UDP, TCP oder TLS; Facility local0,
Schweregrad „warning“ bei abgelehnten oder fehlgeschlagenen Aktionen) und/oder einen **Webhook**
(JSON-Stapel, optional mit HMAC-SHA256-Signatur im Header `X-OPNexus-Signature`) weiter, damit es
auch außerhalb von OPNexus vorliegt. Gesendet werden nur neue Ereignisse ab dem Einschalten; der
Inhalt von Firewall-Objekten (Vorher-/Nachher-Daten) ist nie dabei. Die Zustellung ist „mindestens
einmal“: scheitert das Ziel, versucht OPNexus dieselben Ereignisse erneut, jedes trägt seine ID zum
Entdoppeln. Der Status der letzten Zustellung steht in den Einstellungen.
### Benachrichtigungen ### Benachrichtigungen
Einstellungen › **Benachrichtigungen**. Kanäle: Einstellungen › **Benachrichtigungen**. Kanäle:
@@ -813,6 +956,13 @@ bleibt eine Überschneidung über Aliase unentdeckt, aber es gibt keine falschen
Befunde. Beide Regelformate (OPNsense 26.x und ältere Installationen) werden gelesen. Befunde. Beide Regelformate (OPNsense 26.x und ältere Installationen) werden gelesen.
Die Werte stehen auch als Prometheus-Metriken zur Verfügung (siehe unten). Die Werte stehen auch als Prometheus-Metriken zur Verfügung (siehe unten).
Das **Dashboard** zeigt die Firewalls mit dem niedrigsten Wert. Der Knopf **Bericht** erzeugt eine druckbare Übersicht
aller geprüften Firewalls mit ihren offenen Befunden und Hinweisen zur Behebung; im Browser „Drucken / als PDF speichern“
wählen. In den Einstellungen › Benachrichtigungen lässt sich **„Kritischer oder hoher Sicherheitsbefund“**
einschalten (standardmäßig aus, damit ein bestehender Bestand nicht plötzlich alle alten Befunde auf einmal meldet): Pro
Firewall und Prüfung kommt eine Meldung, sobald sie auftritt, und eine „behoben“-Meldung, wenn sie nach der nächsten
Prüfung verschwunden ist.
### Über OPNexus ### Über OPNexus
Ein Klick auf das Logo öffnet **About**: alle Hosts mit Status, CARP-Rolle, Cluster, Ein Klick auf das Logo öffnet **About**: alle Hosts mit Status, CARP-Rolle, Cluster,
@@ -920,12 +1070,22 @@ mit den Rechten des Tokens: ein „Lesen“-Token sieht auch hier keine Geheimni
entsteht vor jeder Schema-Migration ein Dump (die letzten 5 bleiben). entsteht vor jeder Schema-Migration ein Dump (die letzten 5 bleiben).
**Manuell:** `./opnexus backup` schreibt sofort `manual-<zeit>.dump`. **Manuell:** `./opnexus backup` schreibt sofort `manual-<zeit>.dump`.
**Vollsicherung (empfohlen):** Der Dump allein reicht nicht, um einen OPNexus-Server neu aufzubauen,
denn die Geheimnisse liegen in eigenen Volumes. `./opnexus backup --full [DATEI]` packt Datenbank
und alle Geheimnis-Volumes (Firewall-Zugänge, Zwei-Faktor, VPN- und Zertifikatsschlüssel,
Konfigurationssicherungen) in **eine mit Passphrase verschlüsselte Datei** (`.opxfull`). Die Passphrase
wird abgefragt (oder `OPNEXUS_BACKUP_PASSPHRASE`). Auf einer frischen Installation (gleiche oder
neuere Version) spielt `./opnexus restore DATEI` alles zurück: Es prüft die Datei, fragt nach, stoppt die
Dienste, legt eine Sicherheitskopie des jetzigen Zustands an, spielt Datenbank und Volumes ein und
startet neu. Nicht enthalten: Log-Erfassung, Messwerte und `caddy_data` (die interne CA: nach einer
Wiederherstellung auf neuer Hardware importierst du das CA-Zertifikat im Browser erneut).
Datei und Passphrase getrennt vom Server aufbewahren.
**Was nicht im Dump ist:** **Was nicht im Dump ist:**
- Die Tabellen der **Log-Erfassung** und der **Systemauslastung** (nur das Schema). - Die Tabellen der **Log-Erfassung** und der **Systemauslastung** (nur das Schema).
Nach einer Wiederherstellung sind sie leer und füllen sich aus den Firewalls. Nach einer Wiederherstellung sind sie leer und füllen sich aus den Firewalls.
- **Geheimnisse** (API-Secrets, VPN- und Zertifikatsschlüssel, Zwei-Faktor): sie - **Geheimnisse** (API-Secrets, VPN- und Zertifikatsschlüssel, Zwei-Faktor): sie
liegen in den eigenen Volumes. Für eine vollständige Sicherung auch diese Volumes liegen in den eigenen Volumes. Für eine vollständige Sicherung `./opnexus backup --full` (siehe oben) verwenden.
und `caddy_data` sichern.
**Wiederherstellen** in eine **leere** Datenbank (nicht mit `--clean` in eine **Wiederherstellen** in eine **leere** Datenbank (nicht mit `--clean` in eine
bestehende): bestehende):
+332
View File
@@ -0,0 +1,332 @@
# Opnexus security review — 2026-10-07
## Instructions for Claude Code
This is a remediation handoff, not a record of applied fixes. Work from the finding IDs below. Recheck each finding against the current source before modifying it, preserve unrelated user changes, and add the specified regression coverage. Address SEC-01 through SEC-05 first. Do not deploy to real firewalls to reproduce these issues: use mocked OPNsense responses and a disposable test database. Never include actual credentials, backups, or private keys in tests, logs, or commits.
The review covers commit `ee7b35953a326e848d0bc9e5b19bf7755f78a937` plus the working tree as inspected. Existing user changes were present in `frontend/app/aliases/page.tsx`, `frontend/app/rules/page.tsx`, and the OneToOnePanel, OutboundNatPanel, and PortForwardPanel components. Those files were not modified by this review. All locations below are repository-relative; line references describe this snapshot and can move during remediation.
## Scope and evidence
Reviewed the authentication and authorization boundary; account/password/TOTP flows; all router registrations; firewall rule, alias, NAT, hierarchy, VPN, certificate and ACME operation paths; maintenance and restore controls; secret stores; outbound HTTP and SMTP; frontend API calls and rendering sinks; website worker and scripts; Docker/Compose/proxy configuration; migration and update scripts; CI and release configuration; dependency manifests and lockfile.
The backend inventory contains 164 Python application files and 187 route decorators. This was a broad static review with targeted tracing and isolated probes, not line-by-line proof of every possible behavior or a complete penetration test.
Evidence is in [security-review-2026-10-07](security-review-2026-10-07/):
- `probe-results.json`: 13 synthetic probes, including positive controls. These report current vulnerable behavior, not passing remediation tests.
- `reproduce.py`: reusable, mocked reproduction harness. It does not invoke application lifespan, connect to a database, or contact a firewall. Authentication, database, backup and relevant secret reads are replaced with synthetic fixtures.
- `npm-audit.json`: npm registry audit of the frontend lockfile, reporting 12 affected package entries (10 high, 2 moderate). Dependency-chain entries are not separate exploitable vulnerabilities.
- `pip-audit.json`: resolved requirements audit reporting no known vulnerabilities. This is not a scan of every installed production package or container OS package.
- `route-inventory.tsv`: route source locations, methods, local paths and function names. Apply router prefixes from `main.py` and router declarations to obtain public paths.
- `source-manifest.json`: hashes of tracked source/deployment/website files for identifying the reviewed snapshot. A hash entry does not mean every line was manually examined.
The probes were rerun in a disposable backend-image container with no network and no volume mounts. Source hashes of 12 central authentication, routing, schema and SMTP modules were also compared with the existing backend container and matched. Runtime read-only checks found the existing backend and frontend processes running as UID 0 and the Compose PostgreSQL user marked as a superuser. No real backup contents or secret files were read, no live firewall operations were performed, and no application database rows were changed.
Reproduce using an image built from the reviewed source:
```sh
docker run --rm -i --network none --entrypoint python \
local/opnexus-backend:latest - \
< docs/security-review-2026-10-07/reproduce.py
```
Run the harness only against this snapshot or after reviewing changes to the functions it mocks. The standalone harness has no pytest fixtures and does not drop/create tables. The normal backend pytest suite has database setup/teardown fixtures; it was not run as part of this report. No load tests, exploit payloads for dependency vulnerabilities, browser exploitation, image vulnerability scan, full git-history secret scan, or real-firewall integration tests were performed.
## Findings and priority
Severity is contextual to a firewall-fleet management product. High findings can expose credentials, preserve unauthorized administrative access, or defeat safeguards for live firewall changes. Medium findings require more prerequisites or concern integrity, monitoring or supply-chain exposure. Conditional findings state the necessary deployment conditions. No anonymous remote-code-execution exploit was demonstrated.
| ID | Severity | Finding | Evidence level |
|---|---|---|---|
| SEC-01 | High | Read-only users can download secret-bearing firewall backups | Mocked HTTP reproduction |
| SEC-02 | High | Password changes/resets do not revoke all account sessions | Source + mocked reset |
| SEC-03 | High | SMTP TLS does not verify certificates or hostname | Source + runtime library inspection |
| SEC-04 | High | Ambiguous/live deployment failures disable automatic rollback | Source + mocked deploy |
| SEC-05 | High, conditional | HTTP firewall URLs send API credentials without TLS | Schema probe + client source |
| SEC-06 | Medium | Concurrent TOTP/recovery-code consumption is not atomic | Concurrent mocked reproduction |
| SEC-07 | Medium | Login throttle leaks records and password verification blocks the event loop | Source + expiration probe |
| SEC-08 | Medium, conditional | Raw upstream errors reach readers and persisted records | Source + synthetic error probe |
| SEC-09 | Medium | Host deletion omits VPN/certificate/ACME deployment guards | Source tracing |
| SEC-10 | Medium | Security-sensitive operations are absent from durable audit history | Source tracing |
| SEC-11 | Medium | Notification secrets are ephemeral and missing signing keys fail open | Compose + source tracing |
| SEC-12 | Medium, reachability unverified | Known vulnerable frontend dependencies remain in the lockfile | Registry audit + advisory verification |
| SEC-13 | Medium | MFA self-service endpoints lack attempt limits | Source tracing |
| SEC-14 | Medium | VPN approval can be omitted and tokens do not bind secret rotations | Source + synthetic token probe |
| SEC-15 | Low | Validation errors echo submitted secret values | Mocked HTTP reproduction |
| SEC-16 | Medium, conditional | Read-only notification settings expose capability-bearing webhook URLs | Schema + route tracing |
### SEC-01 — Read-only backup access exposes firewall secrets
**Locations:** `backend/app/auth_middleware.py:55-58`; `backend/app/routers/hosts.py:242-258`; `backend/app/services/backup_store.py:48-55`; `backend/app/services/opnsense_client.py`, `download_backup()`.
**Cause:** The read-only role is implemented as “allow every GET.” Backup listing and download do not enforce a separate permission. Download returns the original snapshot bytes, not the redacted restore manifest. The normal snapshot flow downloads full OPNsense `config.xml` revisions.
**Prerequisites/impact:** Any valid read-only session and an existing backup. Full configurations can contain password hashes, VPN PSKs, private keys and other credentials, depending on the firewall configuration. Exposure can enable offline password attacks and access outside Opnexus. No anonymous access was found. The role's documented allowance for all GETs explains the behavior but does not make secret export least-privileged.
**Reproduce:** Create a synthetic firewall backup fixture. Authenticate as read-only; list `/api/hosts/{host_id}/backups`, then GET `/api/hosts/{host_id}/backups/{backup_id}`. The probe returned HTTP 200 with a synthetic secret marker; a read-only POST control returned 403.
**Fix:** Add server-enforced admin-only or explicit `export_firewall_secrets` authorization for raw downloads, consistently for GET and HEAD. Keep any reader-visible backup metadata separate from export permission. Update the frontend accordingly. Audit successful and denied secret exports without storing their contents.
**Acceptance:** Reader gets 403 for raw download/HEAD, admin gets the exact synthetic bytes, anonymous gets 401. Ordinary reader pages continue working. A path traversal string cannot retrieve another host's backup.
### SEC-02 — Password rotation leaves compromised sessions valid
**Locations:** `backend/app/routers/auth.py:221-241`; `backend/app/routers/users.py:71-78`; `backend/app/services/auth_service.py:151-194`; `backend/app/config.py`, `session_lifetime`.
**Cause:** Self-service password change updates the hash and revokes only the caller's token before creating a replacement. Admin password reset updates only the hash. Session resolution uses token hash, expiry and user existence; it does not check a credential generation. Sessions can remain valid for the configured 30-day default lifetime. MFA enable/disable/reset likewise do not invalidate existing sessions.
**Prerequisites/impact:** Attacker already possesses a session. Changing or administratively resetting the password does not reliably contain the incident; the old session can continue reading secrets or changing firewall policy according to the current account role. This does not let an expired or unknown token authenticate.
**Reproduce:** Log in twice in a disposable database. Change the password in session A or reset it through another admin. Call `/api/auth/me` and a protected route using session B. Source predicts continued access; the mocked reset probe recorded one commit and zero revocation SQL statements. Full two-session database reproduction was not performed.
**Fix:** Revoke every target-account session atomically with password reset/change; for self-service change issue a replacement only after revocation. Consider a credential/session generation for robust concurrency. Define explicit MFA-change session policy, and require reauthentication where appropriate. Do not rely on password hash comparisons at request time.
**Acceptance:** All preexisting tokens fail on protected routes after reset/change. A replacement self-service session works. Failure rolls back both credential change and revocation. A concurrent session-creation attempt cannot bypass the chosen revocation/generation semantics.
### SEC-03 — SMTP encryption lacks peer authentication
**Locations:** `backend/app/services/notification_delivery.py:82-102`, particularly SMTP_SSL construction at line 93 and `client.starttls()` at line 98.
**Cause:** Both TLS modes omit an explicit verifying SSL context. Inspection of the deployed Python 3.12 standard library showed both falling back to `ssl._create_stdlib_context()`, with `verify_mode=0` (`CERT_NONE`) and `check_hostname=False`.
**Prerequisites/impact:** An active network attacker, malicious DNS/route or incorrectly configured SMTP endpoint. The attacker can impersonate the mail server and obtain SMTP credentials and operational notification content even when the UI selects `ssl` or `starttls`. Mode `none` intentionally has no TLS and should not be confused with this defect.
**Fix:** Pass `ssl.create_default_context()` to SMTP_SSL and starttls. Support an explicit custom trust store if needed, without silently disabling verification. Preserve STARTTLS fail-closed behavior when unsupported. Reject authenticated plaintext SMTP by default or require an explicit, documented insecure override.
**Acceptance:** Both modes reject self-signed/untrusted certificates and hostname mismatches; a trusted, matching local test certificate succeeds. Assert the passed context has CERT_REQUIRED and hostname checks. On handshake failure `login()` and `send_message()` must not run.
**Reference:** [Python SMTP TLS context documentation](https://docs.python.org/3.12/library/smtplib.html). The vulnerability conclusion also relies on the inspected runtime implementation, not merely the documentation.
### SEC-04 — Rollback is dropped precisely when a deployment outcome is uncertain
**Locations:** `backend/app/services/rule_deploy_service.py:56-137,154-172`; corresponding `_deploy_to_host()` and post-deploy hierarchy handling in `alias_deploy_service.py`, `port_forward_deploy_service.py`, `one_to_one_deploy_service.py`, `outbound_nat_deploy_service.py`; `policy_hierarchy_deploy_service.py:220-278`.
**Cause:** The API may accept a rule write and apply it before the response is lost. On apply error, the code records `status="error"` and no revert deadline. It generally captures rollback payload/deadline only after successful apply. There is a second, stronger case: after confirmed apply, hierarchy reordering failure changes `pending` to `error` and clears the deadline. The sweeper selects only expired `pending` rows. Policy hierarchy positioning failure can return before saving the new payload and rollback baseline.
**Impact:** A restrictive, permissive or misplaced live rule/NAT/alias change can remain indefinitely despite lack of confirmation. Operators expecting automatic rollback may lose connectivity or leave an unintended opening. Requires an authorized deployment and a failure/race; it is a safety-control failure, not an anonymous authorization bypass. Recovery also requires the management path to remain reachable: Opnexus cannot undo a change over a connection that has become permanently inaccessible.
**Reproduce:** Mock a successful add with a returned remote UUID, then make apply raise a timeout representing a lost response after execution. The probe returned `error`, a known remote UUID and null revert deadline. Separately mock successful apply followed by failed hierarchy reassertion; verify the worker excludes the now-live change.
**Fix:** Introduce explicit state for written/applied/unknown outcomes with durable rollback baseline and recovery scheduling. Persist intent/baseline before external mutation where possible. Do not erase the recovery obligation when apply or ordering is ambiguous. Block ordinary retry/edit paths until reconciliation. Avoid blindly retrying non-idempotent remote actions. Apply the same recovery semantics across all affected resource types.
**Acceptance:** Test failed write before mutation, successful write plus apply timeout, successful apply plus ordering failure, and process interruption between phases. Known or potentially live changes retain recovery metadata and cannot be mistaken for harmless failures. Simulate recovery worker execution without a firewall. Confirmed validation rejection before a write does not spuriously remove a remote rule.
### SEC-05 — Firewall connections accept plaintext HTTP
**Locations:** `backend/app/schemas/host.py:10-14`; `backend/app/routers/hosts.py:102-127`; `backend/app/services/opnsense_client.py:28-37`; `backend/app/services/tls_pin_service.py:84-90`.
**Cause:** `base_url` is a plain string. Create accepts `http://...`; httpx receives the API key/secret as Basic authentication. `verify_tls=True` only affects TLS validation and does not upgrade or reject HTTP. The schema probe accepted an HTTP URL while verification remained true. Setting `verify_tls=False` also deliberately allows unauthenticated HTTPS.
**Prerequisites/impact:** Admin configures an HTTP host or disables verification, then a network observer/attacker is on the path. API credentials and firewall responses are exposed or forgeable. Existing correctly pinned HTTPS hosts are not shown vulnerable by this finding.
**Fix:** Validate canonical HTTPS host URLs, rejecting userinfo, fragments, unsuitable paths and unsupported schemes. Enforce the rule in the client too, including existing database rows. If HTTP or disabled verification must remain for development, put it behind an explicit deployment-level insecure override with clear status and audit evidence. Do not ban private IPs: management of internal firewalls is core functionality.
**Acceptance:** HTTP is rejected before credentials are sent; HTTPS hostname/IP with valid pin works. Existing unsafe records cannot silently continue after migration. Tests distinguish URL scheme protection from `verify_tls` certificate protection.
### SEC-06 — TOTP and recovery codes can be used concurrently more than once
**Locations:** `backend/app/routers/auth.py:116-131`; `backend/app/services/totp_service.py:45-58,77-86`; user resolution/authentication before these calls.
**Cause:** Separate sessions load the same `totp_last_step` or recovery hash list, check locally, modify locally and commit without row locking or conditional updates. Two requests can both pass before either committed update is visible. Sequential replay tests do not cover this.
**Impact:** An attacker with the password and a captured second factor can race legitimate use, violating single-use protection. This is not a bypass with only the password and no code.
**Reproduce:** Two independent synthetic user snapshots and a barrier at commit accepted the same current TOTP twice, `[true,true]`. The same concurrent test accepted one recovery code twice. This demonstrates the check/commit race with mocks; database-level concurrent integration remains to be added.
**Fix:** Lock/reload the user row inside the consumption transaction or use an atomic conditional update that only succeeds once. Consume recovery codes atomically too. Ensure login and MFA-disable use the same primitive and avoid commits that release locks before consumption finishes.
**Acceptance:** Concurrent real-test-database requests for the same code yield exactly one acceptance. Sequential replay, drift window, remaining recovery codes and unrelated-account concurrency continue working.
### SEC-07 — Login throttling has unbounded state and synchronous expensive work
**Locations:** `backend/app/services/auth_service.py:55-60,78-139,199-233`; `backend/app/routers/auth.py:74-113`; backend Dockerfile uvicorn invocation.
**Cause:** `_records` and `_failures_since_success` have no TTL eviction or size cap. Expired attempt records remain, and arbitrary nonexistent usernames create counters. A probe created 200 bucket records for 100 synthetic sources; advancing time and checking every bucket left all 200 records and 100 username counters. Password verification is synchronous Argon2 work inside an async request handler. The limiter is per-process, per-IP/account and per-IP; it has no shared/account-wide policy across sources/processes. A correct password on an MFA account with omitted code exits without recording a failure, allowing repeated expensive checks on that path.
**Impact:** Public login requests can consume memory over time and stall the single event loop, also delaying status, deployment and rollback tasks. One IP's existing lockout reduces simple repeated attacks, so sustained abuse generally needs multiple sources/windows; multiworker deployment introduces additional independent buckets. The review did not load-test a running instance.
**Fix:** Bound/expire all throttle maps, including informational username counters. Use a shared limiter if supporting multiple workers and an account-aware policy that avoids trivially locking users out. Offload Argon2 into a bounded worker pool with admission/concurrency limits. Limit request body size before JSON parsing; field max lengths do not bound the raw HTTP body. Count appropriate incomplete/failed MFA attempts.
**Acceptance:** Expired records are removed, capacity remains bounded under many usernames, and throttles work across supported workers. Heartbeat/health requests stay responsive during bounded synthetic password verification. Missing MFA codes cannot create an unlimited costly path. Oversized chunked and Content-Length bodies are rejected before buffering.
### SEC-08 — Untrusted upstream errors cross the reader/secret boundary
**Locations:** `backend/app/services/opnsense_client.py:40-67`; `backend/app/services/host_status_service.py:35-50`; `backend/app/routers/hosts.py:42-57,69-75,131-136`; `backend/app/routers/maintenance.py:53-61`; `backend/app/services/managed_certificate_service.py`, `push_certificate()`; analogous `str(exc)` assignments in VPN/ACME/deploy services.
**Cause:** HTTP error exceptions include the entire upstream response body. Some direct host routes sanitize with `_upstream_error`, but status returns `str(exc)` through `status_detail`, maintenance returns it as `error`, and multiple deploy/certificate/ACME paths persist it as user-visible messages. An upstream response or validation object may contain reflected secret-bearing inputs. `_mutation_result()` also embeds up to 350 characters of unfiltered JSON.
**Prerequisites/impact:** Upstream emits sensitive details or is compromised/misconfigured. Readers can see otherwise privileged error content; messages may additionally enter database backups/audit exports/logs. The synthetic status probe returned the embedded marker. Actual firewall credential reflection was not tested or claimed.
**Fix:** Use structured upstream exceptions with safe code/status/operation fields. Centralize redaction before persistence, logging and response serialization; avoid raw bodies even in generic backend logs. Bound retained diagnostic data. Cover success-shaped-but-invalid mutation responses too.
**Acceptance:** Mock an upstream error/validation response containing API credentials, PSK, private key and arbitrary markers. None appear in reader JSON, database messages, audit details or logs. Safe operation/status diagnostics remain useful.
### SEC-09 — Deleting a host can leave live VPN/certificate state untracked
**Locations:** `backend/app/routers/hosts.py:175-201`; host foreign keys in `backend/app/models/ipsec_tunnel_deployment.py:40`, `openvpn_instance_deployment.py:44`, `wireguard_tunnel_deployment.py:44`, `managed_certificate.py:61`, `acme_account.py:47`, `acme_certificate_request.py:62`.
**Cause:** Host deletion checks rule/alias/NAT/hierarchy deployments and active maintenance operations but omits VPN deployments and certificate/ACME records. Their host FKs cascade deletion. Deleting the host removes local tracking and its API secret without undeploying the remote objects. File-backed entity secrets can also outlive cascaded entity deletion.
**Impact:** Authorized cleanup can leave unintended VPN trust/tunnels/certificates live while destroying their local management records. This is an integrity/lifecycle issue requiring admin access, not a privilege escalation.
**Reproduce:** In a disposable DB, associate a host only with a deployed VPN or managed certificate. DELETE the host; inspect cascaded local records and mocked remote-call history. Do not run this on a real firewall.
**Fix:** Extend guards across every managed remote resource. Require explicit reconciliation/undeployment or a separately acknowledged orphaning workflow. Clean entity secret files only after a safe, successful cleanup; keep identifiers for partial failures. Serialize creation/deletion races with the same operation lock.
**Acceptance:** Each VPN/certificate/ACME case blocks unsafe deletion or performs the documented explicit cleanup. Partial remote failure preserves tracking and secret access needed for recovery. Ordinary undeployed-host deletion succeeds.
### SEC-10 — Durable audit misses MFA, VPN, certificate and notification changes
**Locations:** `backend/app/auth_middleware.py:65-80`; `backend/app/services/audit_service.py:27-50`; `backend/app/routers/auth.py`; `backend/app/routers/ipsec.py`, `openvpn.py`, `wireguard.py`, `certificates.py`, `notifications.py`.
**Cause:** Middleware's resource mapping covers rules, aliases, NAT, hosts and users. The transactional deployment-history hook covers rule/alias/NAT/hierarchy deployment models, not VPN/certificate/ACME. Those routers/services have no compensating durable audit events. MFA changes and password change use ordinary logs or no audit event. Raw backup downloads also are not tracked.
**Impact:** VPN trust, certificate issuance, notification disablement and account security changes can occur without attributable records in `/api/audit`. Some related operations do have audit coverage and should not be duplicated. An account's current role still restricts writes.
**Fix:** Define an explicit security event inventory and record actor, action, target, correlation ID, timestamp and safe result for each missing operation. Include secret exports and failed authorization for sensitive actions. Preserve transactional history and durable pre-operation intent where externally visible operations require it. Never audit submitted secret bodies.
**Acceptance:** Exercise success/failure for each operation class and verify durable, attributable events. Passwords, keys, cookies, webhook tokens and raw backups never appear. Interrupted remote calls have an interpretable incomplete state. Existing audit views/exports remain compatible.
### SEC-11 — Notification secrets disappear on container replacement; signing fails open
**Locations:** `backend/app/config.py:82`; `docker-compose.yml:103-134,193-205`; `backend/app/services/notification_secret_store.py:15-30`; `backend/app/services/notification_delivery.py:67-72,82-100`; `backend/app/services/notification_settings_service.py:35-49`.
**Cause:** Notification secrets default to `/data/notification-secrets`, but Compose mounts sibling secret directories individually and omits this directory. SMTP/signing material remains in the backend writable layer and disappears on recreation/update; DB `*_set` flags survive. When `webhook_signing_secret_set` is true but the file is missing, webhook delivery silently sends without a signature. Missing SMTP password can lead to sending without login or failed delivery.
**Impact:** Routine replacement can disable authenticated alert delivery or drop expected message authentication. A receiver that correctly rejects unsigned messages will reject delivery; a permissive receiver may accept unauthenticated messages. Mere container restart normally preserves the writable layer; recreation is the trigger.
**Fix:** Mount a dedicated persistent notification-secret volume and document backup/restore. Migrate existing secrets before replacing a container. If a configured secret is absent, fail closed with a safe diagnostic and visible unhealthy notification state rather than silently changing authentication behavior.
**Acceptance:** Save synthetic secrets, recreate disposable backend containers with the intended volume, and verify persistence. With signing flag true but missing file, no HTTP request is sent. With SMTP authentication configured but secret missing, no unauthenticated send occurs. Diagnose state without revealing values.
### SEC-12 — Dependency audit is no longer clean
**Locations:** `frontend/package-lock.json:710,1135,1144,3718,4068,4203`; `frontend/package.json`; `frontend/Dockerfile`; `.github/workflows/ci.yml`. Full chain/version data is in `npm-audit.json`.
**Observed versions:** `sharp 0.35.4`, `source-map-js 1.2.1`, `braces 3.0.3`, `brace-expansion 1.1.18` and nested `5.0.9`, `postcss-selector-parser 6.1.4`. npm also flags their dependent chains. Most listed packages are dev dependencies; sharp and source-map-js are not marked dev in the lockfile.
**Impact/limitations:** Registry advisories establish affected versions, not exploitability in this application. The sharp advisory discusses memory corruption under specific runtime conditions, including glibc; the supplied frontend runner is Alpine/musl. The review found no frontend `next/image` usage and demonstrated no attacker-controlled SVG decoding or remote-code execution. Other advisories concern parsing untrusted patterns/selectors/maps and are primarily build/tooling risks here. Check standalone-image contents and actual input reachability before assigning a higher product severity.
**Fix:** Refresh affected dependencies to compatible patched versions, review transitive overrides, and regenerate the lockfile. Do not blindly use `npm audit fix --force`: its suggested framework downgrade/major Tailwind changes require compatibility analysis. Audit the actual runtime image and add recurring dependency auditing to CI. Revise dated documentation that claims no known vulnerabilities without a current scan.
**Acceptance:** Fresh lockfile audit has no unresolved relevant advisories, or documented time-bounded exceptions with reachability evidence. Frontend build/lint succeed. Verify the standalone runtime uses patched native libraries. Backend audit remains clean for the resolved dependency set, and separately scan OS images.
**Verified primary references:** [sharp maintainer advisory](https://github.com/advisories/GHSA-wq5f-xc86-pv6w), [braces advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm). Additional advisory URLs are preserved in npm's JSON evidence. The sharp advisory lists 0.35.5 as patched; confirm compatible current releases before changing dependencies.
### SEC-13 — MFA management bypasses login attempt limiting
**Locations:** `backend/app/routers/auth.py:153-200`; `backend/app/services/auth_service.py:78-139`.
**Cause:** Setup verifies a password without the login throttle; enable checks a six-digit code without a limit; disable checks password plus second factor without a limit. Only `/login` consults/updates the throttle. Pending enrollment has no explicit expiry or session binding.
**Prerequisites/impact:** An authenticated session. A stolen session allows repeated password guesses against setup/disable; with the password, an attacker can repeatedly guess the disable second factor. An attacker with a session and a pending enrollment can try to complete it without a bounded attempt budget. Argon2 cost and network latency reduce throughput but are not an explicit attempt limit.
**Fix:** Add a bounded per-account/per-action rate limiter and short-lived, session-bound enrollment state. Require recent authentication for security-sensitive changes. Avoid resetting the attempt budget merely by recreating pending enrollment. Reuse atomic factor consumption from SEC-06.
**Acceptance:** Repeated invalid password/code requests become 429 or a documented lockout; counters and enrollment survive the supported worker model. Expired or another session's enrollment cannot enable MFA. Normal read-only self-service still works.
### SEC-14 — VPN preview approval is optional and ignores secret-only changes
**Locations:** `backend/app/routers/ipsec.py:152-160`; `backend/app/routers/openvpn.py:155-163`; `backend/app/routers/wireguard.py:164-172`; `backend/app/services/ipsec_service.py:90-100`; analogous token construction in `openvpn_service.py:109-119`, `wireguard_service.py:104-114`.
**Cause:** Deploy accepts `approval=None`, and checks the token only if supplied. Tokens are built from snapshots with secret-present booleans and masked diff rows, not the current secret's version/hash. Rotating only a PSK/private key while keeping flags true can produce the same token. The IPsec probe used an enabled synthetic target and two different PSKs and observed equal tokens.
**Impact:** An admin API call can skip the claimed preview gate, and an existing approval can apply different secret material than reviewed. This concerns change integrity/lockout prevention; admin already has deployment permission. The source explicitly describes some confirmations as UI-only, so implementing a backend gate changes that policy intentionally.
**Fix:** Make approval mandatory when preview is part of the promised safety workflow. Bind it to entity revision, resolved targets, relevant transport trust and secret version/change identifiers. Keep actual secrets and offline-guessable low-entropy secret fingerprints out of user-visible responses; use an opaque server-side approval record or keyed fingerprint where appropriate. Add expiry/single-use semantics if treating approval as an authorization artifact. Correct comparisons of raw payloads versus redacted stored payloads so unchanged secrets do not always appear modified.
**Acceptance:** Missing token fails without a remote call. Secret-only rotation invalidates old approval; metadata/target/transport changes also invalidate it. An unchanged draft permits the intended no-op. Test all three VPN types, not just IPsec. Exposed preview/revision JSON contains no plaintext secret.
### SEC-15 — Validation responses echo secret-bearing input
**Locations:** `backend/app/main.py` (default validation exception handling); `backend/app/schemas/certificates.py:57-79`; other password/key request schemas.
**Cause:** FastAPI's default validation response includes `input`. Model-level validators can include an entire submitted object; field validators can include invalid secret values. The synthetic request to `/api/certificates/managed` returned 422 containing the submitted private-key marker.
**Impact:** The submitting client already knows the value, so this alone is not cross-user disclosure. It unnecessarily puts secrets into response bodies that error telemetry, browser diagnostics or proxies may capture. Classified low rather than claiming account takeover.
**Fix:** Install a safe RequestValidationError response serializer retaining useful field locations, safe messages and codes while removing/redacting inputs and sensitive context. Do not log raw validation objects. `SecretStr` alone is insufficient unless serialization behavior is explicitly tested.
**Acceptance:** Invalid password, private key, PSK and model-level requests return useful 422 responses without markers anywhere in their JSON/logs. Non-secret validation remains understandable.
### SEC-16 — Reader settings expose webhook credentials embedded in URLs
**Locations:** `backend/app/routers/notifications.py:19-21`; `backend/app/schemas/notification.py:18-19`; `backend/app/services/notification_settings_service.py:22-32`; webhook URL column in `backend/app/models/notification.py`.
**Cause:** Settings GET returns the full webhook URL to any authenticated role. Many webhook integrations authenticate possession of a token in the URL path/query; that URL is therefore a credential, unlike an ordinary hostname. Signing-secret and SMTP-password flags correctly avoid returning those secret values but do not protect URL-embedded tokens.
**Prerequisites/impact:** A configured URL contains a capability token, and a reader account accesses settings. The reader can reuse it outside Opnexus to submit forged messages or use whatever access that receiver grants. For a token-free URL protected separately by a signature, this finding's credential impact does not apply. No real configured URLs were inspected.
**Fix:** Treat capability URLs as write-only secrets or return a carefully redacted display value. Keep a `webhook_url_set` indicator; preserve omitted values on updates and provide explicit clear/replace behavior. At minimum require an explicit privileged permission for the unredacted value. Avoid tokens in DB dumps, audit records and logs where the threat model requires secret-store separation.
**Acceptance:** Reader GET never returns a synthetic path/query token. Saving/reloading settings preserves delivery functionality. Masked UI text is not accidentally written back as the real URL. Both signed token-free URLs and capability URLs work with the intended authorization.
## Additional hardening and policy decisions
These are separate from the 16 findings above. Do not call them demonstrated remote exploits.
| ID | Locations | Observation, prerequisites and concrete next step |
|---|---|---|
| HARD-01 | `backend/Dockerfile`; `frontend/Dockerfile:13-23`; `docker-compose.yml:29-39,103` | Backend/frontend run as root; application uses the PostgreSQL bootstrap superuser. This amplifies a future application compromise. Add non-root runtime users, compatible volume ownership, reduced capabilities/no-new-privileges and a least-privileged DB role. Keep migration credentials separate. Verify runtime UID, secret-store access and migrations before rollout. |
| HARD-02 | `backend/app/security_middleware.py:25-31` | Same-origin check compares only netloc to Host, so `http://host` is accepted for an HTTPS host. Probe confirmed cross-scheme acceptance. Exploitability depends on same-site delivery and access to an insecure origin; HSTS and Secure cookies reduce it. Compare a canonical scheme/host/port origin, respecting only explicitly trusted forwarding. Add cross-scheme and default-port tests. Headerless requests being allowed is not by itself proof of browser CSRF; no such exploit was demonstrated. |
| HARD-03 | All `*_secret_store.py`; `totp_service.py:95-99`; `wireguard_key_store.py:39-44,57-62`; `backup_store.py:19-29` | Files are written before chmod 0600. With umask 022 a newly created file can briefly be 0644; writes are not atomic and follow symlinks. Requires local access to the secret directory; no remote path to that access was found. Create restrictive directories and mode-0600 temporary files at open time, avoid symlink following, then atomically replace. Test permissions before writing and crash/partial-write behavior. |
| HARD-04 | `schemas/host.py`; `tls_pin_service.py:93-113`; `notification_delivery.py:55-100`; `update_check_service.py:65-78` | Admin-controlled hosts, TLS probes and notification targets can cause outbound connections. Private targets are necessary for this product, and only admins can set these inputs; this was not classified as anonymous SSRF. Restrict schemes/ports and optionally provide operator-defined management/notification egress policy, protecting metadata services where relevant. Enforce streaming response-size limits; update-feed size is checked only after full buffering. |
| HARD-05 | `docs/HANDBUCH.md:213-217`; `proxy/Caddyfile`, `/opnexus-ca.crt` handler | First-time CA installation is bootstrapped from the same initially untrusted HTTPS endpoint, without an independent fingerprint in those instructions. An attacker on the first-use path could substitute a root users then trust system-wide. Provide an operator-side fingerprint command/out-of-band comparison before import, or use an already-trusted organizational CA. Do not tell users that encryption alone authenticates the initial download. |
| HARD-06 | `docker-compose.yml:115-116`; `.env.example`; `backend/app/config.py`, `cookie_secure` | Bundled proxy is enabled but Secure-cookie defaults remain false. Production handbook correctly says true. Make secure production defaults/validation align with the shipped TLS topology and retain an explicit local-dev mode. HSTS protects established HTTPS clients but does not replace a Secure cookie on first use. Verify cookie flags behind the proxy and HTTP-only dev behavior. |
| HARD-07 | `.github/workflows/ci.yml`, `release.yml`; Dockerfiles; `scripts/opnexus:130-139,163-166` | CI actions/base images are referenced by mutable tags, Python transitives are not fully hash-locked, and release images have no documented verified signature/provenance gate. Rollback sources a local state file as shell code; exploitation requires ability to write that trusted installation file. Pin/verify build inputs, publish/verifiably check provenance, and parse rollback state as data with strict field validation and restrictive permissions. Do not assume a new remote exploit without establishing who can modify these inputs. |
| HARD-08 | `backup_store.py`; `routers/hosts.py:231-258`; `migrate.py`, `_dump_to()`; `docker-compose.yml`, backup service | Firewall snapshots have no retention/deletion/total-size policy; downloads buffer complete responses. Database dumps are not explicitly chmod-hardened and include authentication-related records. Define backup retention/capacity, streaming caps and restrictive file modes, plus encrypted/off-host backup and complete secret-volume recovery procedures. This is availability/confidentiality hardening requiring access/usage conditions, not proof that stored backups were exposed over the network. |
## Controls checked and non-findings
- Session tokens use cryptographic randomness; only their SHA-256 hashes are stored. Passwords use Argon2. Reader write restrictions and anonymous protected-route restrictions are present. No authentication bypass was demonstrated.
- ORM queries and inspected SQL use bound parameters or trusted static statements. No request-driven SQL injection or shell-command injection path was established. Migration subprocesses use argument lists rather than a shell.
- XML restore parsing uses defusedxml, an input size check and an allowlisted metadata manifest. Raw backup export is a distinct path covered by SEC-01.
- Backup filename checks prevent straightforward directory traversal. UUID-based secret filenames do not use request-controlled arbitrary path strings.
- VPN deployment payloads are currently redacted/hashed before persistence, despite stale secret-store docstrings claiming otherwise. The report does not repeat those obsolete plaintext-payload claims as a current vulnerability. Error paths remain covered by SEC-08, and full git-history/legacy database data were not scanned.
- Inspected React content is normally escaped. The layout's `dangerouslySetInnerHTML` contains a static script; the website lightbox `innerHTML` uses static markup and two fixed language labels. No attacker-controlled HTML sink was established. The proxy's `unsafe-inline` CSP remains a defense-in-depth limitation, not proof of XSS.
- Caddy terminates TLS and adds HSTS/CSP/frame/content headers. Compose publishes backend/frontend to loopback by default and does not publish PostgreSQL. Widening `BIND_ADDRESS` can expose HTTP directly; operators should keep the documented proxy topology.
- Firewall TLS pinning uses a verifying trust context; the unauthenticated probe is deliberately only a fingerprint-discovery step. Trusting an uploaded CA intentionally trusts certificates it signs and is not identical to an exact leaf fingerprint pin. Confirm the chosen trust scope in UI/docs.
- Maintenance execute checks bind preview to actor, host, freshness and observed state and consume it before a non-idempotent remote action. VPN approval is weaker and is separately covered by SEC-14.
- Audit UPDATE/DELETE triggers exist. They are useful accidental-tampering controls, not resistance to the application database superuser, which can disable triggers/truncate tables.
## Remediation execution order
1. Implement explicit secret-export authorization, atomic credential/session revocation, verifying SMTP TLS and durable uncertain-deployment recovery (SEC-01–04).
2. Reject plaintext firewall URLs by default and make production transport settings consistent (SEC-05, HARD-06).
3. Make MFA consumption atomic and bound every authentication/security-management attempt path (SEC-06, SEC-07, SEC-13).
4. Remove raw-error/validation secret reflection, protect capability URLs and persist notification secrets with fail-closed delivery (SEC-08, SEC-11, SEC-15, SEC-16).
5. Guard managed-resource lifecycle, complete durable auditing and bind VPN approval to current secret versions (SEC-09, SEC-10, SEC-14).
6. Update dependencies after compatibility/reachability analysis, then apply infrastructure/build/backup hardening (SEC-12, HARD-01–08).
For each ticket, report the final affected files, regression evidence, migration/backward-compatibility consequences and unresolved limitations. Keep deployment approval separate from implementation work. A clean dependency scan or passing mocks alone must not be described as proof the whole product is secure.
## Remediation status (2026-10-07, Claude Code)
Every finding was re-checked against the source before changing it; all 16 SEC findings were confirmed as described (SEC-03 by reading `smtplib` in the Python 3.12 image, SEC-11 by listing `/data` in the live backend container: no `notification-secrets`, and no notification secrets are configured there). Regression tests: `backend/tests/test_security_review.py` (42 tests); full backend suite 475 passed. Nothing was deployed and no firewall was contacted; the frontend lint/typecheck/build passed in a scratch copy.
| ID | Status | What was done / what remains |
|---|---|---|
| SEC-01 | Fixed | Raw backup download is admin-only (403 `READ_ONLY_ACCESS` otherwise), listing stays visible; every attempt audited (`backup/download`, status `accepted`/`denied`); UI hides the button for readers. |
| SEC-02 | Fixed | Password change/reset revoke all sessions atomically with the new hash; admin 2FA reset revokes all; 2FA enable/disable revoke the *other* sessions; login re-checks the password hash under a row lock so a login racing a reset cannot create a session. |
| SEC-03 | Fixed | Verifying `ssl.create_default_context()` for SSL and STARTTLS (optional `SMTP_CA_FILE`); authenticated plaintext SMTP refused unless `ALLOW_INSECURE_SMTP_AUTH`. |
| SEC-04 | Fixed | New shared write path `services/deploy_flow.py` (all six deploy types): a durable intent row is committed before the first remote call (existing deployment: armed pending with previous payload; first deployment: `revert_failed` = "outcome unknown", blocks retries, notifies, "Rollback wiederholen" forgets it); UUID committed right after a confirmed write; failed apply keeps the deadline; timeouts/lost replies/5xx are classed ambiguous (`OpnsenseAmbiguousError`), connect failures and answered refusals are plain errors. Residual: a crash in the few milliseconds between the firewall executing `addRule` and the intent row being the only trace leaves the "unknown" row, which needs a manual look by design. |
| SEC-05 | Fixed, one deliberate gap | `http://`, userinfo, query/fragment rejected on create and in the client (existing rows get "offline", no credentials sent); `ALLOW_INSECURE_FIREWALL_URLS` as explicit override. `verify_tls=false` over HTTPS is **kept**: it is an existing explicit opt-in (pinning is the supported alternative). |
| SEC-06 | Fixed | User row re-read `FOR UPDATE` before consuming a TOTP step or recovery code; concurrent-request tests with the real database. |
| SEC-07 | Mostly fixed | Bounded/expiring throttle state, Argon2 in a bounded thread pool (4 concurrent, 32 queued, then 503/429), 2 MB request-body cap in Caddy. **Not changed:** the missing-MFA-code path is not counted as a failure (counting it would lock out ordinary two-step logins; the password was already correct and the cost is now bounded); the limiter stays per process (the backend runs one uvicorn worker). |
| SEC-08 | Fixed | Upstream error bodies are scrubbed (secret-named JSON fields, PEM blocks, key=value pairs, the host's own key/secret) and cut to 300 characters where the exception is built, so status, maintenance, deploy messages, DB rows and logs all get the safe form; webhook delivery errors no longer contain the URL. |
| SEC-09 | Fixed | Deletion blocked while VPN deployments or deployed certificate/ACME objects exist; secret files of never-deployed drafts are removed with the host. |
| SEC-10 | Fixed | Middleware now records VPN, certificate/ACME, notification, MFA/password and user-admin operations, plus denied writes of read-only accounts. Paths only — no bodies, no query strings. |
| SEC-11 | Fixed | `notification_secrets` volume + `NOTIFICATION_SECRET_DIR` in `docker-compose.yml`; missing signing secret/SMTP password now fails closed. No migration needed on the live instance (no secrets stored there); `docker compose up -d` creates the volume. |
| SEC-12 | Partly fixed | Lockfile refreshed (`sharp` 0.35.5, `source-map-js` 1.2.2, `brace-expansion`); these were the only findings present in the runtime image. Remaining 9 npm findings are build tooling (Tailwind 3/ESLint chain), no patched `braces` exists, `postcss-selector-parser` fix needs a Tailwind major. Re-scan the image after the next build; no recurring audit job added to CI yet. |
| SEC-13 | Fixed, one gap | Attempt budget on 2FA setup/enable/disable and password change (not reset by a new enrolment); pending enrolment expires after 15 minutes. Session binding of the enrolment not added. |
| SEC-14 | Fixed | Approval token mandatory for all three VPN types, bound to the stored secret's version (no secret-derived value in the token); preview now compares in the redacted form, so unchanged secrets no longer show as modified. Expiry/single-use not added. |
| SEC-15 | Fixed | Custom 422 handler returns only `loc`/`msg`/`type`. |
| SEC-16 | Fixed | Non-admins get the webhook URL reduced to scheme/host/port. |
| HARD-02 | Fixed | Same-host origins must be `https` when `COOKIE_SECURE=true`. |
| HARD-03 | Fixed | Secret files are created 0600 under a temp name with `O_EXCL|O_NOFOLLOW`, fsynced and renamed into place. |
| HARD-05 | Documented | Handbook now gives the fingerprint comparison before importing the CA. |
| HARD-06 | Partly | Startup warning when `FRONTEND_ORIGIN` is https but `COOKIE_SECURE` is false; the compose default is unchanged (would break HTTP development). |
| HARD-01, 04, 07, 08 | Open | Non-root containers/least-privilege DB role (volume ownership and migration role need a rollout plan), egress policy and streaming limits, CI pinning/signing and `scripts/opnexus` state-file parsing, backup retention/permissions — separate work items. |
Rollout notes: rebuild backend, frontend **and proxy** images (Caddyfile is baked in); apply the lockfile with the frontend rebuild; no database migration.
@@ -0,0 +1,407 @@
{
"auditReportVersion": 2,
"vulnerabilities": {
"@next/eslint-plugin-next": {
"name": "@next/eslint-plugin-next",
"severity": "high",
"isDirect": false,
"via": [
"fast-glob"
],
"effects": [
"eslint-config-next"
],
"range": ">=14.3.0-canary.0",
"nodes": [
"node_modules/@next/eslint-plugin-next"
],
"fixAvailable": {
"name": "eslint-config-next",
"version": "14.2.35",
"isSemVerMajor": true
}
},
"brace-expansion": {
"name": "brace-expansion",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1240100,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: Quadratic-time expansion of the `{a},b}` rewrite causes CPU denial of service",
"url": "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr",
"severity": "moderate",
"cwe": [
"CWE-400",
"CWE-407"
],
"cvss": {
"score": 5.3,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
},
"range": "<1.1.21"
},
{
"source": 1240103,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: Quadratic-time expansion of the `{a},b}` rewrite causes CPU denial of service",
"url": "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr",
"severity": "moderate",
"cwe": [
"CWE-400",
"CWE-407"
],
"cvss": {
"score": 5.3,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
},
"range": ">=4.0.0 <5.0.12"
},
{
"source": 1240104,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: DoS via uncontrolled recursion on nested brace groups causing stack exhaustion",
"url": "https://github.com/advisories/GHSA-qhr7-859c-m2p7",
"severity": "high",
"cwe": [
"CWE-400",
"CWE-674"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<1.1.20"
},
{
"source": 1240107,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: DoS via uncontrolled recursion on nested brace groups causing stack exhaustion",
"url": "https://github.com/advisories/GHSA-qhr7-859c-m2p7",
"severity": "high",
"cwe": [
"CWE-400",
"CWE-674"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=4.0.0 <5.0.11"
},
{
"source": 1240108,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: DoS via uncontrolled recursion in parseCommaParts causing stack exhaustion",
"url": "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p",
"severity": "high",
"cwe": [
"CWE-400",
"CWE-674"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<1.1.19"
},
{
"source": 1240111,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: DoS via uncontrolled recursion in parseCommaParts causing stack exhaustion",
"url": "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p",
"severity": "high",
"cwe": [
"CWE-400",
"CWE-674"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=4.0.0 <5.0.10"
}
],
"effects": [],
"range": "<=1.1.20 || 4.0.0 - 5.0.11",
"nodes": [
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion",
"node_modules/brace-expansion"
],
"fixAvailable": true
},
"braces": {
"name": "braces",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1240992,
"name": "braces",
"dependency": "braces",
"title": "braces vulnerable to stack-exhaustion denial of service through deeply nested patterns",
"url": "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm",
"severity": "high",
"cwe": [
"CWE-674"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<=3.0.3"
}
],
"effects": [
"chokidar",
"micromatch"
],
"range": "*",
"nodes": [
"node_modules/braces"
],
"fixAvailable": {
"name": "tailwindcss",
"version": "4.3.3",
"isSemVerMajor": true
}
},
"chokidar": {
"name": "chokidar",
"severity": "high",
"isDirect": false,
"via": [
"braces"
],
"effects": [
"tailwindcss"
],
"range": "2.0.0 - 3.6.0",
"nodes": [
"node_modules/chokidar"
],
"fixAvailable": {
"name": "tailwindcss",
"version": "4.3.3",
"isSemVerMajor": true
}
},
"eslint-config-next": {
"name": "eslint-config-next",
"severity": "high",
"isDirect": true,
"via": [
"@next/eslint-plugin-next"
],
"effects": [],
"range": ">=14.3.0-canary.0",
"nodes": [
"node_modules/eslint-config-next"
],
"fixAvailable": {
"name": "eslint-config-next",
"version": "14.2.35",
"isSemVerMajor": true
}
},
"fast-glob": {
"name": "fast-glob",
"severity": "high",
"isDirect": false,
"via": [
"micromatch"
],
"effects": [
"@next/eslint-plugin-next"
],
"range": "*",
"nodes": [
"node_modules/fast-glob",
"node_modules/tailwindcss/node_modules/fast-glob"
],
"fixAvailable": {
"name": "eslint-config-next",
"version": "14.2.35",
"isSemVerMajor": true
}
},
"micromatch": {
"name": "micromatch",
"severity": "high",
"isDirect": false,
"via": [
"braces"
],
"effects": [
"fast-glob",
"tailwindcss"
],
"range": ">=0.2.0",
"nodes": [
"node_modules/micromatch"
],
"fixAvailable": {
"name": "tailwindcss",
"version": "4.3.3",
"isSemVerMajor": true
}
},
"postcss-nested": {
"name": "postcss-nested",
"severity": "moderate",
"isDirect": false,
"via": [
"postcss-selector-parser"
],
"effects": [],
"range": "2.0.3 - 6.2.0",
"nodes": [
"node_modules/postcss-nested"
],
"fixAvailable": true
},
"postcss-selector-parser": {
"name": "postcss-selector-parser",
"severity": "moderate",
"isDirect": false,
"via": [
{
"source": 1241232,
"name": "postcss-selector-parser",
"dependency": "postcss-selector-parser",
"title": "PostCSS: Quadratic complexity in flat selector parsing allows CPU exhaustion",
"url": "https://github.com/advisories/GHSA-rj75-hqrm-r3gf",
"severity": "moderate",
"cwe": [
"CWE-400",
"CWE-407"
],
"cvss": {
"score": 5.9,
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<7.1.6"
}
],
"effects": [
"postcss-nested",
"tailwindcss"
],
"range": "<7.1.6",
"nodes": [
"node_modules/postcss-selector-parser"
],
"fixAvailable": {
"name": "tailwindcss",
"version": "4.3.3",
"isSemVerMajor": true
}
},
"sharp": {
"name": "sharp",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1241331,
"name": "sharp",
"dependency": "sharp",
"title": "sharp : Vulnerability in librsvg dependency CVE-2026-96889",
"url": "https://github.com/advisories/GHSA-wq5f-xc86-pv6w",
"severity": "high",
"cwe": [
"CWE-416",
"CWE-1395"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<0.35.5"
}
],
"effects": [],
"range": "<0.35.5",
"nodes": [
"node_modules/sharp"
],
"fixAvailable": true
},
"source-map-js": {
"name": "source-map-js",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1241209,
"name": "source-map-js",
"dependency": "source-map-js",
"title": "source-map-js allows event-loop denial of service through indexed source-map section offsets",
"url": "https://github.com/advisories/GHSA-68fv-2mgg-jv7q",
"severity": "high",
"cwe": [
"CWE-1284"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=1.0.0 <1.2.2"
}
],
"effects": [],
"range": "1.0.0 - 1.2.1",
"nodes": [
"node_modules/source-map-js"
],
"fixAvailable": true
},
"tailwindcss": {
"name": "tailwindcss",
"severity": "high",
"isDirect": true,
"via": [
"chokidar",
"fast-glob",
"micromatch",
"postcss-nested",
"postcss-selector-parser"
],
"effects": [],
"range": "<=0.0.0-oxide-insiders.ff2c25f || 0.5.0 - 3.4.19",
"nodes": [
"node_modules/tailwindcss"
],
"fixAvailable": {
"name": "tailwindcss",
"version": "4.3.3",
"isSemVerMajor": true
}
}
},
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 2,
"high": 10,
"critical": 0,
"total": 12
},
"dependencies": {
"prod": 46,
"dev": 332,
"optional": 14,
"peer": 0,
"peerOptional": 0,
"total": 390
}
}
}
@@ -0,0 +1 @@
{"dependencies": [{"name": "fastapi", "version": "0.141.1", "vulns": []}, {"name": "uvicorn", "version": "0.52.4", "vulns": []}, {"name": "sqlalchemy", "version": "2.0.52", "vulns": []}, {"name": "asyncpg", "version": "0.31.0", "vulns": []}, {"name": "pydantic", "version": "2.13.5", "vulns": []}, {"name": "pydantic-settings", "version": "2.15.0", "vulns": []}, {"name": "httpx", "version": "0.28.1", "vulns": []}, {"name": "defusedxml", "version": "0.7.1", "vulns": []}, {"name": "argon2-cffi", "version": "25.1.0", "vulns": []}, {"name": "pytest", "version": "9.1.1", "vulns": []}, {"name": "pytest-asyncio", "version": "1.4.0", "vulns": []}, {"name": "respx", "version": "0.23.1", "vulns": []}, {"name": "cryptography", "version": "50.0.2", "vulns": []}, {"name": "alembic", "version": "1.20.0", "vulns": []}, {"name": "pydantic-core", "version": "2.46.5", "vulns": []}, {"name": "httpcore", "version": "1.0.9", "vulns": []}, {"name": "pluggy", "version": "1.6.0", "vulns": []}, {"name": "annotated-doc", "version": "0.0.5", "vulns": []}, {"name": "annotated-types", "version": "0.8.0", "vulns": []}, {"name": "cffi", "version": "2.1.1", "vulns": []}, {"name": "click", "version": "8.5.0", "vulns": []}, {"name": "greenlet", "version": "3.5.6", "vulns": []}, {"name": "h11", "version": "0.16.0", "vulns": []}, {"name": "httptools", "version": "0.8.0", "vulns": []}, {"name": "iniconfig", "version": "2.3.1", "vulns": []}, {"name": "pygments", "version": "2.21.0", "vulns": []}, {"name": "python-dotenv", "version": "1.2.4", "vulns": []}, {"name": "pyyaml", "version": "6.0.3", "vulns": []}, {"name": "starlette", "version": "1.7.0", "vulns": []}, {"name": "anyio", "version": "4.15.1", "vulns": []}, {"name": "idna", "version": "3.20", "vulns": []}, {"name": "typing-extensions", "version": "4.16.0", "vulns": []}, {"name": "typing-inspection", "version": "0.4.4", "vulns": []}, {"name": "uvloop", "version": "0.23.0", "vulns": []}, {"name": "watchfiles", "version": "1.3.0", "vulns": []}, {"name": "websockets", "version": "17.2", "vulns": []}, {"name": "argon2-cffi-bindings", "version": "26.1.0", "vulns": []}, {"name": "certifi", "version": "2026.7.22", "vulns": []}, {"name": "mako", "version": "1.4.3", "vulns": []}, {"name": "markupsafe", "version": "3.0.4", "vulns": []}, {"name": "pycparser", "version": "3.0", "vulns": []}], "fixes": []}
@@ -0,0 +1,70 @@
[
{
"probe": "read_only_backup_download",
"status": 200,
"synthetic_marker_returned": true
},
{
"probe": "read_only_write_control",
"status": 403
},
{
"probe": "validation_error_secret_echo",
"status": 422,
"synthetic_marker_returned": true
},
{
"probe": "password_reset_revoke",
"sql_statements": [],
"commits": 1
},
{
"probe": "totp_parallel_replay",
"accepted": [
true,
true
]
},
{
"probe": "recovery_parallel_replay",
"accepted": [
true,
true
]
},
{
"probe": "status_error_disclosure",
"synthetic_marker_returned": true
},
{
"probe": "smtp_default_tls_context",
"verify_mode": 0,
"check_hostname": false
},
{
"probe": "cross_scheme_origin",
"accepted": true
},
{
"probe": "plaintext_firewall_url",
"accepted": true,
"verify_tls": true
},
{
"probe": "expired_throttle_records",
"before": 200,
"after": 200,
"username_counters": 100
},
{
"probe": "ambiguous_apply_rollback",
"status": "error",
"revert_deadline": null,
"previous_payload": null,
"remote_uuid_known": true
},
{
"probe": "vpn_preview_secret_binding",
"same_token_after_secret_change": true
}
]
@@ -0,0 +1,94 @@
import asyncio, copy, json, ssl, uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.database import get_db
from app.routers import auth, users, hosts
from app.services import auth_service, totp_service, host_status_service, opnsense_client, rule_deploy_service, ipsec_service
from app.security_middleware import _origin_allowed
from app.schemas.host import HostCreate
results=[]
def record(name, **details):
results.append({'probe':name, **details})
class FakeDB:
def __init__(self, user=None): self.user=user; self.executed=[]; self.commits=0
async def get(self,*args,**kwargs): return self.user
async def commit(self): self.commits+=1
def add(self,obj): pass
async def scalar(self,q): return None
async def execute(self,q): self.executed.append(str(q)); return SimpleNamespace(scalar_one_or_none=lambda: self.user)
async def __aenter__(self): return self
async def __aexit__(self,*args): pass
async def main():
user=SimpleNamespace(id=uuid.uuid4(),username='synthetic-reader',role='read_only')
fake=FakeDB(user)
async def dep(): yield fake
app.dependency_overrides[get_db]=dep
host_id=uuid.uuid4()
with patch('app.auth_middleware.SessionLocal',return_value=fake),patch.object(auth_service,'resolve_session',AsyncMock(return_value=user)),patch.object(hosts,'_get_host_or_404',AsyncMock(return_value=SimpleNamespace(id=host_id))),patch.object(hosts.backup_store,'read_snapshot',return_value=b'<opnsense><synthetic-secret>MARKER</synthetic-secret></opnsense>'):
async with AsyncClient(transport=ASGITransport(app=app),base_url='https://synthetic.test',cookies={auth.COOKIE_NAME:'synthetic-token'}) as c:
r=await c.get(f'/api/hosts/{host_id}/backups/synthetic.xml')
record('read_only_backup_download',status=r.status_code,synthetic_marker_returned='MARKER' in r.text)
w=await c.post('/api/hosts',json={})
record('read_only_write_control',status=w.status_code)
user.role='admin'
with patch('app.services.audit_service.persist',AsyncMock()):
r=await c.post('/api/certificates/managed',json={'action':'internal','private_key':'SYNTHETIC-PRIVATE-KEY-MARKER'})
record('validation_error_secret_echo',status=r.status_code,synthetic_marker_returned='SYNTHETIC-PRIVATE-KEY-MARKER' in r.text)
app.dependency_overrides.clear()
# No real user, session, database or secret is consulted below.
target=SimpleNamespace(id=uuid.uuid4(),password_hash='synthetic-hash')
db=FakeDB(target)
with patch.object(auth_service,'hash_password',return_value='new-synthetic-hash'):
await users.reset_password(target.id,users.UserPasswordReset(new_password='synthetic-password-123'),db)
record('password_reset_revoke',sql_statements=db.executed,commits=db.commits)
secret=totp_service.generate_secret(); code=totp_service.code_at(secret)
barrier=asyncio.Event(); entered=0
class ConcurrentDB(FakeDB):
async def commit(self):
nonlocal entered
entered+=1
if entered==2: barrier.set()
await barrier.wait()
u1=SimpleNamespace(id=uuid.uuid4(),username='synthetic-user',totp_last_step=0,totp_recovery_hashes=[])
u2=copy.copy(u1)
with patch.object(totp_service,'read_secret',return_value=secret):
accepted=await asyncio.gather(auth._accept_second_factor(ConcurrentDB(),u1,code),auth._accept_second_factor(ConcurrentDB(),u2,code))
record('totp_parallel_replay',accepted=accepted)
code='synthetic-recovery'; recovery=totp_service.hash_recovery(code)
u1.totp_recovery_hashes=[recovery];u2.totp_recovery_hashes=[recovery];entered=0;barrier=asyncio.Event()
with patch.object(totp_service,'read_secret',return_value=None):
accepted=await asyncio.gather(auth._accept_second_factor(ConcurrentDB(),u1,code),auth._accept_second_factor(ConcurrentDB(),u2,code))
record('recovery_parallel_replay',accepted=accepted)
with patch.object(host_status_service,'get_vip_status',AsyncMock(side_effect=opnsense_client.OpnsenseAPIError('401 synthetic upstream secret MARKER'))):
status=await host_status_service._status_for(SimpleNamespace(enabled=True))
record('status_error_disclosure',synthetic_marker_returned='MARKER' in status['status_detail'])
ctx=ssl._create_stdlib_context()
record('smtp_default_tls_context',verify_mode=int(ctx.verify_mode),check_hostname=ctx.check_hostname)
record('cross_scheme_origin',accepted=_origin_allowed('http://synthetic.test','synthetic.test'))
h=HostCreate(name='synthetic',base_url='http://synthetic.test',api_key='synthetic',api_secret='synthetic')
record('plaintext_firewall_url',accepted=h.base_url.startswith('http://'),verify_tls=h.verify_tls)
throttle=auth_service._LoginThrottle()
for i in range(100): throttle.record_failure(f'synthetic-{i}',f'user-{i}')
before=len(throttle._records)
with patch('app.services.auth_service.time.monotonic',return_value=10**20):
for i in range(100): throttle.retry_after(f'synthetic-{i}',f'user-{i}')
record('expired_throttle_records',before=before,after=len(throttle._records),username_counters=len(throttle._failures_since_success))
rule=SimpleNamespace(id=uuid.uuid4(),enabled=True,action='pass',interface='wan',direction='in',ipprotocol='inet',protocol='any',source_net='any',source_port=None,destination_net='any',destination_port=None,description='synthetic rule')
with patch.object(opnsense_client,'add_rule',AsyncMock(return_value={'uuid':'synthetic-remote-uuid'})),patch.object(opnsense_client,'apply',AsyncMock(side_effect=opnsense_client.OpnsenseAPIError('synthetic timeout after apply'))):
d=await rule_deploy_service._deploy_to_host(FakeDB(),rule,SimpleNamespace(id=uuid.uuid4()))
record('ambiguous_apply_rollback',status=d.status,revert_deadline=d.revert_deadline,previous_payload=d.previous_payload,remote_uuid_known=bool(d.opnsense_rule_uuid))
tunnel=SimpleNamespace(id=uuid.uuid4(),description='synthetic',local_address='192.0.2.1',remote_address='192.0.2.2',ike_version='ikev2',proposal='aes256-sha256-modp2048',local_identity='synthetic-local',remote_identity='synthetic-remote',local_networks='192.0.2.0/25',remote_networks='198.51.100.0/25',esp_proposal='aes256-sha256',enabled=True,pre_shared_key_set=True,targets=[],groups=[],deployments=[])
tunnel.targets=[SimpleNamespace(id=uuid.uuid4(),name='synthetic-host',enabled=True,base_url='https://synthetic.test',verify_tls=True)]
with patch('app.services.ipsec_psk_store.read_secret',return_value='synthetic-old-secret'):
a=ipsec_service.preview(tunnel)
with patch('app.services.ipsec_psk_store.read_secret',return_value='synthetic-new-secret'):
b=ipsec_service.preview(tunnel)
record('vpn_preview_secret_binding',same_token_after_secret_change=a['token']==b['token'])
print(json.dumps(results,indent=2))
asyncio.run(main())
@@ -0,0 +1,187 @@
backend/app/routers/aliases.py 74 get '' list_aliases
backend/app/routers/aliases.py 90 post '' create_alias
backend/app/routers/aliases.py 117 post '/{alias_id}/deploy' deploy
backend/app/routers/aliases.py 135 post '/{alias_id}/confirm' confirm
backend/app/routers/aliases.py 142 delete '/{alias_id}' delete_alias
backend/app/routers/aliases.py 156 put '/{alias_id}' edit_alias
backend/app/routers/aliases.py 181 get '/{alias_id}/preview' preview_alias
backend/app/routers/aliases.py 186 get '/{alias_id}/revisions' list_revisions
backend/app/routers/aliases.py 193 post '/{alias_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/aliases.py 218 post '/{alias_id}/deployments/{host_id}/retry-rollback' retry_rollback
backend/app/routers/audit.py 11 get '' history
backend/app/routers/auth.py 74 post '/login' login
backend/app/routers/auth.py 148 get '/2fa' totp_status
backend/app/routers/auth.py 153 post '/2fa/setup' totp_setup
backend/app/routers/auth.py 167 post '/2fa/enable' totp_enable
backend/app/routers/auth.py 187 post '/2fa/disable' totp_disable
backend/app/routers/auth.py 204 post '/logout' logout
backend/app/routers/auth.py 212 get '/me' me
backend/app/routers/auth.py 221 post '/change-password' change_password
backend/app/routers/certificates.py 56 get '' list_managed_certificates
backend/app/routers/certificates.py 62 post '' create_managed_certificate
backend/app/routers/certificates.py 77 put '/{cert_id}' edit_managed_certificate
backend/app/routers/certificates.py 95 post '/{cert_id}/push' push_managed_certificate
backend/app/routers/certificates.py 106 delete '/{cert_id}' delete_managed_certificate
backend/app/routers/certificates.py 120 get '' list_acme_accounts
backend/app/routers/certificates.py 126 post '' create_acme_account
backend/app/routers/certificates.py 141 put '/{account_id}' edit_acme_account
backend/app/routers/certificates.py 159 post '/{account_id}/push' push_acme_account
backend/app/routers/certificates.py 170 post '/{account_id}/register' register_acme_account
backend/app/routers/certificates.py 189 delete '/{account_id}' delete_acme_account
backend/app/routers/certificates.py 205 get '' list_acme_certificate_requests
backend/app/routers/certificates.py 220 post '' create_acme_certificate_request
backend/app/routers/certificates.py 231 put '/{request_id}' edit_acme_certificate_request
backend/app/routers/certificates.py 247 post '/{request_id}/push' push_acme_certificate_request
backend/app/routers/certificates.py 261 post '/{request_id}/sign' sign_acme_certificate_request
backend/app/routers/certificates.py 278 post '/{request_id}/poll' poll_acme_certificate_request
backend/app/routers/certificates.py 292 delete '/{request_id}' delete_acme_certificate_request
backend/app/routers/cluster.py 13 get '/carp-events' list_carp_events
backend/app/routers/dashboard.py 188 get '' summary
backend/app/routers/groups.py 51 get '' list_groups
backend/app/routers/groups.py 72 post '' create_group
backend/app/routers/groups.py 78 put '/{group_id}' update_group
backend/app/routers/groups.py 90 delete '/{group_id}' delete_group
backend/app/routers/groups.py 161 post '/{group_id}/preview' preview_group
backend/app/routers/hosts.py 69 get '' list_hosts
backend/app/routers/hosts.py 91 post '/tls-probe' tls_probe
backend/app/routers/hosts.py 102 post '' create_host
backend/app/routers/hosts.py 131 get '/{host_id}' get_host
backend/app/routers/hosts.py 138 patch '/{host_id}' update_host
backend/app/routers/hosts.py 175 delete '/{host_id}' delete_host
backend/app/routers/hosts.py 205 get '/{host_id}/live-rules' live_rules
backend/app/routers/hosts.py 214 get '/{host_id}/certificates' certificates
backend/app/routers/hosts.py 223 get '/{host_id}/cas' cas
backend/app/routers/hosts.py 232 post '/{host_id}/backups' trigger_backup
backend/app/routers/hosts.py 243 get '/{host_id}/backups' list_host_backups
backend/app/routers/hosts.py 249 get '/{host_id}/backups/{backup_id}' download_host_backup
backend/app/routers/hosts.py 262 get '/{host_id}/firmware-info' firmware_info
backend/app/routers/hosts.py 271 get '/{host_id}/interfaces' interfaces
backend/app/routers/hosts.py 280 get '/{host_id}/protocols' protocols
backend/app/routers/hosts.py 289 get '/{host_id}/aliases' aliases
backend/app/routers/ipsec.py 92 get '' list_tunnels
backend/app/routers/ipsec.py 99 post '' create_tunnel
backend/app/routers/ipsec.py 118 put '/{tunnel_id}' edit_tunnel
backend/app/routers/ipsec.py 139 get '/{tunnel_id}/preview' preview_tunnel
backend/app/routers/ipsec.py 144 get '/{tunnel_id}/revisions' list_revisions
backend/app/routers/ipsec.py 154 post '/{tunnel_id}/deploy' deploy
backend/app/routers/ipsec.py 172 delete '/{tunnel_id}' delete_tunnel
backend/app/routers/ipsec.py 185 post '/{tunnel_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/logs.py 25 get '/settings' read_log_settings
backend/app/routers/logs.py 30 put '/settings' update_log_settings
backend/app/routers/logs.py 79 get '' search_logs
backend/app/routers/logs.py 108 get '/stats' log_stats
backend/app/routers/logs.py 157 get '/filter-options' filter_options
backend/app/routers/maintenance.py 53 get '' status
backend/app/routers/maintenance.py 62 post '/preview' preview
backend/app/routers/maintenance.py 73 post '/execute' execute
backend/app/routers/maintenance.py 99 post '/check-updates' check_updates
backend/app/routers/maintenance.py 112 get '/progress' progress
backend/app/routers/maintenance.py 118 post '/carp-preview' carp_preview
backend/app/routers/maintenance.py 129 post '/carp-execute' carp_execute
backend/app/routers/maintenance.py 163 post '/{operation_id}/acknowledge' acknowledge
backend/app/routers/maintenance.py 193 post '/{operation_id}/cancel' cancel
backend/app/routers/notifications.py 20 get '/settings' read_settings
backend/app/routers/notifications.py 25 put '/settings' update_settings
backend/app/routers/notifications.py 30 get '/problems' list_problems
backend/app/routers/notifications.py 46 post '/test' test_notification
backend/app/routers/one_to_one.py 83 get '' list_one_to_one_rules
backend/app/routers/one_to_one.py 91 post '' create_one_to_one_rule
backend/app/routers/one_to_one.py 121 post '/{rule_id}/deploy' deploy
backend/app/routers/one_to_one.py 157 post '/{rule_id}/confirm' confirm
backend/app/routers/one_to_one.py 164 delete '/{rule_id}' delete_one_to_one_rule
backend/app/routers/one_to_one.py 186 put '/{rule_id}' edit_one_to_one_rule
backend/app/routers/one_to_one.py 207 get '/{rule_id}/preview' preview_rule
backend/app/routers/one_to_one.py 212 get '/{rule_id}/revisions' list_revisions
backend/app/routers/one_to_one.py 219 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/one_to_one.py 244 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
backend/app/routers/one_to_one.py 262 get '/hosts/{host_id}/live' live_rules
backend/app/routers/openvpn.py 93 get '' list_instances
backend/app/routers/openvpn.py 100 post '' create_instance
backend/app/routers/openvpn.py 120 put '/{instance_id}' edit_instance
backend/app/routers/openvpn.py 141 get '/{instance_id}/preview' preview_instance
backend/app/routers/openvpn.py 146 get '/{instance_id}/revisions' list_revisions
backend/app/routers/openvpn.py 156 post '/{instance_id}/deploy' deploy
backend/app/routers/openvpn.py 174 delete '/{instance_id}' delete_instance
backend/app/routers/openvpn.py 187 post '/{instance_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/outbound_nat.py 86 get '' list_outbound_nat_rules
backend/app/routers/outbound_nat.py 94 post '' create_outbound_nat_rule
backend/app/routers/outbound_nat.py 126 post '/{rule_id}/deploy' deploy
backend/app/routers/outbound_nat.py 164 post '/{rule_id}/confirm' confirm
backend/app/routers/outbound_nat.py 171 delete '/{rule_id}' delete_outbound_nat_rule
backend/app/routers/outbound_nat.py 193 put '/{rule_id}' edit_outbound_nat_rule
backend/app/routers/outbound_nat.py 214 get '/{rule_id}/preview' preview_rule
backend/app/routers/outbound_nat.py 219 get '/{rule_id}/revisions' list_revisions
backend/app/routers/outbound_nat.py 226 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/outbound_nat.py 251 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
backend/app/routers/outbound_nat.py 269 get '/hosts/{host_id}/live' live_rules
backend/app/routers/outbound_nat.py 282 get '/hosts/{host_id}/mode' outbound_mode
backend/app/routers/policy_hierarchy.py 116 get '/rules' list_rules
backend/app/routers/policy_hierarchy.py 123 post '/rules' create_rule
backend/app/routers/policy_hierarchy.py 158 put '/rules/{rule_id}' edit_rule
backend/app/routers/policy_hierarchy.py 178 put '/groups/{group_id}/rules/{rulebase}/order' reorder_rules
backend/app/routers/policy_hierarchy.py 225 post '/rules/{rule_id}/move' move_rule
backend/app/routers/policy_hierarchy.py 245 post '/rules/{rule_id}/deploy' deploy
backend/app/routers/policy_hierarchy.py 283 post '/rules/{rule_id}/confirm' confirm
backend/app/routers/policy_hierarchy.py 290 delete '/rules/{rule_id}' delete_rule
backend/app/routers/policy_hierarchy.py 304 get '/rules/{rule_id}/preview' preview_rule
backend/app/routers/policy_hierarchy.py 310 get '/rules/{rule_id}/revisions' list_revisions
backend/app/routers/policy_hierarchy.py 320 post '/rules/{rule_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/policy_hierarchy.py 346 post '/rules/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
backend/app/routers/policy_hierarchy.py 365 post '/groups/{group_id}/reassert-ordering' reassert_ordering
backend/app/routers/policy_hierarchy_groups.py 46 get '' list_groups
backend/app/routers/policy_hierarchy_groups.py 54 post '' create_group
backend/app/routers/policy_hierarchy_groups.py 67 put '/{group_id}' update_group
backend/app/routers/policy_hierarchy_groups.py 83 delete '/{group_id}' delete_group
backend/app/routers/port_forward.py 84 get '' list_port_forward_rules
backend/app/routers/port_forward.py 92 post '' create_port_forward_rule
backend/app/routers/port_forward.py 123 post '/{rule_id}/deploy' deploy
backend/app/routers/port_forward.py 159 post '/{rule_id}/confirm' confirm
backend/app/routers/port_forward.py 166 delete '/{rule_id}' delete_port_forward_rule
backend/app/routers/port_forward.py 188 put '/{rule_id}' edit_port_forward_rule
backend/app/routers/port_forward.py 209 get '/{rule_id}/preview' preview_rule
backend/app/routers/port_forward.py 214 get '/{rule_id}/revisions' list_revisions
backend/app/routers/port_forward.py 221 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/port_forward.py 246 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
backend/app/routers/port_forward.py 264 get '/hosts/{host_id}/live' live_rules
backend/app/routers/resource_samples.py 26 get '' list_samples
backend/app/routers/restore.py 57 get '' list_operations
backend/app/routers/restore.py 69 get '/{operation_id}' get_operation
backend/app/routers/restore.py 76 post '/preview' preview
backend/app/routers/restore.py 118 post '/{operation_id}/handoff' handoff
backend/app/routers/restore.py 150 post '/{operation_id}/confirm' confirm
backend/app/routers/restore.py 173 post '/{operation_id}/cancel' cancel
backend/app/routers/rules.py 81 get '' list_rules
backend/app/routers/rules.py 88 post '' create_rule
backend/app/routers/rules.py 118 post '/{rule_id}/deploy' deploy
backend/app/routers/rules.py 155 post '/{rule_id}/confirm' confirm
backend/app/routers/rules.py 168 delete '/{rule_id}' delete_rule
backend/app/routers/rules.py 190 put '/{rule_id}' edit_rule
backend/app/routers/rules.py 210 get '/{rule_id}/preview' preview_rule
backend/app/routers/rules.py 215 get '/{rule_id}/revisions' list_revisions
backend/app/routers/rules.py 222 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
backend/app/routers/rules.py 247 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
backend/app/routers/scheduled_maintenance.py 41 get '' list_scheduled
backend/app/routers/scheduled_maintenance.py 55 post '' create_scheduled
backend/app/routers/scheduled_maintenance.py 74 delete '/{schedule_id}' cancel_scheduled
backend/app/routers/search.py 88 get '' search
backend/app/routers/services.py 22 get '' list_services
backend/app/routers/services.py 58 post '/{name}/start' start_service
backend/app/routers/services.py 63 post '/{name}/stop' stop_service
backend/app/routers/services.py 68 post '/{name}/restart' restart_service
backend/app/routers/settings.py 19 get '' read
backend/app/routers/settings.py 27 put '' update
backend/app/routers/system.py 9 get '/version-check' version_check
backend/app/routers/users.py 25 get '' list_users
backend/app/routers/users.py 31 post '' create_user
backend/app/routers/users.py 46 patch '/{user_id}/role' update_role
backend/app/routers/users.py 58 post '/{user_id}/reset-2fa' reset_two_factor
backend/app/routers/users.py 72 post '/{user_id}/reset-password' reset_password
backend/app/routers/users.py 82 delete '/{user_id}' delete_user
backend/app/routers/wireguard.py 97 get '' list_tunnels
backend/app/routers/wireguard.py 104 post '' create_tunnel
backend/app/routers/wireguard.py 126 put '/{tunnel_id}' edit_tunnel
backend/app/routers/wireguard.py 150 get '/{tunnel_id}/preview' preview_tunnel
backend/app/routers/wireguard.py 155 get '/{tunnel_id}/revisions' list_revisions
backend/app/routers/wireguard.py 165 post '/{tunnel_id}/deploy' deploy
backend/app/routers/wireguard.py 185 delete '/{tunnel_id}' delete_tunnel
backend/app/routers/wireguard.py 199 post '/{tunnel_id}/deployments/{host_id}/remove' remove_previous_target
1 backend/app/routers/aliases.py 74 get '' list_aliases
2 backend/app/routers/aliases.py 90 post '' create_alias
3 backend/app/routers/aliases.py 117 post '/{alias_id}/deploy' deploy
4 backend/app/routers/aliases.py 135 post '/{alias_id}/confirm' confirm
5 backend/app/routers/aliases.py 142 delete '/{alias_id}' delete_alias
6 backend/app/routers/aliases.py 156 put '/{alias_id}' edit_alias
7 backend/app/routers/aliases.py 181 get '/{alias_id}/preview' preview_alias
8 backend/app/routers/aliases.py 186 get '/{alias_id}/revisions' list_revisions
9 backend/app/routers/aliases.py 193 post '/{alias_id}/deployments/{host_id}/remove' remove_previous_target
10 backend/app/routers/aliases.py 218 post '/{alias_id}/deployments/{host_id}/retry-rollback' retry_rollback
11 backend/app/routers/audit.py 11 get '' history
12 backend/app/routers/auth.py 74 post '/login' login
13 backend/app/routers/auth.py 148 get '/2fa' totp_status
14 backend/app/routers/auth.py 153 post '/2fa/setup' totp_setup
15 backend/app/routers/auth.py 167 post '/2fa/enable' totp_enable
16 backend/app/routers/auth.py 187 post '/2fa/disable' totp_disable
17 backend/app/routers/auth.py 204 post '/logout' logout
18 backend/app/routers/auth.py 212 get '/me' me
19 backend/app/routers/auth.py 221 post '/change-password' change_password
20 backend/app/routers/certificates.py 56 get '' list_managed_certificates
21 backend/app/routers/certificates.py 62 post '' create_managed_certificate
22 backend/app/routers/certificates.py 77 put '/{cert_id}' edit_managed_certificate
23 backend/app/routers/certificates.py 95 post '/{cert_id}/push' push_managed_certificate
24 backend/app/routers/certificates.py 106 delete '/{cert_id}' delete_managed_certificate
25 backend/app/routers/certificates.py 120 get '' list_acme_accounts
26 backend/app/routers/certificates.py 126 post '' create_acme_account
27 backend/app/routers/certificates.py 141 put '/{account_id}' edit_acme_account
28 backend/app/routers/certificates.py 159 post '/{account_id}/push' push_acme_account
29 backend/app/routers/certificates.py 170 post '/{account_id}/register' register_acme_account
30 backend/app/routers/certificates.py 189 delete '/{account_id}' delete_acme_account
31 backend/app/routers/certificates.py 205 get '' list_acme_certificate_requests
32 backend/app/routers/certificates.py 220 post '' create_acme_certificate_request
33 backend/app/routers/certificates.py 231 put '/{request_id}' edit_acme_certificate_request
34 backend/app/routers/certificates.py 247 post '/{request_id}/push' push_acme_certificate_request
35 backend/app/routers/certificates.py 261 post '/{request_id}/sign' sign_acme_certificate_request
36 backend/app/routers/certificates.py 278 post '/{request_id}/poll' poll_acme_certificate_request
37 backend/app/routers/certificates.py 292 delete '/{request_id}' delete_acme_certificate_request
38 backend/app/routers/cluster.py 13 get '/carp-events' list_carp_events
39 backend/app/routers/dashboard.py 188 get '' summary
40 backend/app/routers/groups.py 51 get '' list_groups
41 backend/app/routers/groups.py 72 post '' create_group
42 backend/app/routers/groups.py 78 put '/{group_id}' update_group
43 backend/app/routers/groups.py 90 delete '/{group_id}' delete_group
44 backend/app/routers/groups.py 161 post '/{group_id}/preview' preview_group
45 backend/app/routers/hosts.py 69 get '' list_hosts
46 backend/app/routers/hosts.py 91 post '/tls-probe' tls_probe
47 backend/app/routers/hosts.py 102 post '' create_host
48 backend/app/routers/hosts.py 131 get '/{host_id}' get_host
49 backend/app/routers/hosts.py 138 patch '/{host_id}' update_host
50 backend/app/routers/hosts.py 175 delete '/{host_id}' delete_host
51 backend/app/routers/hosts.py 205 get '/{host_id}/live-rules' live_rules
52 backend/app/routers/hosts.py 214 get '/{host_id}/certificates' certificates
53 backend/app/routers/hosts.py 223 get '/{host_id}/cas' cas
54 backend/app/routers/hosts.py 232 post '/{host_id}/backups' trigger_backup
55 backend/app/routers/hosts.py 243 get '/{host_id}/backups' list_host_backups
56 backend/app/routers/hosts.py 249 get '/{host_id}/backups/{backup_id}' download_host_backup
57 backend/app/routers/hosts.py 262 get '/{host_id}/firmware-info' firmware_info
58 backend/app/routers/hosts.py 271 get '/{host_id}/interfaces' interfaces
59 backend/app/routers/hosts.py 280 get '/{host_id}/protocols' protocols
60 backend/app/routers/hosts.py 289 get '/{host_id}/aliases' aliases
61 backend/app/routers/ipsec.py 92 get '' list_tunnels
62 backend/app/routers/ipsec.py 99 post '' create_tunnel
63 backend/app/routers/ipsec.py 118 put '/{tunnel_id}' edit_tunnel
64 backend/app/routers/ipsec.py 139 get '/{tunnel_id}/preview' preview_tunnel
65 backend/app/routers/ipsec.py 144 get '/{tunnel_id}/revisions' list_revisions
66 backend/app/routers/ipsec.py 154 post '/{tunnel_id}/deploy' deploy
67 backend/app/routers/ipsec.py 172 delete '/{tunnel_id}' delete_tunnel
68 backend/app/routers/ipsec.py 185 post '/{tunnel_id}/deployments/{host_id}/remove' remove_previous_target
69 backend/app/routers/logs.py 25 get '/settings' read_log_settings
70 backend/app/routers/logs.py 30 put '/settings' update_log_settings
71 backend/app/routers/logs.py 79 get '' search_logs
72 backend/app/routers/logs.py 108 get '/stats' log_stats
73 backend/app/routers/logs.py 157 get '/filter-options' filter_options
74 backend/app/routers/maintenance.py 53 get '' status
75 backend/app/routers/maintenance.py 62 post '/preview' preview
76 backend/app/routers/maintenance.py 73 post '/execute' execute
77 backend/app/routers/maintenance.py 99 post '/check-updates' check_updates
78 backend/app/routers/maintenance.py 112 get '/progress' progress
79 backend/app/routers/maintenance.py 118 post '/carp-preview' carp_preview
80 backend/app/routers/maintenance.py 129 post '/carp-execute' carp_execute
81 backend/app/routers/maintenance.py 163 post '/{operation_id}/acknowledge' acknowledge
82 backend/app/routers/maintenance.py 193 post '/{operation_id}/cancel' cancel
83 backend/app/routers/notifications.py 20 get '/settings' read_settings
84 backend/app/routers/notifications.py 25 put '/settings' update_settings
85 backend/app/routers/notifications.py 30 get '/problems' list_problems
86 backend/app/routers/notifications.py 46 post '/test' test_notification
87 backend/app/routers/one_to_one.py 83 get '' list_one_to_one_rules
88 backend/app/routers/one_to_one.py 91 post '' create_one_to_one_rule
89 backend/app/routers/one_to_one.py 121 post '/{rule_id}/deploy' deploy
90 backend/app/routers/one_to_one.py 157 post '/{rule_id}/confirm' confirm
91 backend/app/routers/one_to_one.py 164 delete '/{rule_id}' delete_one_to_one_rule
92 backend/app/routers/one_to_one.py 186 put '/{rule_id}' edit_one_to_one_rule
93 backend/app/routers/one_to_one.py 207 get '/{rule_id}/preview' preview_rule
94 backend/app/routers/one_to_one.py 212 get '/{rule_id}/revisions' list_revisions
95 backend/app/routers/one_to_one.py 219 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
96 backend/app/routers/one_to_one.py 244 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
97 backend/app/routers/one_to_one.py 262 get '/hosts/{host_id}/live' live_rules
98 backend/app/routers/openvpn.py 93 get '' list_instances
99 backend/app/routers/openvpn.py 100 post '' create_instance
100 backend/app/routers/openvpn.py 120 put '/{instance_id}' edit_instance
101 backend/app/routers/openvpn.py 141 get '/{instance_id}/preview' preview_instance
102 backend/app/routers/openvpn.py 146 get '/{instance_id}/revisions' list_revisions
103 backend/app/routers/openvpn.py 156 post '/{instance_id}/deploy' deploy
104 backend/app/routers/openvpn.py 174 delete '/{instance_id}' delete_instance
105 backend/app/routers/openvpn.py 187 post '/{instance_id}/deployments/{host_id}/remove' remove_previous_target
106 backend/app/routers/outbound_nat.py 86 get '' list_outbound_nat_rules
107 backend/app/routers/outbound_nat.py 94 post '' create_outbound_nat_rule
108 backend/app/routers/outbound_nat.py 126 post '/{rule_id}/deploy' deploy
109 backend/app/routers/outbound_nat.py 164 post '/{rule_id}/confirm' confirm
110 backend/app/routers/outbound_nat.py 171 delete '/{rule_id}' delete_outbound_nat_rule
111 backend/app/routers/outbound_nat.py 193 put '/{rule_id}' edit_outbound_nat_rule
112 backend/app/routers/outbound_nat.py 214 get '/{rule_id}/preview' preview_rule
113 backend/app/routers/outbound_nat.py 219 get '/{rule_id}/revisions' list_revisions
114 backend/app/routers/outbound_nat.py 226 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
115 backend/app/routers/outbound_nat.py 251 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
116 backend/app/routers/outbound_nat.py 269 get '/hosts/{host_id}/live' live_rules
117 backend/app/routers/outbound_nat.py 282 get '/hosts/{host_id}/mode' outbound_mode
118 backend/app/routers/policy_hierarchy.py 116 get '/rules' list_rules
119 backend/app/routers/policy_hierarchy.py 123 post '/rules' create_rule
120 backend/app/routers/policy_hierarchy.py 158 put '/rules/{rule_id}' edit_rule
121 backend/app/routers/policy_hierarchy.py 178 put '/groups/{group_id}/rules/{rulebase}/order' reorder_rules
122 backend/app/routers/policy_hierarchy.py 225 post '/rules/{rule_id}/move' move_rule
123 backend/app/routers/policy_hierarchy.py 245 post '/rules/{rule_id}/deploy' deploy
124 backend/app/routers/policy_hierarchy.py 283 post '/rules/{rule_id}/confirm' confirm
125 backend/app/routers/policy_hierarchy.py 290 delete '/rules/{rule_id}' delete_rule
126 backend/app/routers/policy_hierarchy.py 304 get '/rules/{rule_id}/preview' preview_rule
127 backend/app/routers/policy_hierarchy.py 310 get '/rules/{rule_id}/revisions' list_revisions
128 backend/app/routers/policy_hierarchy.py 320 post '/rules/{rule_id}/deployments/{host_id}/remove' remove_previous_target
129 backend/app/routers/policy_hierarchy.py 346 post '/rules/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
130 backend/app/routers/policy_hierarchy.py 365 post '/groups/{group_id}/reassert-ordering' reassert_ordering
131 backend/app/routers/policy_hierarchy_groups.py 46 get '' list_groups
132 backend/app/routers/policy_hierarchy_groups.py 54 post '' create_group
133 backend/app/routers/policy_hierarchy_groups.py 67 put '/{group_id}' update_group
134 backend/app/routers/policy_hierarchy_groups.py 83 delete '/{group_id}' delete_group
135 backend/app/routers/port_forward.py 84 get '' list_port_forward_rules
136 backend/app/routers/port_forward.py 92 post '' create_port_forward_rule
137 backend/app/routers/port_forward.py 123 post '/{rule_id}/deploy' deploy
138 backend/app/routers/port_forward.py 159 post '/{rule_id}/confirm' confirm
139 backend/app/routers/port_forward.py 166 delete '/{rule_id}' delete_port_forward_rule
140 backend/app/routers/port_forward.py 188 put '/{rule_id}' edit_port_forward_rule
141 backend/app/routers/port_forward.py 209 get '/{rule_id}/preview' preview_rule
142 backend/app/routers/port_forward.py 214 get '/{rule_id}/revisions' list_revisions
143 backend/app/routers/port_forward.py 221 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
144 backend/app/routers/port_forward.py 246 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
145 backend/app/routers/port_forward.py 264 get '/hosts/{host_id}/live' live_rules
146 backend/app/routers/resource_samples.py 26 get '' list_samples
147 backend/app/routers/restore.py 57 get '' list_operations
148 backend/app/routers/restore.py 69 get '/{operation_id}' get_operation
149 backend/app/routers/restore.py 76 post '/preview' preview
150 backend/app/routers/restore.py 118 post '/{operation_id}/handoff' handoff
151 backend/app/routers/restore.py 150 post '/{operation_id}/confirm' confirm
152 backend/app/routers/restore.py 173 post '/{operation_id}/cancel' cancel
153 backend/app/routers/rules.py 81 get '' list_rules
154 backend/app/routers/rules.py 88 post '' create_rule
155 backend/app/routers/rules.py 118 post '/{rule_id}/deploy' deploy
156 backend/app/routers/rules.py 155 post '/{rule_id}/confirm' confirm
157 backend/app/routers/rules.py 168 delete '/{rule_id}' delete_rule
158 backend/app/routers/rules.py 190 put '/{rule_id}' edit_rule
159 backend/app/routers/rules.py 210 get '/{rule_id}/preview' preview_rule
160 backend/app/routers/rules.py 215 get '/{rule_id}/revisions' list_revisions
161 backend/app/routers/rules.py 222 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
162 backend/app/routers/rules.py 247 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
163 backend/app/routers/scheduled_maintenance.py 41 get '' list_scheduled
164 backend/app/routers/scheduled_maintenance.py 55 post '' create_scheduled
165 backend/app/routers/scheduled_maintenance.py 74 delete '/{schedule_id}' cancel_scheduled
166 backend/app/routers/search.py 88 get '' search
167 backend/app/routers/services.py 22 get '' list_services
168 backend/app/routers/services.py 58 post '/{name}/start' start_service
169 backend/app/routers/services.py 63 post '/{name}/stop' stop_service
170 backend/app/routers/services.py 68 post '/{name}/restart' restart_service
171 backend/app/routers/settings.py 19 get '' read
172 backend/app/routers/settings.py 27 put '' update
173 backend/app/routers/system.py 9 get '/version-check' version_check
174 backend/app/routers/users.py 25 get '' list_users
175 backend/app/routers/users.py 31 post '' create_user
176 backend/app/routers/users.py 46 patch '/{user_id}/role' update_role
177 backend/app/routers/users.py 58 post '/{user_id}/reset-2fa' reset_two_factor
178 backend/app/routers/users.py 72 post '/{user_id}/reset-password' reset_password
179 backend/app/routers/users.py 82 delete '/{user_id}' delete_user
180 backend/app/routers/wireguard.py 97 get '' list_tunnels
181 backend/app/routers/wireguard.py 104 post '' create_tunnel
182 backend/app/routers/wireguard.py 126 put '/{tunnel_id}' edit_tunnel
183 backend/app/routers/wireguard.py 150 get '/{tunnel_id}/preview' preview_tunnel
184 backend/app/routers/wireguard.py 155 get '/{tunnel_id}/revisions' list_revisions
185 backend/app/routers/wireguard.py 165 post '/{tunnel_id}/deploy' deploy
186 backend/app/routers/wireguard.py 185 delete '/{tunnel_id}' delete_tunnel
187 backend/app/routers/wireguard.py 199 post '/{tunnel_id}/deployments/{host_id}/remove' remove_previous_target
File diff suppressed because it is too large. Load diff
+24 -1
View File
@@ -6,7 +6,7 @@ import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { Skeleton } from "@/components/ui/Skeleton"; import { Skeleton } from "@/components/ui/Skeleton";
import { StatusDot } from "@/components/ui/StatusDot"; import { StatusDot } from "@/components/ui/StatusDot";
import { api, DashboardSummary, NotificationProblem } from "@/lib/api"; import { api, ComplianceHost, DashboardSummary, NotificationProblem } from "@/lib/api";
import { import {
Activity, Activity,
CheckCircle2, CheckCircle2,
@@ -115,6 +115,7 @@ export default function DashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null); const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [problems, setProblems] = useState<NotificationProblem[] | null>(null); const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null); const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
const [posture, setPosture] = useState<ComplianceHost[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Pending confirmations expire after a few minutes, so keep the summary // Pending confirmations expire after a few minutes, so keep the summary
@@ -163,6 +164,8 @@ export default function DashboardPage() {
}) })
.catch((e) => setError(errorMessage(e, "dashboard.loadFailed"))); .catch((e) => setError(errorMessage(e, "dashboard.loadFailed")));
api.listCompliance().then((d) => active && setPosture(d)).catch(() => active && setPosture([]));
api api
.notificationProblems(false) .notificationProblems(false)
.then((data) => { .then((data) => {
@@ -242,6 +245,26 @@ export default function DashboardPage() {
)} )}
</SectionCard> </SectionCard>
<SectionCard title={t("dashboard.securityPosture")} icon={ShieldAlert}>
{posture === null ? (
<Skeleton className="h-10" />
) : posture.filter((h) => h.score !== null).length === 0 ? (
<p className="text-sm text-slate-500">{t("dashboard.postureNone")} <Link href="/security" className="text-sky-700 hover:underline">{t("dashboard.postureOpen")}</Link></p>
) : (
<ul className="divide-y divide-slate-100">
{[...posture].filter((h) => h.score !== null).sort((a, b) => (a.score ?? 0) - (b.score ?? 0)).slice(0, 6).map((h) => (
<li key={h.host_id} className="flex items-center justify-between gap-3 py-2 text-sm">
<Link href="/security" className="min-w-0 truncate font-medium text-sky-700 hover:underline">{h.host_name}</Link>
<div className="flex items-center gap-2">
{h.counts && h.counts.critical > 0 && <Badge tone="danger">{h.counts.critical} {t("security.severity.critical")}</Badge>}
<Badge tone={h.grade === "good" ? "success" : h.grade === "fair" ? "warning" : "danger"}>{h.score} / 100</Badge>
</div>
</li>
))}
</ul>
)}
</SectionCard>
<SectionCard title={t("dashboard.pendingActions")} icon={ClipboardList}> <SectionCard title={t("dashboard.pendingActions")} icon={ClipboardList}>
{summary === null ? ( {summary === null ? (
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div> <div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
+16 -4
View File
@@ -10,19 +10,22 @@ import { Skeleton } from "@/components/ui/Skeleton";
import { Spinner } from "@/components/ui/Spinner"; import { Spinner } from "@/components/ui/Spinner";
import { StatusDot } from "@/components/ui/StatusDot"; import { StatusDot } from "@/components/ui/StatusDot";
import { api, AliasOption, Host, HostBackup, LiveRule, PolicyAlias, RestoreOperation, Rule } from "@/lib/api"; import { api, AliasOption, Host, HostBackup, LiveRule, PolicyAlias, RestoreOperation, Rule } from "@/lib/api";
import { ArrowLeft, Download, ExternalLink, RotateCcw, ShieldCheck, ShieldOff } from "lucide-react"; import { ArrowLeft, Download, ExternalLink, GitCompare, RotateCcw, ShieldCheck, ShieldOff } from "lucide-react";
import { HostVariables } from "@/components/HostVariables";
import { BackupDiffModal } from "@/components/BackupDiffModal";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Collapsible } from "@/components/ui/Collapsible"; import { Collapsible } from "@/components/ui/Collapsible";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLanguage } from "@/components/LanguageProvider"; import { useLanguage } from "@/components/LanguageProvider";
import { useReadOnly } from "@/lib/useReadOnly"; import { useNotAdmin } from "@/lib/useReadOnly";
export default function HostDetailPage() { export default function HostDetailPage() {
const { t, ts, errorMessage, locale } = useLanguage(); const { t, ts, errorMessage, locale } = useLanguage();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
// The raw config.xml contains secrets: its download is admin-only on the server too. // The raw config.xml contains secrets: its download is admin-only on the server too.
const readOnly = useReadOnly(); const readOnly = useNotAdmin();
const [diffTarget, setDiffTarget] = useState<{ older: string; newer: string } | null>(null);
const [host, setHost] = useState<Host | null>(null); const [host, setHost] = useState<Host | null>(null);
const [rules, setRules] = useState<Rule[] | null>(null); const [rules, setRules] = useState<Rule[] | null>(null);
const [liveRules, setLiveRules] = useState<LiveRule[] | null>(null); const [liveRules, setLiveRules] = useState<LiveRule[] | null>(null);
@@ -391,11 +394,17 @@ export default function HostDetailPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{backups.map((backup) => ( {backups.map((backup, index) => (
<tr key={backup.id} className="border-b border-slate-100 last:border-0"> <tr key={backup.id} className="border-b border-slate-100 last:border-0">
<td className="px-5 py-3 text-slate-900">{new Date(backup.created_at).toLocaleString(locale)}</td> <td className="px-5 py-3 text-slate-900">{new Date(backup.created_at).toLocaleString(locale)}</td>
<td className="px-5 py-3 text-slate-500">{formatSize(backup.size)}</td> <td className="px-5 py-3 text-slate-500">{formatSize(backup.size)}</td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
{index + 1 < backups.length && (
<Button variant="ghost" onClick={() => setDiffTarget({ older: backups[index + 1].id, newer: backup.id })}>
<GitCompare size={14} />
{t("hostDetail.changes")}
</Button>
)}
{!readOnly && ( {!readOnly && (
<Button variant="ghost" onClick={() => handleDownloadBackup(backup.id)}> <Button variant="ghost" onClick={() => handleDownloadBackup(backup.id)}>
<Download size={14} /> <Download size={14} />
@@ -416,6 +425,8 @@ export default function HostDetailPage() {
<HostServices host={host} /> <HostServices host={host} />
<HostVariables host={host} />
{restoreHistory && restoreHistory.length > 0 && ( {restoreHistory && restoreHistory.length > 0 && (
<> <>
<h2 className="mb-3 mt-8 text-lg font-semibold text-slate-900">{t("hostDetail.restoreHistory")}</h2> <h2 className="mb-3 mt-8 text-lg font-semibold text-slate-900">{t("hostDetail.restoreHistory")}</h2>
@@ -447,6 +458,7 @@ export default function HostDetailPage() {
)} )}
</> </>
)} )}
{diffTarget && host && <BackupDiffModal hostId={host.id} older={diffTarget.older} newer={diffTarget.newer} onClose={() => setDiffTarget(null)} />}
{restoreTarget && ( {restoreTarget && (
<RestoreWizard <RestoreWizard
hostId={id} hostId={id}
+2 -2
View File
@@ -8,7 +8,7 @@ import { Skeleton } from "@/components/ui/Skeleton";
import { StatusDot } from "@/components/ui/StatusDot"; import { StatusDot } from "@/components/ui/StatusDot";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { api, Host, HostCreate, HostUpdate, PolicyHierarchyGroup } from "@/lib/api"; import { api, Host, HostCreate, HostUpdate, PolicyHierarchyGroup } from "@/lib/api";
import { useReadOnly } from "@/lib/useReadOnly"; import { useNotAdmin } from "@/lib/useReadOnly";
import { ExternalLink, Pencil, Plus, Trash2 } from "lucide-react"; import { ExternalLink, Pencil, Plus, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -29,7 +29,7 @@ const emptyForm: HostCreate = {
}; };
export default function HostsPage() { export default function HostsPage() {
const readOnly = useReadOnly(); const readOnly = useNotAdmin();
const toast = useToast(); const toast = useToast();
const { t, errorMessage } = useLanguage(); const { t, errorMessage } = useLanguage();
const [hosts, setHosts] = useState<Host[] | null>(null); const [hosts, setHosts] = useState<Host[] | null>(null);
+19 -1
View File
@@ -9,7 +9,7 @@ import { api, ApiError } from "@/lib/api";
import { LOGIN_INFO_STORAGE_KEY } from "@/lib/loginInfo"; import { LOGIN_INFO_STORAGE_KEY } from "@/lib/loginInfo";
import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version"; import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLanguage } from "@/components/LanguageProvider"; import { useLanguage } from "@/components/LanguageProvider";
export default function LoginPage() { export default function LoginPage() {
@@ -23,6 +23,17 @@ export default function LoginPage() {
const [needCode, setNeedCode] = useState(false); const [needCode, setNeedCode] = useState(false);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const shakeRef = useRef<HTMLDivElement>(null); const shakeRef = useRef<HTMLDivElement>(null);
const [sso, setSso] = useState<{ enabled: boolean; name: string | null } | null>(null);
useEffect(() => {
api.oidcConfig().then(setSso).catch(() => setSso(null));
const code = new URLSearchParams(window.location.search).get("sso_error");
if (code) setError(t(`login.sso.errors.${["no_role", "state", "token", "provider", "throttled", "disabled"].includes(code) ? code : "failed"}`));
}, []);
function signInWithSso() {
window.location.href = `/api/auth/oidc/start?origin=${encodeURIComponent(window.location.origin)}`;
}
function shake() { function shake() {
const el = shakeRef.current; const el = shakeRef.current;
@@ -103,6 +114,13 @@ export default function LoginPage() {
</Button> </Button>
</div> </div>
</form> </form>
{sso?.enabled && !needCode && (
<div className="login-rise mt-4 border-t border-slate-200 pt-4" style={rise(340)}>
<Button type="button" variant="outline" onClick={signInWithSso} className="w-full justify-center">
{t("login.sso.button", { name: sso.name ?? "SSO" })}
</Button>
</div>
)}
</div> </div>
</div> </div>
<p className="login-rise mt-4 text-center text-xs text-slate-400" style={rise(380)}> <p className="login-rise mt-4 text-center text-xs text-slate-400" style={rise(380)}>
+331
View File
@@ -0,0 +1,331 @@
"use client";
import { InfoTip } from "@/components/ui/Tooltip";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { api, Host, OverlayInstructions, OverlayPeer, OverlaySettings } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { useNotAdmin } from "@/lib/useReadOnly";
import { Copy, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
const formatBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
export default function OverlayPage() {
const { t, errorMessage, locale } = useLanguage();
const notAdmin = useNotAdmin();
const toast = useToast();
const [settings, setSettings] = useState<OverlaySettings | null>(null);
const [peers, setPeers] = useState<OverlayPeer[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [error, setError] = useState<string | null>(null);
const [enabled, setEnabled] = useState(false);
const [endpoint, setEndpoint] = useState("");
const [port, setPort] = useState(51820);
const [subnet, setSubnet] = useState("10.99.0.0/24");
const [saving, setSaving] = useState(false);
const [adding, setAdding] = useState(false);
const [showing, setShowing] = useState<{ name: string; ip: string; instructions: OverlayInstructions } | null>(null);
const [editing, setEditing] = useState<OverlayPeer | null>(null);
async function load() {
try {
const data = await api.getOverlay();
setSettings(data.settings);
setPeers(data.peers);
setEnabled(data.settings.enabled);
setEndpoint(data.settings.endpoint ?? "");
setPort(data.settings.listen_port);
setSubnet(data.settings.subnet);
setError(null);
} catch (e) {
setError(errorMessage(e, "overlay.loadFailed"));
}
}
useEffect(() => {
load();
api.listHosts().then(setHosts).catch(() => {});
const timer = window.setInterval(load, 10000);
return () => window.clearInterval(timer);
}, []);
async function save(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
await api.updateOverlay({ enabled, endpoint, listen_port: port, subnet });
await load();
toast.show(t("overlay.saved"), "success");
} catch (err) {
setError(errorMessage(err, "common.saveFailed"));
} finally {
setSaving(false);
}
}
async function showInstructions(peer: OverlayPeer) {
try {
setShowing({ name: peer.name, ip: peer.ip, instructions: await api.overlayInstructions(peer.id) });
} catch (err) {
setError(errorMessage(err, "overlay.loadFailed"));
}
}
async function remove(peer: OverlayPeer) {
if (!confirm(t("overlay.deleteConfirm", { name: peer.name }))) return;
try {
await api.deleteOverlayPeer(peer.id);
await load();
toast.show(t("overlay.deleted", { name: peer.name }), "success");
} catch (err) {
setError(errorMessage(err, "common.deleteFailed"));
}
}
const hubTone = settings?.hub_state === "running" ? "success" : settings?.hub_state === "stale" ? "warning" : "neutral";
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="flex items-center gap-2 text-2xl font-semibold text-slate-900">
{t("overlay.title")}
<InfoTip text={t("overlay.intro")} size={18} />
</h1>
<fieldset disabled={notAdmin} className="contents">
<Button onClick={() => setAdding(true)} disabled={!settings?.enabled}>
<Plus size={16} />
{t("overlay.addPeer")}
</Button>
</fieldset>
</div>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<Card className="p-5">
{!settings ? <Skeleton className="h-24 w-full" /> : (
<form onSubmit={save} className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={enabled} disabled={notAdmin} onChange={(e) => setEnabled(e.target.checked)} />
{t("overlay.enable")}
</label>
{settings.enabled && <Badge tone={hubTone}>{t(`overlay.hub.${settings.hub_state}`)}</Badge>}
</div>
{settings.enabled && settings.hub_state === "absent" && <p className="text-sm text-amber-700">{t("overlay.hubAbsent")}</p>}
<fieldset disabled={!enabled || notAdmin} className="grid gap-3 disabled:opacity-50 sm:grid-cols-3">
<FieldGroup label={t("overlay.endpoint")} hint={t("overlay.endpointHint")}>
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="vpn.example.com" />
</FieldGroup>
<FieldGroup label={t("overlay.port")}>
<Input type="number" min={1} max={65535} value={port} onChange={(e) => setPort(Number(e.target.value))} />
</FieldGroup>
<FieldGroup label={t("overlay.subnet")} hint={t("overlay.subnetHint")}>
<Input value={subnet} onChange={(e) => setSubnet(e.target.value)} disabled={peers.length > 0} />
</FieldGroup>
</fieldset>
{settings.hub_public_key && (
<p className="text-xs text-slate-500">
{t("overlay.hubKey")} <code className="break-all font-mono">{settings.hub_public_key}</code> · {t("overlay.hubAddress")} <code className="font-mono">{settings.hub_address}</code>
</p>
)}
<Button type="submit" disabled={saving || notAdmin} loading={saving}>{t("common.save")}</Button>
</form>
)}
</Card>
<Card className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-5 py-3 font-medium">{t("overlay.colName")}</th>
<th className="px-5 py-3 font-medium">{t("overlay.colAddress")}</th>
<th className="px-5 py-3 font-medium">{t("overlay.colStatus")}</th>
<th className="px-5 py-3 font-medium">{t("overlay.colHost")}</th>
<th className="px-5 py-3 font-medium"><span className="sr-only">{t("common.actions")}</span></th>
</tr>
</thead>
<tbody>
{peers.length === 0 && (
<tr><td colSpan={5} className="px-5 py-8 text-center text-slate-500">{t("overlay.empty")}</td></tr>
)}
{peers.map((peer) => (
<tr key={peer.id} className="border-b border-slate-100 last:border-0">
<td className="px-5 py-3 font-medium text-slate-900">{peer.name}</td>
<td className="px-5 py-3 font-mono text-xs text-slate-600">{peer.ip}</td>
<td className="px-5 py-3">
{!peer.public_key ? <Badge tone="warning">{t("overlay.needsKey")}</Badge> : peer.connected ? (
<span className="flex flex-col gap-0.5">
<Badge tone="success">{t("overlay.connected")}</Badge>
<span className="text-xs text-slate-500">
{peer.last_handshake_at ? new Date(peer.last_handshake_at).toLocaleTimeString(locale) : ""} · ↓{formatBytes(peer.rx_bytes)} ↑{formatBytes(peer.tx_bytes)}
</span>
</span>
) : <Badge tone="neutral">{t("overlay.waiting")}</Badge>}
</td>
<td className="px-5 py-3">
{peer.host_id ? <Link className="text-brand-600 hover:underline" href={`/hosts/${peer.host_id}`}>{peer.host_name}</Link> : <span className="text-slate-300">—</span>}
</td>
<td className="px-5 py-3 text-right">
<fieldset disabled={notAdmin} className="contents">
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => showInstructions(peer)}>{t("overlay.instructions")}</Button>
<Button variant="outline" onClick={() => setEditing(peer)}>{t("overlay.link")}</Button>
<Button variant="dangerSubtle" onClick={() => remove(peer)}><Trash2 size={14} />{t("rules.delete")}</Button>
</div>
</fieldset>
</td>
</tr>
))}
</tbody>
</table>
</Card>
{adding && (
<AddPeerModal
onClose={() => setAdding(false)}
onCreated={async (created) => { setAdding(false); setShowing(created); await load(); }}
/>
)}
{showing && <InstructionsModal data={showing} onClose={() => setShowing(null)} />}
{editing && (
<LinkModal
peer={editing}
hosts={hosts}
onClose={() => setEditing(null)}
onSaved={async () => { setEditing(null); await load(); toast.show(t("overlay.saved"), "success"); }}
/>
)}
</div>
);
}
function AddPeerModal({ onClose, onCreated }: { onClose: () => void; onCreated: (c: { name: string; ip: string; instructions: OverlayInstructions }) => void }) {
const { t, errorMessage } = useLanguage();
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
onCreated(await api.createOverlayPeer(name.trim()));
} catch (err) {
setError(errorMessage(err, "common.saveFailed"));
} finally {
setBusy(false);
}
}
return (
<Modal title={t("overlay.addPeer")} onClose={onClose}>
<form onSubmit={submit} className="space-y-4">
<FieldGroup label={t("overlay.colName")} hint={t("overlay.nameHint")}>
<Input required maxLength={80} value={name} onChange={(e) => setName(e.target.value)} placeholder="filiale-nord" />
</FieldGroup>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" disabled={busy} loading={busy}>{t("overlay.create")}</Button>
</div>
</form>
</Modal>
);
}
function CopyRow({ label, value }: { label: string; value: string }) {
const { t } = useLanguage();
const toast = useToast();
return (
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</div>
<code className="block break-all font-mono text-xs text-slate-900">{value}</code>
</div>
<Button type="button" variant="ghost" onClick={() => { void navigator.clipboard?.writeText(value); toast.show(t("tokens.copied"), "success"); }}>
<Copy size={14} />
</Button>
</div>
);
}
function InstructionsModal({ data, onClose }: { data: { name: string; ip: string; instructions: OverlayInstructions }; onClose: () => void }) {
const { t } = useLanguage();
const i = data.instructions;
return (
<Modal title={t("overlay.howTitle", { name: data.name })} onClose={onClose}>
<div className="space-y-4 text-sm text-slate-700">
<p>{t("overlay.howIntro")}</p>
<h3 className="font-medium text-slate-900">{t("overlay.step1")}</h3>
<div className="space-y-2 rounded-md border border-slate-200 p-3">
<p className="text-xs text-slate-500">{t("overlay.step1Hint")}</p>
<CopyRow label={t("overlay.fieldAddress")} value={i.tunnel_address} />
</div>
<h3 className="font-medium text-slate-900">{t("overlay.step2")}</h3>
<div className="space-y-2 rounded-md border border-slate-200 p-3">
<CopyRow label={t("overlay.fieldPublicKey")} value={i.hub_public_key} />
<CopyRow label={t("overlay.fieldPsk")} value={i.psk} />
<CopyRow label={t("overlay.fieldEndpoint")} value={i.endpoint ?? ""} />
<CopyRow label={t("overlay.fieldAllowed")} value={i.allowed_ips} />
<CopyRow label={t("overlay.fieldKeepalive")} value={String(i.keepalive)} />
</div>
<h3 className="font-medium text-slate-900">{t("overlay.step3")}</h3>
<p>{t("overlay.step3Text", { hub: i.hub_ip })}</p>
<h3 className="font-medium text-slate-900">{t("overlay.step4")}</h3>
<p>{t("overlay.step4Text")}</p>
<h3 className="font-medium text-slate-900">{t("overlay.step5")}</h3>
<div className="space-y-2 rounded-md border border-slate-200 p-3">
<p className="text-xs text-slate-500">{t("overlay.step5Text")}</p>
<CopyRow label={t("overlay.fieldUrl")} value={i.host_url} />
</div>
<div className="flex justify-end"><Button onClick={onClose}>{t("tokens.done")}</Button></div>
</div>
</Modal>
);
}
function LinkModal({ peer, hosts, onClose, onSaved }: { peer: OverlayPeer; hosts: Host[]; onClose: () => void; onSaved: () => void }) {
const { t, errorMessage } = useLanguage();
const [key, setKey] = useState(peer.public_key ?? "");
const [hostId, setHostId] = useState(peer.host_id ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await api.updateOverlayPeer(peer.id, { public_key: key.trim(), ...(hostId ? { host_id: hostId } : { clear_host: true }) });
onSaved();
} catch (err) {
setError(errorMessage(err, "common.saveFailed"));
} finally {
setBusy(false);
}
}
return (
<Modal title={t("overlay.linkTitle", { name: peer.name })} onClose={onClose}>
<form onSubmit={submit} className="space-y-4">
<FieldGroup label={t("overlay.firewallKey")} hint={t("overlay.firewallKeyHint")}>
<Input value={key} onChange={(e) => setKey(e.target.value)} className="font-mono" placeholder="44 Zeichen, endet auf =" />
</FieldGroup>
<FieldGroup label={t("overlay.colHost")} hint={t("overlay.hostHint")}>
<Select value={hostId} onChange={(e) => setHostId(e.target.value)}>
<option value="">—</option>
{hosts.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
</Select>
</FieldGroup>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" disabled={busy} loading={busy}>{t("common.save")}</Button>
</div>
</form>
</Modal>
);
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more