Compare commits
2
Commits
v1.11.0-rc2
...
v1.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc2d2c87c | ||
|
|
9889be64ef |
No files matched your search
@@ -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
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **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
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"}}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}>
|
||||
<Input
|
||||
value={form.caref ?? ""}
|
||||
onChange={(e) => setForm({ ...form, caref: e.target.value || null })}
|
||||
required={form.action === "sign_csr"}
|
||||
placeholder={t("certForm.carefPlaceholder")}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,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";
|
||||
@@ -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[@]}"
|
||||
|
||||
|
||||
Reference in new issue
Block a user