Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:
- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
`/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
`cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
`/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
(VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
`SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
(„instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
(„Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
„Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
allen Seiten, hell und dunkel, nichts mehr.
- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
(Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
233 lines
11 KiB
Python
233 lines
11 KiB
Python
"""Covers IpsecTunnel's deliberately simpler status model (draft/success/
|
|
error only — see ipsec_deploy_service.py's module docstring) — unlike
|
|
test_deploy_safety.py for FirewallRule, there is no pending/confirm/sweep
|
|
behaviour to test here, because those states don't exist for this entity.
|
|
What matters instead: a failed config-write surfaces as "error" with a
|
|
concrete message, and a failed reconfigure (after all 5 config writes
|
|
already succeeded) surfaces as "error" with the SPECIFIC message the plan
|
|
requires — not a generic exception string — while still recording that the
|
|
config itself was written (no rollback exists, so partial state is kept
|
|
and reported honestly rather than hidden)."""
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.database import SessionLocal
|
|
from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment
|
|
from tests.test_ipsec import _create_host, _tunnel_payload
|
|
from tests.helpers import deploy_approved
|
|
|
|
|
|
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"})
|
|
)
|
|
respx_mock.post(f"{base_url}/api/ipsec/connections/addLocal").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "local-1"})
|
|
)
|
|
respx_mock.post(f"{base_url}/api/ipsec/connections/addRemote").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "remote-1"})
|
|
)
|
|
respx_mock.post(f"{base_url}/api/ipsec/connections/addChild").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "child-1"})
|
|
)
|
|
respx_mock.post(f"{base_url}/api/ipsec/preSharedKeys/addItem").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "psk-1"})
|
|
)
|
|
respx_mock.post(f"{base_url}/api/ipsec/service/reconfigure").mock(
|
|
return_value=httpx.Response(200, json={"status": "ok"})
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deploy_success_persists_all_five_uuids(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")
|
|
|
|
resp = await deploy_approved(client, f"/api/ipsec/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(IpsecTunnelDeployment))).scalar_one()
|
|
assert row.opnsense_connection_uuid == "conn-1"
|
|
assert row.opnsense_local_uuid == "local-1"
|
|
assert row.opnsense_remote_uuid == "remote-1"
|
|
assert row.opnsense_child_uuid == "child-1"
|
|
assert row.opnsense_psk_uuid == "psk-1"
|
|
assert row.last_payload is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deploy_config_write_failure_surfaces_as_error_without_rollback(client, respx_mock):
|
|
"""addChild fails after Connection/Local/Remote already succeeded —
|
|
status must be "error" with a concrete message, and the 3 UUIDs that
|
|
DID get written must still be persisted (no rollback exists for this
|
|
entity, and a retry should update those 3 rather than duplicate them)."""
|
|
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"]
|
|
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addConnection").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "conn-1"})
|
|
)
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addLocal").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "local-1"})
|
|
)
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addRemote").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "remote-1"})
|
|
)
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addChild").mock(
|
|
side_effect=httpx.ConnectError("connection refused")
|
|
)
|
|
reconfigure_route = respx_mock.post("https://fw1.test/api/ipsec/service/reconfigure").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
|
|
dep = resp.json()["deployments"][0]
|
|
assert dep["status"] == "error"
|
|
assert dep["message"]
|
|
# Reconfigure must never be reached — the config write itself failed.
|
|
assert not reconfigure_route.called
|
|
|
|
async with SessionLocal() as db:
|
|
row = (await db.execute(select(IpsecTunnelDeployment))).scalar_one()
|
|
assert row.opnsense_connection_uuid == "conn-1"
|
|
assert row.opnsense_local_uuid == "local-1"
|
|
assert row.opnsense_remote_uuid == "remote-1"
|
|
assert row.opnsense_child_uuid is None
|
|
assert row.opnsense_psk_uuid is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, respx_mock):
|
|
"""All 5 config writes succeed, but the follow-up reconfigure call
|
|
fails — the plan specifies an exact, non-generic message here because
|
|
"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/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
|
|
tunnel_id = resp.json()["id"]
|
|
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addConnection").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "conn-1"})
|
|
)
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addLocal").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "local-1"})
|
|
)
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addRemote").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "remote-1"})
|
|
)
|
|
respx_mock.post("https://fw1.test/api/ipsec/connections/addChild").mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "child-1"})
|
|
)
|
|
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")
|
|
)
|
|
|
|
resp = await deploy_approved(client, f"/api/ipsec/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(IpsecTunnelDeployment))).scalar_one()
|
|
# All 5 objects were in fact written — no rollback happens.
|
|
assert row.opnsense_connection_uuid == "conn-1"
|
|
assert row.opnsense_local_uuid == "local-1"
|
|
assert row.opnsense_remote_uuid == "remote-1"
|
|
assert row.opnsense_child_uuid == "child-1"
|
|
assert row.opnsense_psk_uuid == "psk-1"
|
|
assert row.last_payload is not None
|
|
|
|
|
|
@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/ipsec/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/ipsec/tunnels/{tunnel_id}/deploy")
|
|
|
|
add_route = respx_mock.post("https://fw1.test/api/ipsec/connections/addConnection")
|
|
|
|
resp = await deploy_approved(client, f"/api/ipsec/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_child_local_remote_connection_then_psk(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")
|
|
await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
|
|
|
|
del_child = respx_mock.post("https://fw1.test/api/ipsec/connections/delChild/child-1").mock(
|
|
return_value=httpx.Response(200, json={"result": "deleted"})
|
|
)
|
|
del_local = respx_mock.post("https://fw1.test/api/ipsec/connections/delLocal/local-1").mock(
|
|
return_value=httpx.Response(200, json={"result": "deleted"})
|
|
)
|
|
del_remote = respx_mock.post("https://fw1.test/api/ipsec/connections/delRemote/remote-1").mock(
|
|
return_value=httpx.Response(200, json={"result": "deleted"})
|
|
)
|
|
del_conn = respx_mock.post("https://fw1.test/api/ipsec/connections/delConnection/conn-1").mock(
|
|
return_value=httpx.Response(200, json={"result": "deleted"})
|
|
)
|
|
del_psk = respx_mock.post("https://fw1.test/api/ipsec/preSharedKeys/delItem/psk-1").mock(
|
|
return_value=httpx.Response(200, json={"result": "deleted"})
|
|
)
|
|
|
|
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
|