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.
141 lines
6.3 KiB
Python
141 lines
6.3 KiB
Python
"""Optional auto-revert timer layered on top of the existing CARP
|
|
maintenance-mode toggle (opnsense_client.toggle_carp_maintenance, driven by
|
|
routers/maintenance.py's carp-preview/carp-execute/{id}/acknowledge flow).
|
|
|
|
Unlike a rule/NAT deploy, entering CARP maintenance is a role handover, not
|
|
a config write that could sever the management path — so, unlike the VPN
|
|
trilogy's deliberate no-auto-revert stance, automatically calling the same
|
|
toggle back is safe and prevents the real, already-observed failure mode of
|
|
a host being left demoted indefinitely because a human forgot to switch it
|
|
back. The sweep bypasses the human confirm-name/acknowledge gate entirely
|
|
(same rationale _deploy_revert_sweep_loop already uses elsewhere): the
|
|
human already opted in by choosing a duration when entering maintenance.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
async def open_window(db: AsyncSession, host: OpnsenseHost, auto_revert_minutes: int | None) -> CarpMaintenanceWindow | None:
|
|
if not auto_revert_minutes or auto_revert_minutes <= 0:
|
|
return None
|
|
window = CarpMaintenanceWindow(
|
|
host_id=host.id, host_name=host.name,
|
|
auto_revert_at=_now() + timedelta(minutes=auto_revert_minutes),
|
|
status="active",
|
|
)
|
|
db.add(window)
|
|
await db.commit()
|
|
return window
|
|
|
|
|
|
async def active_window(db: AsyncSession, host_id) -> CarpMaintenanceWindow | None:
|
|
return await db.scalar(
|
|
select(CarpMaintenanceWindow)
|
|
.where(CarpMaintenanceWindow.host_id == host_id, CarpMaintenanceWindow.status == "active")
|
|
.order_by(CarpMaintenanceWindow.entered_at.desc())
|
|
)
|
|
|
|
|
|
async def close_windows_for_manual_leave(db: AsyncSession, host: OpnsenseHost) -> None:
|
|
"""Called when a human completes a carp_leave themselves, pre-empting
|
|
any running timer for that host."""
|
|
result = await db.execute(
|
|
select(CarpMaintenanceWindow).where(CarpMaintenanceWindow.host_id == host.id, CarpMaintenanceWindow.status == "active")
|
|
)
|
|
for window in result.scalars().all():
|
|
window.status = "cancelled_manual"
|
|
window.resolved_at = _now()
|
|
window.message = "Manuell vor Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet."
|
|
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(
|
|
CarpMaintenanceWindow.status == "active", CarpMaintenanceWindow.auto_revert_at <= _now(),
|
|
)
|
|
)
|
|
windows = list(result.scalars().all())
|
|
resolved: list[CarpMaintenanceWindow] = []
|
|
for window in windows:
|
|
host = await db.get(OpnsenseHost, window.host_id)
|
|
if not host:
|
|
window.status = "revert_failed"
|
|
window.resolved_at = _now()
|
|
window.message = "Host wurde gelöscht, bevor das Zeitfenster ablief."
|
|
resolved.append(window)
|
|
continue
|
|
try:
|
|
statuses = await host_status_service.get_statuses([host])
|
|
if not statuses.get(host.id, {}).get("carp_maintenance_mode"):
|
|
# Already left maintenance some other way (manual action that
|
|
# didn't go through close_windows_for_manual_leave, e.g. a
|
|
# direct OPNsense-side change) — nothing to do.
|
|
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)
|
|
if result_payload.get("action") != "leave_maintenance":
|
|
raise client.OpnsenseAPIError("CARP-Antwort passt nicht zur erwarteten Richtung (leave_maintenance)")
|
|
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),
|
|
message=window.message, details={"response": result_payload},
|
|
)
|
|
except client.OpnsenseAPIError as exc:
|
|
window.status = "revert_failed"
|
|
window.resolved_at = _now()
|
|
window.message = f"Automatischer Rückschalt-Versuch fehlgeschlagen: {exc}"[:800]
|
|
await audit_service.persist(
|
|
kind="host", action="carp_auto_revert", status="revert_failed",
|
|
host_id=str(host.id), host_name=host.name, entity_id=str(window.id),
|
|
message=window.message,
|
|
)
|
|
resolved.append(window)
|
|
|
|
if resolved:
|
|
await db.commit()
|
|
return resolved
|