Compare commits

...
2 Commits
Author SHA1 Message Date
Julian Boldt 1fc2d2c87c Release v1.11.1
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:

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

- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
  blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
  bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
  Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
  jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
  (Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
  sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
  Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
  das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
2026-10-09 18:35:53 +02:00
Julian Boldt 9889be64ef Release v1.11.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
Sicherheitsüberprüfung vom 2026-10-07, umgesetzte Korrekturen.
**Verhaltensänderungen**, die beim Update auffallen können:

- **Backup-Rohdownload nur für Admins**. Nur-Lese-Konten sehen weiter die
  Liste der Snapshots, bekommen die `config.xml` (enthält Passwort-Hashes, VPN-Schlüssel)
  aber nicht mehr; jeder Versuch steht im Audit-Verlauf (neuer Status „abgelehnt“).
- **Passwortwechsel/-reset beendet alle Sitzungen** des Kontos; ebenso
  2FA-Reset durch einen Admin. 2FA aktivieren/deaktivieren beendet die *anderen* Sitzungen.
- **SMTP über SSL/STARTTLS prüft Zertifikat und Hostname**. Mailserver mit
  privater CA: `SMTP_CA_FILE` setzen. SMTP-Anmeldung ohne Verschlüsselung ist gesperrt
  (`ALLOW_INSECURE_SMTP_AUTH=true` hebt das bewusst auf).
- **Firewalls nur noch per `https://`** — beim Anlegen und bei jeder Anfrage;
  ein vorhandener `http://`-Host zeigt „offline“, statt Zugangsdaten im Klartext zu senden
  (`ALLOW_INSECURE_FIREWALL_URLS=true` nur für Labor-Setups).
- **VPN-Push (IPsec/OpenVPN/WireGuard) verlangt das Vorschau-Token** und das
  Token ändert sich, sobald nur das Geheimnis (PSK/Schlüssel) rotiert wurde. Die Vorschau
  zeigt „unverändert“ jetzt korrekt, statt jeden Tunnel dauerhaft als geändert.
- **Host löschen** ist auch bei VPN-, Zertifikats- und ACME-Objekten auf der Firewall
  gesperrt, bis diese entfernt sind; Schlüsseldateien nie bereitgestellter
  Entwürfe werden mit entfernt.
- Benachrichtigungs-Geheimnisse liegen jetzt im Volume `notification_secrets`
 . Ohne lesbares Signatur-/SMTP-Geheimnis wird **nichts** mehr gesendet
  (vorher: unsigniert/ohne Login). Wer Geheimnisse gesetzt hat, muss sie nach dem
  Update einmal neu eingeben, falls der Container zwischenzeitlich ersetzt wurde.

Weitere Korrekturen:

- Oberfläche: Der „Bestätigen“-Button steht jetzt am Ende der Deploy-Ergebnis-Zeile statt
  in der Aktionszeile (Regeln, Aliase, Port-Forwards, 1:1-NAT, Outbound-NAT,
  Hierarchie-Regeln); die Aktionszeile springt nicht mehr, wenn ein Deployment offen wird.

- **Deployments hinterlassen vor dem ersten Firewall-Aufruf eine dauerhafte Spur**
  (gemeinsamer Ablauf `deploy_flow.py` für Regeln, Aliase, Port-Forwards, 1:1-NAT,
  Outbound-NAT und Hierarchie-Regeln): Bei Absturz, verlorener Antwort oder Timeout bleibt
  die Rollback-Frist erhalten und der Sweeper macht die Änderung rückgängig. Wo die Firewall
  eine neue Regel möglicherweise angelegt hat, ohne dass eine UUID bekannt ist, steht der
  Eintrag auf „Rollback fehlgeschlagen“ mit dem Hinweis „Ausgang unklar“ (blockiert
  erneutes Deployen, löst die Benachrichtigung aus); „Rollback wiederholen“ entfernt ihn.
  Von der Firewall beantwortete Ablehnungen und Verbindungsfehler bleiben einfache Fehler.
- TOTP- und Recovery-Codes sind auch bei gleichzeitigen Anfragen genau einmal
  verwendbar (Zeilensperre).
- Login-Sperre: begrenzter Speicher, Argon2 läuft außerhalb des Event-Loops mit
  Parallelitätsgrenze, 2FA-Einrichtung/-Aktivierung/-Deaktivierung und Passwortwechsel
  haben ein Versuchslimit; offene 2FA-Einrichtung läuft nach 15 Minuten ab; Proxy
  begrenzt API-Anfragen auf 2 MB.
- Fehlermeldungen der Firewall werden vor Speicherung/Anzeige von Geheimnissen
  bereinigt und gekürzt; Validierungsfehler (422) geben Eingaben nicht mehr
  zurück; Webhook-URLs (oft mit Token) sieht ein Nur-Lese-Konto nur noch als
  Host.
- Audit: MFA/Passwort, VPN, Zertifikate/ACME, Benachrichtigungen und Backup-Exporte
  werden aufgezeichnet, ebenso abgelehnte Schreibversuche von Nur-Lese-Konten.
- Same-Origin-Prüfung berücksichtigt das Schema bei `COOKIE_SECURE=true`;
  Warnung beim Start, wenn `FRONTEND_ORIGIN` https ist, das Cookie aber nicht `Secure`
 . Geheimnisdateien werden atomar mit Modus 0600 angelegt.
- Frontend-Abhängigkeiten (`sharp`, `source-map-js`, `brace-expansion`) aktualisiert.
  Offen: `braces`/`postcss-selector-parser` nur in Build-Werkzeugen (Tailwind 3 /
  ESLint), nicht im Laufzeit-Image; für `braces` gibt es keine gepatchte Version.
2026-10-07 13:34:54 +02:00
62 changed files with 691 additions and 106 deletions

No files matched your search

+72
View File
@@ -31,6 +31,78 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.11.1] "Merlin" — 2026-10-09
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:
- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
`/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
`cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
`/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
(VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
`SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
(„instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
(„Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
„Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
allen Seiten, hell und dunkel, nichts mehr.
- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
(Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
## [1.11.0] "Merlin" — 2026-10-07
Sicherheitsüberprüfung vom 2026-10-07, umgesetzte Korrekturen.
+2 -2
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](website/assets/screenshots/de/rules-light-desktop.webp)
> **Stand:** `1.11.0-rc2` ist der aktuelle öffentliche Release-Candidate (er löst `1.11.0-rc1` und die `1.10.x`-Kandidaten ab). Entwickelt wurde gegen echte OPNsense-Instanzen, eine frische Installation aus den veröffentlichten Images ist aber noch nicht im Feld erprobt — bitte Ecken und Kanten melden.
> **Stand:** `1.11.1` 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
@@ -46,7 +46,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.11.0-rc2
OPNEXUS_VERSION=1.11.1
```
```bash
+2 -2
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](website/assets/screenshots/en/rules-light-desktop.webp)
> **Status:** `1.11.0-rc2` is the current public release candidate (it replaces `1.11.0-rc1` and the `1.10.x` candidates). It has been developed against real OPNsense instances, but a fresh install from the published images has not been field-tested yet — please expect rough edges and report them.
> **Status:** `1.11.1` 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
@@ -48,7 +48,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.11.0-rc2
OPNEXUS_VERSION=1.11.1
```
```bash
+4
View File
@@ -134,6 +134,10 @@ class ErrorCode(str, Enum):
OPEN_DEPLOYMENT_DRAFT_UNCHANGED = "OPEN_DEPLOYMENT_DRAFT_UNCHANGED"
OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY = "OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY"
OPEN_RESTORE_BLOCKS_DEPLOY = "OPEN_RESTORE_BLOCKS_DEPLOY"
UPSTREAM_REJECTED = "UPSTREAM_REJECTED"
INVALID_CHARACTERS = "INVALID_CHARACTERS"
SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
SERVICE_LOCKED = "SERVICE_LOCKED"
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
+16
View File
@@ -7,6 +7,9 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import DBAPIError
from app.errors import ErrorCode
from app import models # noqa: F401 - registers models with Base.metadata
from app.auth_middleware import AuthMiddleware
@@ -289,6 +292,19 @@ async def _safe_validation_error(request: Request, exc: RequestValidationError):
)
@app.exception_handler(DBAPIError)
async def _database_text_errors(request: Request, exc: DBAPIError):
"""A NUL byte in any text field cannot be stored by Postgres and used to
surface as a 500 plus a dropped connection (found by fuzzing). That is bad
input, not a server fault. Anything else stays a normal 500."""
if type(exc.orig).__name__.endswith("CharacterNotInRepertoireError") or "0x00" in str(exc.orig):
return JSONResponse(
status_code=422,
content={"detail": {"code": ErrorCode.INVALID_CHARACTERS.value, "params": {}}},
)
raise exc
app.add_middleware(AuthMiddleware)
# Inside CORS so preflights are answered first; outside Auth so a forged
# cross-site write is rejected before any session lookup happens.
+1 -1
View File
@@ -146,7 +146,7 @@ async def delete_alias(alias_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_alias(db, alias)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(alias, attribute_names=["deployments"])
await db.delete(alias)
await db.commit()
+3 -3
View File
@@ -112,7 +112,7 @@ async def delete_managed_certificate(cert_id: uuid.UUID, db: AsyncSession = Depe
try:
await managed_certificate_service.delete_certificate(db, cert, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
# ── ACME accounts ──────────────────────────────────────────────────────
@@ -197,7 +197,7 @@ async def delete_acme_account(account_id: uuid.UUID, db: AsyncSession = Depends(
try:
await acme_service.delete_account(db, account, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
# ── ACME certificate requests ─────────────────────────────────────────
@@ -298,4 +298,4 @@ async def delete_acme_certificate_request(request_id: uuid.UUID, db: AsyncSessio
try:
await acme_service.delete_certificate_request(db, req, host)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+1 -1
View File
@@ -174,7 +174,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
try:
await undeploy_tunnel(db, tunnel)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(tunnel, attribute_names=["deployments"])
ipsec_psk_store.delete_secret(tunnel.id)
await db.delete(tunnel)
+2 -2
View File
@@ -168,7 +168,7 @@ async def delete_one_to_one_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(
try:
await undeploy_one_to_one(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -268,4 +268,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_one_to_one_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+1 -1
View File
@@ -176,7 +176,7 @@ async def delete_instance(instance_id: uuid.UUID, db: AsyncSession = Depends(get
try:
await undeploy_instance(db, instance)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(instance, attribute_names=["deployments"])
openvpn_key_store.delete_secret(instance.id)
await db.delete(instance)
+2 -2
View File
@@ -175,7 +175,7 @@ async def delete_outbound_nat_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
try:
await undeploy_outbound_nat(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -275,7 +275,7 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_outbound_nat_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
@router.get("/hosts/{host_id}/mode")
+1 -1
View File
@@ -294,7 +294,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_hierarchy_rule(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
+2 -2
View File
@@ -170,7 +170,7 @@ async def delete_port_forward_rule(rule_id: uuid.UUID, db: AsyncSession = Depend
try:
await undeploy_port_forward(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
@@ -270,4 +270,4 @@ async def live_rules(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
return split_rows(await opnsense_client.search_port_forward_rules(host))
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
+1 -1
View File
@@ -172,7 +172,7 @@ async def delete_rule(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
try:
await undeploy_rule(db, rule)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(rule, attribute_names=["deployments"])
await db.delete(rule)
await db.commit()
+13
View File
@@ -31,6 +31,19 @@ async def _control(host_id: uuid.UUID, name: str, action: str, fn, db: AsyncSess
await policy_lock(db)
host = await host_or_404(db, host_id)
await ensure_no_maintenance(db, [host])
# The UI greys out locked services, but the API must not rely on that:
# unknown names are accepted by the firewall with "ok", and locked ones
# (pf, webgui, routing, ...) can be stopped through it.
try:
listing = await client.search_services(host)
except client.OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
rows = listing.get("rows", []) if isinstance(listing, dict) else []
row = next((r for r in rows if name in (r.get("id"), r.get("name"))), None)
if row is None:
raise api_error(404, ErrorCode.SERVICE_NOT_FOUND, service=name)
if row.get("locked") in (1, "1", True):
raise api_error(409, ErrorCode.SERVICE_LOCKED, service=name)
await audit_service.persist(
kind="host", action=action, status="requested",
host_id=str(host.id), host_name=host.name,
+1 -1
View File
@@ -187,7 +187,7 @@ async def delete_tunnel(tunnel_id: uuid.UUID, db: AsyncSession = Depends(get_db)
try:
await undeploy_tunnel(db, tunnel)
except OpnsenseAPIError as exc:
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
raise api_error(502, ErrorCode.UPSTREAM_REJECTED, reason=str(exc)) from exc
await db.refresh(tunnel, attribute_names=["deployments"])
wireguard_key_store.delete_private_key(tunnel.id)
wireguard_key_store.delete_psk(tunnel.id)
+4
View File
@@ -123,6 +123,9 @@ CertStatus = Literal["valid", "expiring_soon", "expired", "unknown"]
class HostCertificate(BaseModel):
uuid: str
# OPNsense's own cross-references (OpenVPN cert/ca, Trust-Store caref) use
# this id, not the row UUID.
refid: str = ""
descr: str
common_name: str | None
issuer: str
@@ -135,6 +138,7 @@ class HostCertificate(BaseModel):
class HostCa(BaseModel):
uuid: str
refid: str = ""
descr: str
common_name: str | None
valid_from: datetime | None
+1 -1
View File
@@ -135,7 +135,7 @@ async def undeploy_alias(db: AsyncSession, alias: FirewallAlias) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+19 -2
View File
@@ -15,10 +15,27 @@ from app.models.audit import AuditEvent
context = ContextVar("audit_context", default=None)
# Column widths of audit_events. entity_id/host_id/action are taken from the
# request path BEFORE the router validated it, so a junk segment (300 chars,
# or a NUL byte which Postgres text cannot store) used to turn into a 500 or a
# dropped connection instead of a plain 404/422 (found by fuzzing, 2026-10-09).
_LIMITS = {"correlation_id": 36, "actor": 150, "kind": 32, "action": 40, "status": 32,
"entity_id": 36, "entity_name": 255, "host_id": 36, "host_name": 255}
def _clean(key, value):
if not isinstance(value, str):
return value
value = value.replace("\x00", "")
limit = _LIMITS.get(key)
return value[:limit] if limit else value
def make_event(**values):
ctx = context.get() or {}
return AuditEvent(actor=ctx.get("actor", "system:rollback"),
correlation_id=ctx.get("id", str(uuid.uuid4())), **values)
values = {k: _clean(k, v) for k, v in values.items()}
return AuditEvent(actor=_clean("actor", ctx.get("actor", "system:rollback")),
correlation_id=_clean("correlation_id", ctx.get("id", str(uuid.uuid4()))), **values)
async def persist(**values):
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.carp_maintenance_window import CarpMaintenanceWindow
from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation
from app.services import audit_service, host_status_service
from app.services import opnsense_client as client
@@ -63,6 +64,26 @@ async def close_windows_for_manual_leave(db: AsyncSession, host: OpnsenseHost) -
await db.commit()
async def _close_enter_operations(db: AsyncSession, host: OpnsenseHost, message: str) -> None:
"""Closes the host's still-open `carp_enter` maintenance record once the
window has been resolved by the timer. Without this the record stays
"submitted" forever: acknowledge cannot succeed (the host is no longer in
maintenance) and `ensure_no_maintenance` keeps blocking every deploy and
update in the cluster until someone cancels it by hand."""
result = await db.execute(
select(HostOperation).where(
HostOperation.host_id == host.id, HostOperation.action == "carp_enter", HostOperation.status == "submitted",
)
)
for op in result.scalars().all():
op.status = "cancelled"
op.message = message
await audit_service.persist(
kind="host", action="carp_enter", status="cancelled",
host_id=str(host.id), host_name=host.name, entity_id=str(op.id), message=message,
)
async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
result = await db.execute(
select(CarpMaintenanceWindow).where(
@@ -88,6 +109,7 @@ async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
window.status = "cancelled_manual"
window.resolved_at = _now()
window.message = "Host war beim Ablauf bereits nicht mehr im CARP-Wartungsmodus."
await _close_enter_operations(db, host, window.message)
resolved.append(window)
continue
result_payload = await client.toggle_carp_maintenance(host)
@@ -96,6 +118,7 @@ async def sweep_expired(db: AsyncSession) -> list[CarpMaintenanceWindow]:
window.status = "reverted"
window.resolved_at = _now()
window.message = "Automatisch nach Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet."
await _close_enter_operations(db, host, window.message)
await audit_service.persist(
kind="host", action="carp_auto_revert", status="submitted",
host_id=str(host.id), host_name=host.name, entity_id=str(window.id),
+4 -1
View File
@@ -254,6 +254,9 @@ async def _deploy_to_host(db: AsyncSession, tunnel: IpsecTunnel, host: OpnsenseH
try:
await _write_config(host, payloads, deployment)
# Same trap as WireGuard: with the global IPsec switch off the config
# is stored but nothing runs, and reconfigure still answers ok.
await opnsense_client.ensure_ipsec_enabled(host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
@@ -324,7 +327,7 @@ async def undeploy_tunnel(db: AsyncSession, tunnel: IpsecTunnel) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
@@ -44,6 +44,7 @@ def _row_to_certificate(row: dict) -> HostCertificate:
return HostCertificate(
uuid=row.get("uuid", ""),
refid=row.get("refid", ""),
descr=row.get("descr") or "(ohne Beschreibung)",
common_name=row.get("commonname") or None,
issuer=row.get("%caref") or "Selbstsigniert",
@@ -68,6 +69,7 @@ def _row_to_ca(row: dict) -> HostCa:
return HostCa(
uuid=row.get("uuid", ""),
refid=row.get("refid", ""),
descr=row.get("descr") or "(ohne Beschreibung)",
common_name=row.get("commonname") or None,
valid_from=valid_from,
+5 -1
View File
@@ -62,6 +62,10 @@ async def _live_rows_or_none(host: OpnsenseHost, nat_type: str) -> list[dict] |
async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> list[dict]:
return (await compute_cluster_drift_with_reachable(hosts, nat_type))[0]
async def compute_cluster_drift_with_reachable(hosts: list[OpnsenseHost], nat_type: str) -> tuple[list[dict], set]:
"""Same shape/contract as rule_drift_service.compute_cluster_drift:
one row per fingerprint missing from at least one reachable host."""
_, fingerprint_fn = _SPECS[nat_type]
@@ -78,4 +82,4 @@ async def compute_cluster_drift(hosts: list[OpnsenseHost], nat_type: str) -> lis
for entry in by_fingerprint.values():
entry["reachable_host_ids"] = reachable_ids
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)]
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)], reachable_ids
+19 -6
View File
@@ -15,6 +15,7 @@ from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.port_forward_deployment import PortForwardDeployment
from app.models.rule_deployment import RuleDeployment
from app.services import nat_drift_service, rule_drift_service
from app.services.notification_dedup import Problems
from app.services.host_status_service import get_statuses
from app.services.live_certificates_service import get_live_certificates
from app.services.opnsense_client import OpnsenseAPIError
@@ -53,13 +54,15 @@ async def check_host_offline(db: AsyncSession) -> dict[str, dict]:
async def check_certificates(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
problems: dict[str, dict] = {}
problems = Problems()
for host in hosts:
try:
certs = await get_live_certificates(host)
except OpnsenseAPIError:
# Unreachable — already reported by check_host_offline, no need
# to double-report it here as a certificate problem.
# to double-report it here as a certificate problem. Its open
# certificate problems stay as they are (not evaluable now).
problems.preserve_prefixes.add(f"{host.id}:")
continue
for cert in certs:
if cert.status not in ("expiring_soon", "expired"):
@@ -83,12 +86,18 @@ async def check_rule_drift(db: AsyncSession) -> dict[str, dict]:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems: dict[str, dict] = {}
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
names_by_id = {h.id: h.name for h in cluster_hosts}
for row in await rule_drift_service.compute_cluster_drift(cluster_hosts):
rows, reachable = await rule_drift_service.compute_cluster_drift_with_reachable(cluster_hosts)
if len(reachable) < 2:
# A member is down (reboot/update): drift cannot be judged, so keep
# what is open instead of resolving it and re-opening it later.
problems.preserve_prefixes.add(f"{cluster_name}:")
continue
for row in rows:
key = f"{cluster_name}:{row['fingerprint']}"
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
missing_names = sorted(
@@ -113,13 +122,17 @@ async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems: dict[str, dict] = {}
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
names_by_id = {h.id: h.name for h in cluster_hosts}
for nat_type in _NAT_TYPE_LABELS:
for row in await nat_drift_service.compute_cluster_drift(cluster_hosts, nat_type):
rows, reachable = await nat_drift_service.compute_cluster_drift_with_reachable(cluster_hosts, nat_type)
if len(reachable) < 2:
problems.preserve_prefixes.add(f"{cluster_name}:{nat_type}:")
continue
for row in rows:
key = f"{cluster_name}:{nat_type}:{row['fingerprint']}"
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
missing_names = sorted(
+13 -1
View File
@@ -13,6 +13,17 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
class Problems(dict):
"""The result of a check ({dedup_key: context}) plus the key prefixes the
check could NOT evaluate this round (e.g. a cluster member is offline, so
drift is not computable). Open problems under such a prefix are left alone
instead of being resolved and re-opened on the next round."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.preserve_prefixes: set[str] = set()
async def diff_and_update(
db: AsyncSession, problem_type: str, current: dict[str, dict]
) -> tuple[list[NotificationProblem], list[NotificationProblem]]:
@@ -74,8 +85,9 @@ async def diff_and_update(
existing.last_seen_at = now
existing.context = context
preserve = tuple(getattr(current, "preserve_prefixes", ()))
for key, row in open_rows.items():
if key not in current:
if key not in current and not (preserve and key.startswith(preserve)):
row.status = "resolved"
row.resolved_at = now
resolved.append(row)
@@ -140,7 +140,7 @@ async def undeploy_one_to_one(db: AsyncSession, rule: OneToOneRule) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+21 -3
View File
@@ -15,7 +15,8 @@ citations):
Instances.Instance: vpnid (VPNIdField, Required, MinimumValue 1, NO
<Default> — never sent by this app; OPNsense's own UI never prompts for
one either, so its field-type presumably auto-assigns one the same way
on addAction — NOT independently verified, flagged rather than guessed),
on addAction — VERIFIED LIVE 2026-10-09 on 26.7.5: it is NOT auto-assigned, add() fails
with "instance.vpnid: A value is required." — see _next_vpnid),
enabled (BooleanField, Default 1), dev_type (OptionValues
tun/tap/ovpn(DCO), Default "tun" — never sent, relies on the model
default exactly like IPsec relies on Child.mode defaulting to "tunnel"),
@@ -27,7 +28,8 @@ citations):
section below, this is the key finding that reshaped v1), server
(NetworkField, NetMaskRequired Y — server-role tunnel network), cert /
ca (CertificateField, i.e. ModelRelationField into OPNsense's own
Trust\\Cert store), tls_key (ModelRelationField -> this SAME model's
Trust\\Cert store; values are the Trust-Store REFIDs, not row UUIDs —
verified live 2026-10-09), tls_key (ModelRelationField -> this SAME model's
StaticKeys.StaticKey, display "description,mode"), auth/data-ciphers
(cipher suite OptionFields — v1 always omits these, relying on
OPNsense's own defaults, exactly like IPsec's proposals default to
@@ -238,6 +240,18 @@ def build_payloads(instance: OpenVpnInstance, host_id: uuid.UUID) -> dict:
}
async def _next_vpnid(host: OpnsenseHost) -> str:
data = await opnsense_client.search_openvpn_instances(host)
rows = data.get("rows", []) if isinstance(data, dict) else []
used = []
for row in rows:
try:
used.append(int(row.get("vpnid")))
except (TypeError, ValueError):
continue
return str(max(used, default=0) + 1)
async def _write_config(host: OpnsenseHost, payloads: dict, deployment: OpenVpnInstanceDeployment) -> None:
"""Writes the StaticKey (if enabled) then the Instance, in that
dependency order, persisting each UUID onto `deployment` as soon as
@@ -270,6 +284,10 @@ async def _write_config(host: OpnsenseHost, payloads: dict, deployment: OpenVpnI
if deployment.opnsense_instance_uuid:
await opnsense_client.set_openvpn_instance(host, deployment.opnsense_instance_uuid, instance_wire)
else:
# vpnid is Required and the firewall does NOT assign it (verified live
# on 26.7: "instance.vpnid: A value is required."), so pick the next
# free one. It stays untouched on later set() calls.
instance_wire["vpnid"] = await _next_vpnid(host)
result = await opnsense_client.add_openvpn_instance(host, instance_wire)
deployment.opnsense_instance_uuid = result.get("uuid") if isinstance(result, dict) else None
if not deployment.opnsense_instance_uuid:
@@ -370,7 +388,7 @@ async def undeploy_instance(db: AsyncSession, instance: OpenVpnInstance) -> None
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+47 -22
View File
@@ -557,6 +557,19 @@ async def del_ipsec_psk(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/ipsec/preSharedKeys/delItem/{uuid_}"), "IPsec Pre-Shared-Key entfernen", {"deleted", "not found"})
async def ensure_ipsec_enabled(host: OpnsenseHost) -> bool:
"""Switch the service-wide IPsec flag on (`ipsec/connections/isEnabled` /
`toggle/1`, ConnectionsController — it is NOT part of the service
controller). Without it the connections are stored but no daemon runs
(live 26.7.6: is_enabled=false, service status "disabled", yet
reconfigure answered ok). Returns True when it had to be switched on."""
data = await _request(host, "GET", "/ipsec/connections/is_enabled")
if isinstance(data, dict) and data.get("enabled") is True:
return False
_mutation_result(await _request(host, "POST", "/ipsec/connections/toggle/1"), "IPsec aktivieren", {"ok"})
return True
async def reconfigure_ipsec(host: OpnsenseHost) -> dict:
"""`ipsec/service/reconfigure` (ServiceController::reconfigureAction,
inherited from ApiMutableServiceControllerBase) — soft-reloads a
@@ -756,19 +769,12 @@ async def reconfigure_openvpn(host: OpnsenseHost) -> dict:
# ServiceController's own `$internalServiceEnabled = 'enabled'` /
# `$internalServiceClass = General::class` tie together for the
# framework's generic service-status plumbing. NOT INDEPENDENTLY
# VERIFIED: whether `wireguard configure` (the configd action
# reconfigureAction shells out to) actually brings up any wg-quick
# interface end-to-end while General.enabled=0 — reconfigureAction's
# OWN PHP body never reads $internalServiceEnabled at all (it always
# runs unconditionally), so this depends on configd's own action
# script content, which is out of the controller/model source layer
# this app's verification standard covers (same boundary IPsec/
# OpenVPN draw). Flagged as a real, source-confirmed structural
# difference from IPsec/OpenVPN (neither of which have an equivalent
# separate master switch) rather than silently ignored — see README:
# operators should confirm "VPN > WireGuard > General" is enabled at
# least once via the OPNsense UI before relying on OPNexus-pushed
# tunnels.
# VERIFIED LIVE (26.7.5, 2026-10-09): with General.enabled=0 a deploy
# writes Server+Peer correctly but NO wg interface comes up and
# `wireguard/service/show` stays empty, while OPNexus used to report
# "success". Enabling it (wireguard/general/set + reconfigure) brings
# wg0 up. wireguard_deploy_service therefore switches it on before the
# reconfigure (ensure_wireguard_enabled below); undeploy never touches it.
#
# Field names verified against Server.xml/Client.xml (fetched directly,
# not summarized) — see wireguard_deploy_service.py for the exact
@@ -821,6 +827,21 @@ async def del_wireguard_peer(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/wireguard/client/delClient/{uuid_}"), "WireGuard-Peer entfernen", {"deleted", "not found"})
async def ensure_wireguard_enabled(host: OpnsenseHost) -> bool:
"""Turn the service-wide WireGuard switch (General.enabled, default 0) on
if it is off. Returns True when it had to be switched on."""
data = await _request(host, "GET", "/wireguard/general/get")
general = data.get("general", {}) if isinstance(data, dict) else {}
if str(general.get("enabled", "0")) == "1":
return False
_mutation_result(
await _request(host, "POST", "/wireguard/general/set", json={"general": {"enabled": "1"}}),
"WireGuard-Dienst aktivieren",
{"saved"},
)
return True
async def reconfigure_wireguard(host: OpnsenseHost) -> dict:
"""`wireguard/service/reconfigure` (ServiceController::reconfigureAction)
— runs interface registration + template reload + `wireguard configure`.
@@ -838,6 +859,10 @@ async def reconfigure_wireguard(host: OpnsenseHost) -> dict:
# CertController.php directly) — see managed_certificate_service.py's
# module docstring for the full field-mapping writeup.
#
# URL SEGMENTS ARE NOT PHP METHOD NAMES: the router strips the `Action`
# suffix, so addAction() is POST /trust/cert/add (the `...Action` URL is a
# 404 — found live 2026-10-09 on 26.7.5, the respx mocks had hidden it).
#
# CertController extends ApiMutableModelControllerBase exactly like
# FilterController/ConnectionsController/etc: searchAction (already used
# via search_certificates() above) / getAction / addAction / setAction /
@@ -866,11 +891,11 @@ def _trust_cert_result(data: Any, operation: str) -> dict:
async def add_trust_cert(host: OpnsenseHost, payload: dict) -> dict:
return _trust_cert_result(await _request(host, "POST", "/trust/cert/addAction", json={"cert": payload}), "Zertifikat anlegen")
return _trust_cert_result(await _request(host, "POST", "/trust/cert/add", json={"cert": payload}), "Zertifikat anlegen")
async def set_trust_cert(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
return _trust_cert_result(await _request(host, "POST", f"/trust/cert/setAction/{uuid_}", json={"cert": payload}), "Zertifikat ändern")
return _trust_cert_result(await _request(host, "POST", f"/trust/cert/set/{uuid_}", json={"cert": payload}), "Zertifikat ändern")
async def del_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
@@ -879,7 +904,7 @@ async def del_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
# matching every other del* call in this module (add_alias_item's
# del_alias_item sibling etc.), so this reuses the same
# {"deleted", "not found"} results set they all share.
return _mutation_result(await _request(host, "POST", f"/trust/cert/delAction/{uuid_}"), "Zertifikat entfernen", {"deleted", "not found"})
return _mutation_result(await _request(host, "POST", f"/trust/cert/del/{uuid_}"), "Zertifikat entfernen", {"deleted", "not found"})
async def get_trust_cert(host: OpnsenseHost, uuid_: str) -> dict:
@@ -940,15 +965,15 @@ def _acme_result(data: Any, operation: str) -> dict:
async def add_acme_account(host: OpnsenseHost, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", "/acmeclient/accounts/addAction", json={"account": payload}), "ACME-Account anlegen")
return _acme_result(await _request(host, "POST", "/acmeclient/accounts/add", json={"account": payload}), "ACME-Account anlegen")
async def update_acme_account(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", f"/acmeclient/accounts/updateAction/{uuid_}", json={"account": payload}), "ACME-Account ändern")
return _acme_result(await _request(host, "POST", f"/acmeclient/accounts/update/{uuid_}", json={"account": payload}), "ACME-Account ändern")
async def del_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/acmeclient/accounts/delAction/{uuid_}"), "ACME-Account entfernen", {"deleted", "not found"})
return _mutation_result(await _request(host, "POST", f"/acmeclient/accounts/del/{uuid_}"), "ACME-Account entfernen", {"deleted", "not found"})
async def get_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
@@ -956,15 +981,15 @@ async def get_acme_account(host: OpnsenseHost, uuid_: str) -> dict:
async def add_acme_certificate(host: OpnsenseHost, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", "/acmeclient/certificates/addAction", json={"certificate": payload}), "ACME-Zertifikatsanfrage anlegen")
return _acme_result(await _request(host, "POST", "/acmeclient/certificates/add", json={"certificate": payload}), "ACME-Zertifikatsanfrage anlegen")
async def update_acme_certificate(host: OpnsenseHost, uuid_: str, payload: dict) -> dict:
return _acme_result(await _request(host, "POST", f"/acmeclient/certificates/updateAction/{uuid_}", json={"certificate": payload}), "ACME-Zertifikatsanfrage ändern")
return _acme_result(await _request(host, "POST", f"/acmeclient/certificates/update/{uuid_}", json={"certificate": payload}), "ACME-Zertifikatsanfrage ändern")
async def del_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
return _mutation_result(await _request(host, "POST", f"/acmeclient/certificates/delAction/{uuid_}"), "ACME-Zertifikatsanfrage entfernen", {"deleted", "not found"})
return _mutation_result(await _request(host, "POST", f"/acmeclient/certificates/del/{uuid_}"), "ACME-Zertifikatsanfrage entfernen", {"deleted", "not found"})
async def get_acme_certificate(host: OpnsenseHost, uuid_: str) -> dict:
@@ -152,7 +152,7 @@ async def undeploy_outbound_nat(db: AsyncSession, rule: OutboundNatRule) -> None
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
@@ -311,7 +311,7 @@ async def undeploy_hierarchy_rule(db: AsyncSession, rule: PolicyHierarchyRule) -
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
@@ -152,7 +152,7 @@ async def undeploy_port_forward(db: AsyncSession, rule: PortForwardRule) -> None
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+19 -4
View File
@@ -25,12 +25,27 @@ _PEM = re.compile(r"-----BEGIN [A-Z0-9 ]+-----.*?(?:-----END [A-Z0-9 ]+-----|$)"
_PAIR = re.compile(r"(?i)\b([\w.\-]*(?:pass|secret|psk|token|private|api_?key|key)[\w.\-]*)\b([\"']?\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&]+)")
def _scrub_json(value: Any, key: str = "") -> Any:
# Keys under which OPNsense lists per-field validation errors
# ({"validations": {"instance.cert": "Please select a valid certificate"}}).
# There the field name says WHICH field failed. "cert", "crt" and "auth" in it
# do not make the message secret, so those three alone stop triggering the
# redaction (otherwise every certificate/auth error from the firewall reads
# "[redacted]"). Real secret names (psk, key, password, ...) still do.
_VALIDATION_KEYS = {"validations", "validation"}
_SECRET_KEY_IN_VALIDATIONS = re.compile(
r"pass|secret|psk|pre_?shared|private|prv|token|api_?key|apikey|credential|\bkey\b|_key|key_|tls_?key",
re.IGNORECASE,
)
def _scrub_json(value: Any, key: str = "", in_validations: bool = False) -> Any:
if isinstance(value, dict):
return {k: _scrub_json(v, str(k)) for k, v in value.items()}
nested = in_validations or key in _VALIDATION_KEYS
return {k: _scrub_json(v, str(k), nested) for k, v in value.items()}
if isinstance(value, list):
return [_scrub_json(v, key) for v in value]
if isinstance(value, str) and key and _SECRET_KEY.search(key):
return [_scrub_json(v, key, in_validations) for v in value]
pattern = _SECRET_KEY_IN_VALIDATIONS if in_validations else _SECRET_KEY
if isinstance(value, str) and key and pattern.search(key):
return _REDACTED
if isinstance(value, str):
return _scrub_text(value)
+1 -1
View File
@@ -160,7 +160,7 @@ async def undeploy_rule(db: AsyncSession, rule: FirewallRule) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+5 -1
View File
@@ -38,6 +38,10 @@ async def _live_rules_or_none(host: OpnsenseHost) -> list[LiveRule] | None:
async def compute_cluster_drift(hosts: list[OpnsenseHost]) -> list[dict]:
return (await compute_cluster_drift_with_reachable(hosts))[0]
async def compute_cluster_drift_with_reachable(hosts: list[OpnsenseHost]) -> tuple[list[dict], set]:
"""Returns one row per fingerprint that isn't present on every reachable
host in the cluster: {"fingerprint": str, "sample": LiveRule,
"present_on": set[host_id], "reachable_host_ids": set[host_id]}.
@@ -58,4 +62,4 @@ async def compute_cluster_drift(hosts: list[OpnsenseHost]) -> list[dict]:
for entry in by_fingerprint.values():
entry["reachable_host_ids"] = reachable_ids
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)]
return [entry for entry in by_fingerprint.values() if len(entry["present_on"]) < len(reachable_ids)], reachable_ids
@@ -324,6 +324,9 @@ async def _deploy_to_host(db: AsyncSession, tunnel: WireguardTunnel, host: Opnse
try:
await _write_config(host, payloads, deployment)
# Without the global switch the config is stored but no interface
# ever comes up (live-verified), so a "success" would be a lie.
await opnsense_client.ensure_wireguard_enabled(host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
@@ -388,7 +391,7 @@ async def undeploy_tunnel(db: AsyncSession, tunnel: WireguardTunnel) -> None:
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
+1 -1
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung").
"""
APP_VERSION = "1.11.0"
APP_VERSION = "1.11.1"
APP_CODENAME = "Merlin"
+14 -14
View File
@@ -75,7 +75,7 @@ async def test_push_acme_account_with_eab(client, respx_mock):
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]), )
account_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
resp = await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -97,7 +97,7 @@ async def test_register_account_regression_response_shape(client, respx_mock):
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -118,7 +118,7 @@ async def test_register_account_failed_precheck_shape(client, respx_mock):
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -175,7 +175,7 @@ async def test_push_certificate_request_injects_account_uuid(client, respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
@@ -183,7 +183,7 @@ async def test_push_certificate_request_injects_account_uuid(client, respx_mock)
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
resp = await client.post(f"/api/acme/certificates/{request_id}/push")
@@ -202,14 +202,14 @@ async def test_push_certificate_request_is_noop_when_unchanged(client, respx_moc
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
resp1 = await client.post(f"/api/acme/certificates/{request_id}/push")
@@ -230,7 +230,7 @@ async def test_push_certificate_request_repushes_after_account_switch(client, re
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"], name="Account A"))
account_a = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
side_effect=[
httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-a"}),
httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-b"}),
@@ -243,7 +243,7 @@ async def test_push_certificate_request_repushes_after_account_switch(client, re
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_a))
request = resp.json()
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
push_resp = await client.post(f"/api/acme/certificates/{request['id']}/push")
@@ -257,7 +257,7 @@ async def test_push_certificate_request_repushes_after_account_switch(client, re
resp = await client.put(f"/api/acme/certificates/{request['id']}", json=edit_payload)
assert resp.status_code == 200, resp.text
set_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/updateAction/.*").mock(
set_route = respx_mock.post(url__regex=r".*/acmeclient/certificates/update/.*").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
resp = await client.post(f"/api/acme/certificates/{request['id']}/push")
@@ -272,14 +272,14 @@ async def test_sign_is_async_trigger_not_a_success_claim(client, respx_mock):
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
await client.post(f"/api/acme/certificates/{request_id}/push")
@@ -302,14 +302,14 @@ async def test_poll_certificate_status_updates_remote_fields(client, respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/acme/accounts", json=_account_payload(host["id"]))
account_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/accounts/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/accounts/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "acct-uuid-1"})
)
await client.post(f"/api/acme/accounts/{account_id}/push")
resp = await client.post("/api/acme/certificates", json=_cert_request_payload(host["id"], account_id))
request_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/acmeclient/certificates/addAction$").mock(
respx_mock.post(url__regex=r".*/acmeclient/certificates/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-req-uuid-1"})
)
await client.post(f"/api/acme/certificates/{request_id}/push")
+21
View File
@@ -128,6 +128,27 @@ async def test_delete_alias(client: AsyncClient, respx_mock):
assert (await client.get("/api/aliases")).json() == []
@pytest.mark.asyncio
async def test_delete_alias_refused_by_firewall_names_the_reason(client: AsyncClient, respx_mock):
"""Live 26.7: an alias still used by a rule cannot be removed; the user must
see why (it used to be a bare "request failed")."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
r = (await client.post("/api/aliases", json=_alias_payload([host["id"]]))).json()
_mock_alias_push(respx_mock, "https://fw1.test")
assert (await client.post(f"/api/aliases/{r['id']}/deploy")).status_code == 200
assert (await client.post(f"/api/aliases/{r['id']}/confirm")).status_code == 200
respx_mock.post(url__regex=r".*/firewall/alias/delItem/.*").mock(
return_value=httpx.Response(200, json={"status": "failed", "message": "Alias is used by rule FW3 test block"})
)
resp = await client.delete(f"/api/aliases/{r['id']}")
assert resp.status_code == 502, resp.text
detail = resp.json()["detail"]
assert detail["code"] == "UPSTREAM_REJECTED"
assert "used by rule" in detail["params"]["reason"]
@pytest.mark.asyncio
async def test_deploy_alias_without_targets_rejected(client: AsyncClient):
r = (await client.post("/api/aliases", json=_alias_payload([]))).json()
+51
View File
@@ -157,6 +157,57 @@ async def test_sweep_reverts_expired_window(client, respx_mock):
assert await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"])) is None
async def _enter_with_expired_window(client, primary):
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True, "auto_revert_minutes": 30},
)
async with SessionLocal() as db:
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
window.auto_revert_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
return plan["preview_id"]
async def test_sweep_revert_closes_the_enter_operation_and_unblocks_deploys(client, respx_mock):
"""Nach dem Auto-Revert darf die carp_enter-Aktion nicht offen bleiben:
sie ließe sich nie mehr bestätigen und blockiert sonst alle Deploys im Cluster."""
from app.services.host_maintenance_service import ACTIVE, ensure_no_maintenance
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
op_id = await _enter_with_expired_window(client, primary)
async with SessionLocal() as db:
op = await db.get(HostOperation, uuid.UUID(op_id))
assert op.status == "submitted"
await carp_maintenance_window_service.sweep_expired(db)
async with SessionLocal() as db:
op = await db.get(HostOperation, uuid.UUID(op_id))
assert op.status == "cancelled" and op.status not in ACTIVE
assert "Automatisch" in op.message
host = await db.get(__import__("app.models.host", fromlist=["OpnsenseHost"]).OpnsenseHost, uuid.UUID(primary["id"]))
await ensure_no_maintenance(db, [host]) # raises if still blocked
async def test_sweep_failed_revert_keeps_the_enter_operation_open(client, respx_mock):
"""Schlägt der automatische Rückschalt-Versuch fehl, muss ein Mensch nachsehen:
die Aktion bleibt offen."""
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
op_id = await _enter_with_expired_window(client, primary)
switch.mock(side_effect=httpx.ConnectError("boom"))
async with SessionLocal() as db:
resolved = await carp_maintenance_window_service.sweep_expired(db)
assert resolved[0].status == "revert_failed"
async with SessionLocal() as db:
assert (await db.get(HostOperation, uuid.UUID(op_id))).status == "submitted"
@pytest.mark.asyncio
async def test_sweep_marks_revert_failed_on_toggle_error(client, respx_mock):
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
+13 -6
View File
@@ -95,7 +95,7 @@ async def test_push_managed_certificate(client, respx_mock):
resp = await client.post("/api/certificates/managed", json=_cert_payload(host["id"]))
cert_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/trust/cert/addAction$").mock(
respx_mock.post(url__regex=r".*/trust/cert/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-uuid-1"})
)
resp = await client.post(f"/api/certificates/managed/{cert_id}/push")
@@ -112,7 +112,7 @@ async def test_push_is_noop_when_nothing_changed(client, respx_mock):
resp = await client.post("/api/certificates/managed", json=_cert_payload(host["id"]))
cert_id = resp.json()["id"]
add_route = respx_mock.post(url__regex=r".*/trust/cert/addAction$").mock(
add_route = respx_mock.post(url__regex=r".*/trust/cert/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-uuid-1"})
)
resp1 = await client.post(f"/api/certificates/managed/{cert_id}/push")
@@ -148,14 +148,21 @@ async def test_delete_in_use_certificate_returns_stable_error_code(client, respx
resp = await client.post("/api/certificates/managed", json=_cert_payload(host["id"]))
cert_id = resp.json()["id"]
respx_mock.post(url__regex=r".*/trust/cert/addAction$").mock(
respx_mock.post(url__regex=r".*/trust/cert/add$").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "cert-uuid-1"})
)
await client.post(f"/api/certificates/managed/{cert_id}/push")
respx_mock.post(url__regex=r".*/trust/cert/delAction/.*").mock(
return_value=httpx.Response(500, json={"message": "OpenVPN - roadwarrior {openvpn.instance.abc}"})
respx_mock.post(url__regex=r".*/trust/cert/del/.*").mock(
# shape captured from a real OPNsense 26.7.5 (cert used by an OpenVPN instance)
return_value=httpx.Response(
500,
json={"errorMessage": "OpenVPN - roadwarrior {OpenVPN.Instances.Instance.abc}\n", "errorTitle": "Item in use by", "errorLevel": "error"},
)
)
resp = await client.delete(f"/api/certificates/managed/{cert_id}")
assert resp.status_code == 502
assert resp.json()["detail"] == {"code": "UPSTREAM_REQUEST_FAILED", "params": {}}
detail = resp.json()["detail"]
assert detail["code"] == "UPSTREAM_REJECTED"
assert "Item in use by" in detail["params"]["reason"]
assert "roadwarrior" in detail["params"]["reason"]
+14
View File
@@ -0,0 +1,14 @@
"""OPNsense routes drop the `Action` suffix of the PHP method name
(addAction() -> /add). A URL such as /trust/cert/addAction is a 404 on a real
firewall, but a respx mock written with the same typo happily accepts it
(live-found 2026-10-09), so guard the client source itself."""
import re
from pathlib import Path
import app.services.opnsense_client as client
def test_no_request_path_ends_in_action_suffix():
source = Path(client.__file__).read_text()
offenders = re.findall(r'"/[A-Za-z0-9_/{}$.]*?[a-z]Action(?:/[^"]*)?"', source)
assert offenders == [], offenders
+3
View File
@@ -467,6 +467,8 @@ async def test_cas_parses_real_shape(client, respx_mock):
"rows": [
{
"uuid": "ca-uuid-1",
# OpenVPN cert/ca and Trust-Store caref reference this id, not the uuid
"refid": "6ac8a49fa1589",
"descr": "Internal CA",
"commonname": "Internal Root CA",
"valid_from": str(now - 300 * 86400),
@@ -482,6 +484,7 @@ async def test_cas_parses_real_shape(client, respx_mock):
cas = resp.json()
assert len(cas) == 1
assert cas[0]["uuid"] == "ca-uuid-1"
assert cas[0]["refid"] == "6ac8a49fa1589"
assert cas[0]["common_name"] == "Internal Root CA"
assert cas[0]["status"] == "valid"
+28 -1
View File
@@ -18,7 +18,16 @@ from tests.test_ipsec import _create_host, _tunnel_payload
from tests.helpers import deploy_approved
def _mock_full_success(respx_mock, base_url: str):
def _mock_ipsec_enabled(respx_mock, base_url: str, enabled: bool = True):
# The service-wide switch is checked before every deploy (live 26.7.6:
# off by default, and reconfigure still answers ok while it is off).
respx_mock.get(f"{base_url}/api/ipsec/connections/is_enabled").mock(
return_value=httpx.Response(200, json={"enabled": enabled})
)
def _mock_full_success(respx_mock, base_url: str, enabled: bool = True):
_mock_ipsec_enabled(respx_mock, base_url, enabled)
respx_mock.post(f"{base_url}/api/ipsec/connections/addConnection").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "conn-1"})
)
@@ -134,6 +143,7 @@ async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, r
respx_mock.post("https://fw1.test/api/ipsec/preSharedKeys/addItem").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "psk-1"})
)
_mock_ipsec_enabled(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/ipsec/service/reconfigure").mock(
side_effect=httpx.ConnectError("connection refused")
)
@@ -203,3 +213,20 @@ async def test_undeploy_deletes_child_local_remote_connection_then_psk(client, r
resp = await client.delete(f"/api/ipsec/tunnels/{tunnel_id}")
assert resp.status_code == 204, resp.text
assert del_child.called and del_local.called and del_remote.called and del_conn.called and del_psk.called
@pytest.mark.asyncio
async def test_deploy_switches_on_the_global_ipsec_service_when_disabled(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")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test", enabled=False)
toggle = respx_mock.post("https://fw1.test/api/ipsec/connections/toggle/1").mock(
return_value=httpx.Response(200, json={"status": "ok"})
)
resp = await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
assert resp.json()["deployments"][0]["status"] == "success"
assert toggle.call_count == 1
+35
View File
@@ -177,6 +177,41 @@ async def test_check_rule_drift_no_drift_when_identical(client, respx_mock):
assert problems == {}
@pytest.mark.asyncio
async def test_drift_stays_open_while_a_cluster_member_is_unreachable(client, respx_mock):
"""Live 26.7.5: rebooting one firewall made every open drift problem
resolve (only one host reachable => nothing to compare) and re-open
afterwards, sending a resolved+opened pair per problem."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
await _create_host(client, "fw-a", "https://fw-a.test", cluster_name="ha")
await _create_host(client, "fw-b", "https://fw-b.test", cluster_name="ha")
respx_mock.get("https://fw-a.test/api/firewall/filter/searchRule").mock(
return_value=httpx.Response(200, json={"rows": [_live_rule_row("r1", "Allow SSH"), _live_rule_row("r2", "Only A")]})
)
b = respx_mock.get("https://fw-b.test/api/firewall/filter/searchRule").mock(
return_value=httpx.Response(200, json={"rows": [_live_rule_row("r1", "Allow SSH")]})
)
async with SessionLocal() as db:
current = await notification_checks.check_rule_drift(db)
opened, resolved = await notification_dedup.diff_and_update(db, "rule_drift", current)
await db.commit()
assert len(opened) == 1 and not resolved
b.mock(side_effect=httpx.ConnectError("down")) # fw-b reboots
current = await notification_checks.check_rule_drift(db)
assert dict(current) == {}
opened, resolved = await notification_dedup.diff_and_update(db, "rule_drift", current)
await db.commit()
assert not opened and not resolved # still open, nothing sent
b.mock(return_value=httpx.Response(200, json={"rows": [_live_rule_row("r1", "Allow SSH")]}))
current = await notification_checks.check_rule_drift(db)
opened, resolved = await notification_dedup.diff_and_update(db, "rule_drift", current)
await db.commit()
assert not opened and not resolved # same problem, no duplicate "opened"
# ── check_revert_failed / check_maintenance_unknown ─────────────────────
@pytest.mark.asyncio
+21 -2
View File
@@ -17,17 +17,29 @@ from tests.test_openvpn import _create_host, _instance_payload
from tests.helpers import deploy_approved
def _mock_vpnid_search(respx_mock, base_url: str, rows=None):
respx_mock.get(f"{base_url}/api/openvpn/instances/search").mock(
return_value=httpx.Response(200, json={"rows": rows or []})
)
def _mock_full_success(respx_mock, base_url: str, with_statickey: bool = False):
if with_statickey:
respx_mock.post(f"{base_url}/api/openvpn/instances/addStaticKey").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "statickey-1"})
)
respx_mock.post(f"{base_url}/api/openvpn/instances/add").mock(
# The firewall does not assign vpnid itself (live 26.7.5: "A value is
# required."), so the deploy asks for the existing ones first.
respx_mock.get(f"{base_url}/api/openvpn/instances/search").mock(
return_value=httpx.Response(200, json={"rows": [{"uuid": "x", "vpnid": "3"}, {"uuid": "y", "vpnid": "1"}]})
)
add_route = respx_mock.post(f"{base_url}/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
respx_mock.post(f"{base_url}/api/openvpn/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "ok"})
)
return add_route
@pytest.mark.asyncio
@@ -37,13 +49,14 @@ async def test_deploy_success_persists_instance_uuid(client, respx_mock):
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
add_route = _mock_full_success(respx_mock, "https://fw1.test")
resp = await deploy_approved(client, f"/api/openvpn/instances/{instance_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "success"
assert dep["message"] is None
assert json.loads(add_route.calls.last.request.content)["instance"]["vpnid"] == "4"
async with SessionLocal() as db:
row = (await db.execute(select(OpenVpnInstanceDeployment))).scalar_one()
@@ -85,6 +98,7 @@ async def test_deploy_config_write_failure_surfaces_as_error_without_rollback(cl
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
side_effect=httpx.ConnectError("connection refused")
)
@@ -115,6 +129,7 @@ async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, r
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
@@ -150,6 +165,7 @@ async def test_reconfigure_checks_result_key_explicitly(client, respx_mock):
resp = await client.post("/api/openvpn/instances", json=_instance_payload([host["id"]]))
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
@@ -174,6 +190,7 @@ async def test_redeploy_unchanged_skips_opnsense_entirely(client, respx_mock):
_mock_full_success(respx_mock, "https://fw1.test")
await deploy_approved(client, f"/api/openvpn/instances/{instance_id}/deploy")
_mock_vpnid_search(respx_mock, "https://fw1.test")
add_route = respx_mock.post("https://fw1.test/api/openvpn/instances/add")
resp = await deploy_approved(client, f"/api/openvpn/instances/{instance_id}/deploy")
@@ -241,6 +258,7 @@ async def test_multi_host_deploy_sends_each_host_its_own_cert_and_ca(client, res
instance_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
_mock_vpnid_search(respx_mock, "https://fw2.test")
respx_mock.post("https://fw2.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-2"})
)
@@ -271,6 +289,7 @@ async def test_host_missing_cert_selection_fails_closed_without_any_call(client,
)
instance_id = resp.json()["id"]
_mock_vpnid_search(respx_mock, "https://fw1.test")
add_route = respx_mock.post("https://fw1.test/api/openvpn/instances/add").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "instance-1"})
)
+38
View File
@@ -535,6 +535,17 @@ async def test_password_hashing_runs_off_the_event_loop():
# ── SEC-08: upstream errors never carry secrets ───────────────────────
def test_redaction_keeps_validation_messages_readable():
body = (
'{"result": "failed", "validations": {"instance.cert": "Please select a valid certificate from the list.",'
' "instance.auth": "Authentication method required"}, "api_key": "k-MARKER"}'
)
out = redact_text(body)
assert "Please select a valid certificate" in out
assert "Authentication method required" in out
assert "k-MARKER" not in out # secrets outside "validations" are still masked
def test_redaction_scrubs_json_pem_and_known_secrets():
pem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg\n-----END PRIVATE KEY-----"
body = json.dumps({
@@ -859,3 +870,30 @@ async def test_process_death_after_intent_leaves_sweepable_or_flagged_row(client
async with SessionLocal() as db:
row = (await db.scalars(select(RuleDeployment))).one()
assert row.status == "revert_failed" and row.opnsense_rule_uuid is None # visible, blocks retries, notifies
# ── audit events built from unvalidated request paths ────────────────────
@pytest.mark.asyncio
async def test_junk_path_segments_give_422_not_500(client):
"""Fuzzing found 500s/dropped connections: the audit event copied the raw
path segment (300 chars, NUL byte) into VARCHAR(36)/Postgres text."""
for path in (
"/api/hosts/" + "x" * 300,
"/api/hosts/%00/services/ntpd/restart",
"/api/rules/not-a-uuid/deploy",
):
method = "PATCH" if path.count("/") == 3 else "POST"
resp = await client.request(method, path, json={})
assert resp.status_code in (404, 422), (path[:40], resp.status_code, resp.text[:100])
@pytest.mark.asyncio
async def test_nul_byte_in_a_text_field_is_a_422_not_a_500(client):
for path, body in (
("/api/groups", {"name": "a\u0000b", "description": "x", "host_ids": []}),
("/api/rules", {"description": "a\u0000b", "interface": "lan", "target_host_ids": []}),
):
resp = await client.post(path, json=body)
assert resp.status_code == 422, (path, resp.status_code, resp.text[:120])
assert resp.json()["detail"]["code"] == "INVALID_CHARACTERS"
+38
View File
@@ -8,6 +8,17 @@ def _mock_vip(respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
def _mock_service_list(respx_mock):
respx_mock.get("https://fw1.test/api/core/service/search").respond(
200,
json={"rows": [
{"id": "unbound", "name": "unbound", "locked": 0, "running": 1},
{"id": "ntpd", "name": "ntpd", "locked": 0, "running": 1},
{"id": "pf", "name": "pf", "locked": 1, "running": 1},
]},
)
@pytest.mark.asyncio
async def test_list_services(client, respx_mock):
_mock_vip(respx_mock)
@@ -24,6 +35,7 @@ async def test_list_services(client, respx_mock):
async def test_restart_service_success(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
restart = respx_mock.post("https://fw1.test/api/core/service/restart/unbound").respond(200, json={"result": "ok"})
r = await client.post(f"/api/hosts/{h['id']}/services/unbound/restart")
assert r.status_code == 200, r.text
@@ -35,6 +47,7 @@ async def test_restart_service_success(client, respx_mock):
async def test_start_and_stop_service(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
start = respx_mock.post("https://fw1.test/api/core/service/start/ntpd").respond(200, json={"result": "ok"})
stop = respx_mock.post("https://fw1.test/api/core/service/stop/ntpd").respond(200, json={"result": "ok"})
r1 = await client.post(f"/api/hosts/{h['id']}/services/ntpd/start")
@@ -47,6 +60,7 @@ async def test_start_and_stop_service(client, respx_mock):
async def test_restart_service_failure_result_is_rejected(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
respx_mock.post("https://fw1.test/api/core/service/restart/unbound").respond(200, json={"result": "failed"})
r = await client.post(f"/api/hosts/{h['id']}/services/unbound/restart")
assert r.status_code == 502, r.text
@@ -66,3 +80,27 @@ async def test_read_only_user_can_list_but_not_restart(client, respx_mock):
assert blocked.status_code == 403
finally:
await viewer.aclose()
@pytest.mark.asyncio
async def test_unknown_service_is_rejected_without_calling_the_firewall(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
start = respx_mock.post(url__regex=r".*/api/core/service/start/.*").respond(200, json={"result": "ok"})
r = await client.post(f"/api/hosts/{h['id']}/services/doesnotexist/start")
assert r.status_code == 404, r.text
assert r.json()["detail"]["code"] == "SERVICE_NOT_FOUND"
assert start.call_count == 0
@pytest.mark.asyncio
async def test_locked_service_cannot_be_controlled_via_api(client, respx_mock):
_mock_vip(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
_mock_service_list(respx_mock)
stop = respx_mock.post(url__regex=r".*/api/core/service/stop/.*").respond(200, json={"result": "ok"})
r = await client.post(f"/api/hosts/{h['id']}/services/pf/stop")
assert r.status_code == 409, r.text
assert r.json()["detail"]["code"] == "SERVICE_LOCKED"
assert stop.call_count == 0
+33 -1
View File
@@ -6,6 +6,8 @@ denormalized `servers` field on the Peer payload) and to WireGuard's OWN
reconfigure response shape ({"result": "ok"}, matching OpenVPN's shape,
NOT IPsec's {"status": "ok"}), which this suite checks explicitly rather
than assuming symmetry with either sibling feature."""
import json
import httpx
import pytest
from sqlalchemy import select
@@ -16,7 +18,15 @@ from tests.test_wireguard import _create_host, _tunnel_payload
from tests.helpers import deploy_approved
def _mock_full_success(respx_mock, base_url: str):
def _mock_general(respx_mock, base_url: str, enabled: str = "1"):
# The service-wide switch (General.enabled) is checked before every deploy.
respx_mock.get(f"{base_url}/api/wireguard/general/get").mock(
return_value=httpx.Response(200, json={"general": {"enabled": enabled}})
)
def _mock_full_success(respx_mock, base_url: str, general_enabled: str = "1"):
_mock_general(respx_mock, base_url, general_enabled)
respx_mock.post(f"{base_url}/api/wireguard/server/addServer").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "server-1"})
)
@@ -68,6 +78,7 @@ async def test_peer_payload_carries_injected_server_uuid(client, respx_mock):
add_peer = respx_mock.post("https://fw1.test/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
_mock_general(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/wireguard/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "ok"})
)
@@ -134,6 +145,7 @@ async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, r
respx_mock.post("https://fw1.test/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
_mock_general(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/wireguard/service/reconfigure").mock(
side_effect=httpx.ConnectError("connection refused")
)
@@ -173,6 +185,7 @@ async def test_reconfigure_checks_result_key_explicitly(client, respx_mock):
respx_mock.post("https://fw1.test/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
_mock_general(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/wireguard/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "failed"})
)
@@ -282,3 +295,22 @@ async def test_secrets_never_persisted_and_rotation_is_not_a_no_op(client, respx
assert add_server_route.call_count == 1, "unchanged from the first deploy — the rotation must go through setServer, not addServer"
assert set_server_route.call_count == 1, "the rotated key must actually be re-pushed, not silently no-op'd"
assert rotated_key.encode() in set_server_route.calls.last.request.content
@pytest.mark.asyncio
async def test_deploy_switches_on_the_global_wireguard_service_when_disabled(client, respx_mock):
"""Live 26.7.5: with General.enabled=0 the config is written but no wg
interface exists, so the deploy must enable the switch first."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/wireguard/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test", general_enabled="0")
set_route = respx_mock.post("https://fw1.test/api/wireguard/general/set").mock(
return_value=httpx.Response(200, json={"result": "saved"})
)
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
assert resp.json()["deployments"][0]["status"] == "success"
assert json.loads(set_route.calls.last.request.content) == {"general": {"enabled": "1"}}
+6
View File
@@ -5,6 +5,12 @@
/* Visible keyboard focus everywhere. Mouse clicks do not trigger :focus-visible, so this
only shows for keyboard users. Inputs keep their own ring via the focus: utilities. */
@layer base {
/* A scroll container must also be the containing block of absolutely
positioned content inside it. Otherwise the table header's `sr-only` span
(position:absolute, static position in the right-most column) escapes the
clip, widens the whole document and makes phones zoom the page out
(found live on /hosts and /users at 375px). */
.overflow-x-auto { position: relative; }
:focus-visible {
outline: 2px solid rgb(var(--focus-ring));
outline-offset: 2px;
+1 -1
View File
@@ -157,7 +157,7 @@ function IpsecContent() {
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-slate-900">{tunnel.description}</h3>
<h2 className="font-medium text-slate-900">{tunnel.description}</h2>
<WorkflowBadge state={tunnel.workflow_state} />
{!tunnel.pre_shared_key_set && (
<Badge tone="warning">
+3 -3
View File
@@ -197,7 +197,7 @@ function OpenVpnContent() {
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-slate-900">{instance.description}</h3>
<h2 className="font-medium text-slate-900">{instance.description}</h2>
<WorkflowBadge state={instance.workflow_state} />
{instance.tls_key_enabled && !instance.tls_key_material_set && (
<Badge tone="warning">
@@ -367,7 +367,7 @@ function OpenVpnContent() {
) : (
<Select required value={form.cert_refs[hostId] ?? ""} onChange={(e) => setCertRef(hostId, e.target.value)}>
<option value="" disabled>{t("ovpn.pleaseChoose")}</option>
{store.certs.map((c) => <option key={c.uuid} value={c.uuid}>{c.descr}{c.common_name ? ` (${c.common_name})` : ""}</option>)}
{store.certs.map((c) => <option key={c.uuid} value={c.refid}>{c.descr}{c.common_name ? ` (${c.common_name})` : ""}</option>)}
</Select>
)}
</FieldGroup>
@@ -377,7 +377,7 @@ function OpenVpnContent() {
) : (
<Select required value={form.ca_refs[hostId] ?? ""} onChange={(e) => setCaRef(hostId, e.target.value)}>
<option value="" disabled>{t("ovpn.pleaseChoose")}</option>
{store.cas.map((c) => <option key={c.uuid} value={c.uuid}>{c.descr}{c.common_name ? ` (${c.common_name})` : ""}</option>)}
{store.cas.map((c) => <option key={c.uuid} value={c.refid}>{c.descr}{c.common_name ? ` (${c.common_name})` : ""}</option>)}
</Select>
)}
</FieldGroup>
+1 -1
View File
@@ -309,7 +309,7 @@ export default function UpdatesPage() {
setPageError(null);
try {
const progress = await api.hostUpdateProgress(host.id);
setLog({ host, text: progress.log || progress.status });
setLog({ host, text: progress.log || t("updates.logEmpty") });
} catch (error) {
setPageError(errorMessage(error, "updates.logFailed"));
} finally {
+1 -1
View File
@@ -160,7 +160,7 @@ function WireguardContent() {
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-slate-900">{tunnel.server_name} → {tunnel.peer_name}</h3>
<h2 className="font-medium text-slate-900">{tunnel.server_name} → {tunnel.peer_name}</h2>
<WorkflowBadge state={tunnel.workflow_state} />
{!tunnel.server_private_key_set && (
<Badge tone="warning">
+24 -2
View File
@@ -3,9 +3,9 @@
import { Button } from "@/components/ui/Button";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { api, CertAction, CertDigest, CertKeyType, Host, ManagedCertificate, ManagedCertificateCreate } from "@/lib/api";
import { api, CertAction, CertDigest, CertKeyType, Host, HostCa, ManagedCertificate, ManagedCertificateCreate } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { FormEvent, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
const certActions: CertAction[] = ["import", "internal", "external", "sign_csr", "import_csr", "reissue", "manual"];
@@ -47,6 +47,17 @@ export function ManagedCertificateForm({ hosts, editing, onClose, onSaved }: {
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// The firewall expects the CA's refid in caref (not its row UUID), so the
// CAs of the selected host are offered as a list; if that lookup fails the
// field stays a free text input.
const [cas, setCas] = useState<HostCa[] | null>(null);
useEffect(() => {
let cancelled = false;
setCas(null);
if (!form.host_id) return;
api.getHostCas(form.host_id).then((list) => { if (!cancelled) setCas(list); }).catch(() => { if (!cancelled) setCas(null); });
return () => { cancelled = true; };
}, [form.host_id]);
async function submit(e: FormEvent) {
e.preventDefault();
@@ -135,12 +146,23 @@ export function ManagedCertificateForm({ hosts, editing, onClose, onSaved }: {
{(form.action === "internal" || form.action === "sign_csr") && (
<FieldGroup label={form.action === "sign_csr" ? t("certForm.carefRequired") : t("certForm.carefOptional")}>
{cas && cas.length > 0 ? (
<Select
value={form.caref ?? ""}
onChange={(e) => setForm({ ...form, caref: e.target.value || null })}
required={form.action === "sign_csr"}
>
<option value="">{form.action === "sign_csr" ? t("certForm.carefChoose") : t("certForm.carefSelfSigned")}</option>
{cas.map((c) => <option key={c.uuid} value={c.refid}>{c.descr}{c.common_name ? ` (${c.common_name})` : ""}</option>)}
</Select>
) : (
<Input
value={form.caref ?? ""}
onChange={(e) => setForm({ ...form, caref: e.target.value || null })}
required={form.action === "sign_csr"}
placeholder={t("certForm.carefPlaceholder")}
/>
)}
</FieldGroup>
)}
+1 -1
View File
@@ -441,7 +441,7 @@ function OneToOneContent() {
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-slate-900">{rule.description}</h3>
<h2 className="font-medium text-slate-900">{rule.description}</h2>
<WorkflowBadge state={rule.workflow_state} />
</div>
<p className="mt-1 text-sm text-slate-500">
+1 -1
View File
@@ -450,7 +450,7 @@ function OutboundNatContent() {
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-slate-900">{rule.description}</h3>
<h2 className="font-medium text-slate-900">{rule.description}</h2>
<WorkflowBadge state={rule.workflow_state} />
</div>
<p className="mt-1 text-sm text-slate-500">
+1 -1
View File
@@ -447,7 +447,7 @@ function PortForwardContent() {
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium text-slate-900">{rule.description}</h3>
<h2 className="font-medium text-slate-900">{rule.description}</h2>
<WorkflowBadge state={rule.workflow_state} />
</div>
<p className="mt-1 text-sm text-slate-500">
+2
View File
@@ -242,6 +242,7 @@ export type CertStatus = "valid" | "expiring_soon" | "expired" | "unknown";
export interface HostCertificate {
uuid: string;
refid: string;
descr: string;
common_name: string | null;
issuer: string;
@@ -254,6 +255,7 @@ export interface HostCertificate {
export interface HostCa {
uuid: string;
refid: string;
descr: string;
common_name: string | null;
valid_from: string | null;
+8 -1
View File
@@ -438,6 +438,10 @@
"ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN": "Ziele oder Deployment wurden geändert. Vorschau aktualisieren.",
"ZIEL_HOST_NICHT_GEFUNDEN": "Ziel-Host nicht gefunden",
"OUTBOUND_MODE_UNREADABLE": "Outbound-Modus auf {host} nicht lesbar: {reason}",
"INVALID_CHARACTERS": "Die Eingabe enthält ein nicht speicherbares Zeichen (z. B. ein NUL-Zeichen)",
"UPSTREAM_REJECTED": "Die Firewall hat die Anfrage abgelehnt: {reason}",
"SERVICE_NOT_FOUND": "{service}: Dienst auf dieser Firewall nicht gefunden",
"SERVICE_LOCKED": "{service}: Dienst ist gesperrt und kann nicht per API gesteuert werden",
"OUTBOUND_MODE_BLOCKS_PUSH": "{host}: Outbound-NAT-Modus „{mode}“ erlaubt keinen Push. Modus in OPNsense selbst auf Hybrid/Manuell umstellen. OPNexus ändert diesen Modus nicht.",
"GROUP_CANNOT_BE_OWN_PARENT": "Eine Gruppe kann nicht ihr eigener Parent sein",
"GROUP_HIERARCHY_CYCLE": "Diese Zuordnung würde einen Zyklus in der Gruppenhierarchie erzeugen",
@@ -840,7 +844,9 @@
"lifetime": "Gültigkeit (Tage)",
"carefRequired": "CA-Referenz (caref) — Pflicht",
"carefOptional": "CA-Referenz (caref) — leer = selbstsigniert",
"carefPlaceholder": "UUID einer bereits vorhandenen internen CA auf diesem Host",
"carefPlaceholder": "Refid einer bereits vorhandenen internen CA auf diesem Host",
"carefChoose": "Bitte wählen",
"carefSelfSigned": "Selbstsigniert (keine CA)",
"csr": "CSR (PEM)",
"certificate": "Zertifikat (PEM)",
"privateKeyKeep": "Privater Schlüssel (PEM) — leer lassen = unverändert",
@@ -1632,6 +1638,7 @@
"cancelWarnStrong": "keinen",
"cancelWarnPost": " erfolgreichen Abschluss — es schließt den Datensatz nur, damit dieser Host/Cluster nicht weiter gesperrt bleibt. Nur nutzen, wenn der erwartete Zustand nachweislich nie eingetreten ist (z. B. CARP-Rollenwechsel hängt fest).",
"logTitle": "Update-Protokoll · {host}",
"logEmpty": "Kein Protokoll vorhanden. OPNsense leert es beim Neustart – nach einem erfolgreichen Update ist das normal. Maßgeblich ist der angezeigte Status der Firewall.",
"readOnlyBadge": "Nur Lesen",
"lockedPrefix": "Gesperrt",
"tipCheckAll": "Fragt alle geeigneten Firewalls nach neuen Updates. Es wird nichts installiert.",
+8 -1
View File
@@ -438,6 +438,10 @@
"ZIELE_ODER_DEPLOYMENT_WURDEN_GEAENDERT_VORSCHAU_AKTUALISIEREN": "The targets or deployment changed. Refresh the preview.",
"ZIEL_HOST_NICHT_GEFUNDEN": "Target host not found",
"OUTBOUND_MODE_UNREADABLE": "Outbound mode on {host} not readable: {reason}",
"INVALID_CHARACTERS": "The input contains a character that cannot be stored (e.g. a NUL character)",
"UPSTREAM_REJECTED": "The firewall rejected the request: {reason}",
"SERVICE_NOT_FOUND": "{service}: service not found on this firewall",
"SERVICE_LOCKED": "{service}: service is locked and cannot be controlled via the API",
"OUTBOUND_MODE_BLOCKS_PUSH": "{host}: outbound NAT mode “{mode}” does not allow a push. Switch the mode to Hybrid/Manual in OPNsense itself. OPNexus does not change this mode.",
"GROUP_CANNOT_BE_OWN_PARENT": "A group cannot be its own parent",
"GROUP_HIERARCHY_CYCLE": "This assignment would create a cycle in the group hierarchy",
@@ -840,7 +844,9 @@
"lifetime": "Validity (days)",
"carefRequired": "CA reference (caref) — required",
"carefOptional": "CA reference (caref) — empty = self-signed",
"carefPlaceholder": "UUID of an existing internal CA on this host",
"carefPlaceholder": "Refid of an existing internal CA on this host",
"carefChoose": "Please choose",
"carefSelfSigned": "Self-signed (no CA)",
"csr": "CSR (PEM)",
"certificate": "Certificate (PEM)",
"privateKeyKeep": "Private key (PEM) — leave empty = unchanged",
@@ -1632,6 +1638,7 @@
"cancelWarnStrong": "not confirm",
"cancelWarnPost": " a successful completion — it only closes the record so this host/cluster is no longer locked. Only use it if the expected state demonstrably never occurred (e.g. a CARP role switch is stuck).",
"logTitle": "Update log · {host}",
"logEmpty": "No log available. OPNsense clears it on reboot – after a successful update that is normal. The firewall's status shown on this page is what counts.",
"readOnlyBadge": "Read-only",
"lockedPrefix": "Locked",
"tipCheckAll": "Asks all eligible firewalls for new updates. Nothing is installed.",
+1 -1
View File
@@ -1,6 +1,6 @@
// Manually bumped whenever a notable change ships — mirrors the dated
// entries already kept in the project README/CHANGELOG.md, no build
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
export const APP_VERSION = "1.11.0";
export const APP_VERSION = "1.11.1";
export const APP_CODENAME = "Merlin";
export const APP_MAINTAINER = "Julian Boldt";
+8
View File
@@ -159,6 +159,14 @@ cmd_rollback() {
. "./$STATE_FILE"
[ -n "${prev_version:-}" ] || die "$STATE_FILE ist unvollständig."
# Rollback target = the exact image tag that was configured before the update.
# The version the backend REPORTS only matches the tag for final releases: an
# rc image reports its final version (rc1 images say 1.11.0), so rolling back
# to that would land on a different, newer image. Floating tags ("latest",
# "1.11") cannot be restored by name, so those fall back to the reported version.
if [[ "${prev_setting:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
prev_version="$prev_setting"
fi
say "Rolle zurück auf $prev_version …"
c stop "${SERVICES[@]}"