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

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

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

317 lines
16 KiB
Python

"""Covers WireguardTunnel's deliberately simpler status model (draft/
success/error only — see wireguard_deploy_service.py's module docstring)
— same rationale as test_ipsec_deploy_safety.py/test_openvpn_deploy_safety.py,
adapted to this entity's 2-object shape (Server, then Peer, linked via the
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
from app.database import SessionLocal
from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment
from tests.test_wireguard import _create_host, _tunnel_payload
from tests.helpers import deploy_approved
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"})
)
respx_mock.post(f"{base_url}/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
respx_mock.post(f"{base_url}/api/wireguard/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "ok"})
)
@pytest.mark.asyncio
async def test_deploy_success_persists_server_and_peer_uuid(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/wireguard/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "success"
assert dep["message"] is None
async with SessionLocal() as db:
row = (await db.execute(select(WireguardTunnelDeployment))).scalar_one()
assert row.opnsense_server_uuid == "server-1"
assert row.opnsense_peer_uuid == "peer-1"
assert row.last_payload is not None
@pytest.mark.asyncio
async def test_peer_payload_carries_injected_server_uuid(client, respx_mock):
"""The denormalized peer<->server linkage (verified directly from
ClientController::setClientAction — see module docstring) is written
by this app sending `servers` = the Server's own UUID on the Peer's
add/set call. Locks in that this app actually injects it, in the
correct order (Server created before Peer references it)."""
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"]
add_server = respx_mock.post("https://fw1.test/api/wireguard/server/addServer").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "server-1"})
)
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"})
)
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
assert add_server.called and add_peer.called
peer_body = add_peer.calls.last.request.content
import json as _json
posted = _json.loads(peer_body)
assert posted["client"]["servers"] == "server-1"
@pytest.mark.asyncio
async def test_deploy_config_write_failure_surfaces_as_error_without_rollback(client, respx_mock):
"""The Server add fails — status must be "error" with a concrete
message, the Peer must never be written, and reconfigure must never be
reached (config write itself failed, no rollback exists for this
entity)."""
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"]
respx_mock.post("https://fw1.test/api/wireguard/server/addServer").mock(
side_effect=httpx.ConnectError("connection refused")
)
peer_route = respx_mock.post("https://fw1.test/api/wireguard/client/addClient").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
reconfigure_route = respx_mock.post("https://fw1.test/api/wireguard/service/reconfigure").mock(
return_value=httpx.Response(200, json={"result": "ok"})
)
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "error"
assert dep["message"]
assert not peer_route.called
assert not reconfigure_route.called
async with SessionLocal() as db:
row = (await db.execute(select(WireguardTunnelDeployment))).scalar_one()
assert row.opnsense_server_uuid is None
assert row.opnsense_peer_uuid is None
@pytest.mark.asyncio
async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, respx_mock):
"""Both config writes succeed, but the follow-up reconfigure call
fails — the plan specifies the exact same non-generic message IPsec/
OpenVPN use ("config written, reload unconfirmed" is a materially
different failure mode from "config write itself 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")
resp = await client.post("/api/wireguard/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
respx_mock.post("https://fw1.test/api/wireguard/server/addServer").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "server-1"})
)
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")
)
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "error"
assert dep["message"] == "Konfiguration wurde geschrieben, Dienst-Reload aber nicht bestätigt — Erreichbarkeit manuell prüfen"
async with SessionLocal() as db:
row = (await db.execute(select(WireguardTunnelDeployment))).scalar_one()
assert row.opnsense_server_uuid == "server-1"
assert row.opnsense_peer_uuid == "peer-1"
assert row.last_payload is not None
@pytest.mark.asyncio
async def test_reconfigure_checks_result_key_explicitly(client, respx_mock):
"""Regression guard locking in WireGuard's own reconfigure response
shape: ServiceController::reconfigureAction (a hand-rolled override,
read directly from source) reports failure via {"result": "failed"} —
matching OpenVPN's "result"-based shape, NOT IPsec's "status"-based
one. A mock returning {"result": "failed"} must be treated as an
unconfirmed reconfigure with the same exact message as a
transport-level failure, proving this app checks the actual key/value
WireGuard's reconfigure reports through rather than assuming either
sibling feature's shape."""
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"]
respx_mock.post("https://fw1.test/api/wireguard/server/addServer").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "server-1"})
)
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"})
)
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "error"
assert dep["message"] == "Konfiguration wurde geschrieben, Dienst-Reload aber nicht bestätigt — Erreichbarkeit manuell prüfen"
@pytest.mark.asyncio
async def test_redeploy_unchanged_skips_opnsense_entirely(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/wireguard/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
add_route = respx_mock.post("https://fw1.test/api/wireguard/server/addServer")
resp = await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "success"
assert add_route.call_count == 1
@pytest.mark.asyncio
async def test_undeploy_deletes_peer_before_server(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/wireguard/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
await deploy_approved(client, f"/api/wireguard/tunnels/{tunnel_id}/deploy")
del_peer = respx_mock.post("https://fw1.test/api/wireguard/client/delClient/peer-1").mock(
return_value=httpx.Response(200, json={"result": "deleted"})
)
del_server = respx_mock.post("https://fw1.test/api/wireguard/server/delServer/server-1").mock(
return_value=httpx.Response(200, json={"result": "deleted"})
)
resp = await client.delete(f"/api/wireguard/tunnels/{tunnel_id}")
assert resp.status_code == 204, resp.text
assert del_peer.called and del_server.called
@pytest.mark.asyncio
async def test_secrets_never_persisted_and_rotation_is_not_a_no_op(client, respx_mock):
"""Regression test for a real bug found and fixed 2026-09-09:
build_payloads() embeds the raw server private key / peer PSK for the
wire request, and that exact dict used to also become
`deployment.last_payload` verbatim — i.e. both secrets were persisted
in plaintext into Postgres, contradicting wireguard_key_store.py's own
"never touches the database, disk-only" docstring. It also meant a
key ROTATION (re-uploading a new private key with every other field
unchanged) was invisible to the no-op check, since the boolean-style
"is a key present" state never changed — the redeploy would have
silently skipped OPNsense entirely, leaving the OLD key live on the
firewall while the UI kept showing "success". Fixed by storing a
SHA-256 hash of each secret instead of the secret itself."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
original_key = "p" * 43 + "="
resp = await client.post("/api/wireguard/tunnels", json=_tunnel_payload([host["id"]], server_private_key=original_key))
assert resp.status_code == 201, resp.text
tunnel = resp.json()
tunnel_id = tunnel["id"]
_mock_full_success(respx_mock, "https://fw1.test")
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"
async with SessionLocal() as db:
deployment = (await db.execute(
select(WireguardTunnelDeployment).where(WireguardTunnelDeployment.tunnel_id == tunnel_id)
)).scalar_one()
stored = deployment.last_payload
assert original_key not in str(stored), "raw private key must never be persisted in last_payload"
assert stored["server"]["privkey"] != original_key
assert stored["server"]["privkey"], "a hash stand-in must still be present so rotation is detectable"
add_server_route = respx_mock.post("https://fw1.test/api/wireguard/server/addServer")
set_server_route = respx_mock.post("https://fw1.test/api/wireguard/server/setServer/server-1").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "server-1"})
)
respx_mock.post("https://fw1.test/api/wireguard/client/setClient/peer-1").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "peer-1"})
)
rotated_key = "q" * 43 + "="
resp = await client.put(
f"/api/wireguard/tunnels/{tunnel_id}",
json={**_tunnel_payload([host["id"]], server_private_key=rotated_key), "expected_updated_at": tunnel["updated_at"]},
)
assert resp.status_code == 200, resp.text
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 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"}}