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.
246 lines
11 KiB
Python
246 lines
11 KiB
Python
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.database import SessionLocal
|
|
from app.models import HostOperation
|
|
from app.models.carp_maintenance_window import CarpMaintenanceWindow
|
|
from app.services import carp_maintenance_window_service, notification_checks
|
|
from tests.test_rules import _create_host
|
|
|
|
|
|
async def _backdate_and_acknowledge(client, host, operation_id: str):
|
|
async with SessionLocal() as db:
|
|
op = await db.get(HostOperation, uuid.UUID(operation_id))
|
|
op.started_at = datetime.now(timezone.utc) - timedelta(seconds=6)
|
|
await db.commit()
|
|
resp = await client.post(f"/api/hosts/{host['id']}/maintenance/{operation_id}/acknowledge", json={"confirm_name": host["name"]})
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
def _stateful_cluster(respx_mock):
|
|
"""Same stateful role/maintenance mock shape as
|
|
test_maintenance.py::test_carp_handover_and_return_are_preview_bound_and_verified,
|
|
reused here since the auto-revert sweep needs the same realistic
|
|
toggle-flips-the-role behavior to verify against."""
|
|
roles = {
|
|
"https://fw-primary.test": {"role": "MASTER", "maintenance": False},
|
|
"https://fw-secondary.test": {"role": "BACKUP", "maintenance": False},
|
|
}
|
|
for base in roles:
|
|
def vip_response(request, base=base):
|
|
current = roles[base]
|
|
return httpx.Response(200, json={
|
|
"rows": [{"vhid": "1", "status": current["role"]}],
|
|
"carp": {"allow": "1", "maintenancemode": current["maintenance"], "demotion": "240" if current["maintenance"] else "0", "status_msg": ""},
|
|
})
|
|
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=vip_response)
|
|
respx_mock.get(f"{base}/api/core/firmware/running").respond(200, json={"status": "ready"})
|
|
|
|
def toggle(request):
|
|
current = roles["https://fw-primary.test"]
|
|
current["maintenance"] = not current["maintenance"]
|
|
if current["maintenance"]:
|
|
current["role"] = "BACKUP"; roles["https://fw-secondary.test"]["role"] = "MASTER"; action = "enter_maintenance"
|
|
else:
|
|
current["role"] = "MASTER"; roles["https://fw-secondary.test"]["role"] = "BACKUP"; action = "leave_maintenance"
|
|
return httpx.Response(200, json={"status": "ok", "action": action})
|
|
switch = respx_mock.post("https://fw-primary.test/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=toggle)
|
|
return roles, switch
|
|
|
|
|
|
async def _make_cluster(client):
|
|
primary = await _create_host(client, "fw-primary", "https://fw-primary.test")
|
|
secondary = await _create_host(client, "fw-secondary", "https://fw-secondary.test")
|
|
await client.patch(f"/api/hosts/{primary['id']}", json={"cluster_name": "ha"})
|
|
await client.patch(f"/api/hosts/{secondary['id']}", json={"cluster_name": "ha"})
|
|
return primary, secondary
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_carp_execute_with_auto_revert_creates_window(client, respx_mock):
|
|
_stateful_cluster(respx_mock)
|
|
primary, secondary = await _make_cluster(client)
|
|
|
|
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
|
|
resp = 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": 15},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
async with SessionLocal() as db:
|
|
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
|
|
assert window is not None
|
|
assert window.status == "active"
|
|
assert window.auto_revert_at > datetime.now(timezone.utc)
|
|
|
|
await _backdate_and_acknowledge(client, primary, resp.json()["id"])
|
|
|
|
# The preview for the follow-up carp_leave should surface the window.
|
|
leave_plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
|
|
assert leave_plan["state"]["auto_revert_window"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_carp_leave_cancels_pending_window(client, respx_mock):
|
|
_stateful_cluster(respx_mock)
|
|
primary, secondary = await _make_cluster(client)
|
|
|
|
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
|
|
entered = 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},
|
|
)
|
|
await _backdate_and_acknowledge(client, primary, entered.json()["id"])
|
|
|
|
leave_plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
|
|
assert leave_plan["action"] == "carp_leave"
|
|
resp = await client.post(
|
|
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
|
|
json={"preview_id": leave_plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
async with SessionLocal() as db:
|
|
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
|
|
assert window is None
|
|
all_windows = (await db.execute(select(CarpMaintenanceWindow))).scalars().all()
|
|
assert len(all_windows) == 1
|
|
assert all_windows[0].status == "cancelled_manual"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sweep_ignores_window_not_yet_due(client, respx_mock):
|
|
roles, switch = _stateful_cluster(respx_mock)
|
|
primary, secondary = await _make_cluster(client)
|
|
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},
|
|
)
|
|
assert switch.call_count == 1
|
|
|
|
async with SessionLocal() as db:
|
|
resolved = await carp_maintenance_window_service.sweep_expired(db)
|
|
assert resolved == []
|
|
assert switch.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sweep_reverts_expired_window(client, respx_mock):
|
|
roles, switch = _stateful_cluster(respx_mock)
|
|
primary, secondary = await _make_cluster(client)
|
|
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},
|
|
)
|
|
assert roles["https://fw-primary.test"]["maintenance"] is True
|
|
|
|
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()
|
|
|
|
resolved = await carp_maintenance_window_service.sweep_expired(db)
|
|
|
|
assert len(resolved) == 1
|
|
assert resolved[0].status == "reverted"
|
|
assert switch.call_count == 2
|
|
assert roles["https://fw-primary.test"]["maintenance"] is False
|
|
|
|
async with SessionLocal() as db:
|
|
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(
|
|
return_value=httpx.Response(200, json={"rows": [{"vhid": "1", "status": "BACKUP"}], "carp": {"allow": "1", "maintenancemode": True, "demotion": "240"}})
|
|
)
|
|
a = await _create_host(client, "fw-a", "https://fw-a.test")
|
|
|
|
async with SessionLocal() as db:
|
|
from app.models.host import OpnsenseHost
|
|
host = await db.get(OpnsenseHost, uuid.UUID(a["id"]))
|
|
window = await carp_maintenance_window_service.open_window(db, host, 30)
|
|
window.auto_revert_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
await db.commit()
|
|
|
|
respx_mock.post("https://fw-a.test/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=httpx.ConnectError("down"))
|
|
|
|
async with SessionLocal() as db:
|
|
resolved = await carp_maintenance_window_service.sweep_expired(db)
|
|
assert len(resolved) == 1
|
|
assert resolved[0].status == "revert_failed"
|
|
|
|
async with SessionLocal() as db:
|
|
problems = await notification_checks.check_carp_auto_revert_failed(db)
|
|
assert len(problems) == 1
|
|
assert next(iter(problems.values()))["host_name"] == "fw-a"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_roundtrip_carp_auto_revert_failed_toggle(client):
|
|
resp = await client.get("/api/notifications/settings")
|
|
assert resp.json()["carp_auto_revert_failed_enabled"] is True
|
|
|
|
resp = await client.put("/api/notifications/settings", json={"carp_auto_revert_failed_enabled": False})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["carp_auto_revert_failed_enabled"] is False
|