Files
OPNexus/backend/app/services/nat_drift_service.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

86 lines
3.8 KiB
Python

"""Backend port of frontend/components/LiveNatDrift.tsx's fingerprint/diff
logic for the three NAT types, mirroring rule_drift_service.py's approach
for firewall rules. Keep the three fingerprint functions here in exact sync
with LiveNatDrift.tsx's frontend copies.
Field names verified against OPNsense core source before use, not guessed:
- Port Forward (D-NAT): DNatController::searchRuleAction returns "descr"
(not "description") and dotted flat keys "source.network"/"source.port"/
"destination.network"/"destination.port" (not nested objects — that
nesting only exists in the addRule/setRule *write* payload shape used by
port_forward_deploy_service.py's _build_payload, not the search response).
- One-to-One / Outbound NAT: flat "description"/"source_net"/
"destination_net" etc., matching the field names already verified and
used by one_to_one_deploy_service.py / outbound_nat_deploy_service.py's
own _build_payload (search response and write payload agree here since
neither model nests source/destination).
Only the "rules" key (user-managed) is compared — "automatic_rules" are
OPNsense-internal, host/interface-specific, and would produce false-positive
drift if compared across hosts (see nat_live_service.py's own docstring).
"""
from app.models.host import OpnsenseHost
from app.services import opnsense_client
from app.services.nat_live_service import split_rows
from app.services.opnsense_client import OpnsenseAPIError
def port_forward_fingerprint(row: dict) -> str:
return "|".join(str(row.get(key, "")) for key in (
"descr", "interface", "protocol", "source.network", "source.port",
"destination.network", "destination.port", "target", "local-port",
))
def one_to_one_fingerprint(row: dict) -> str:
return "|".join(str(row.get(key, "")) for key in (
"description", "interface", "type", "source_net", "destination_net", "external",
))
def outbound_nat_fingerprint(row: dict) -> str:
return "|".join(str(row.get(key, "")) for key in (
"description", "interface", "protocol", "source_net", "source_port",
"destination_net", "destination_port", "target", "target_port",
))
_SPECS = {
"port_forward": (opnsense_client.search_port_forward_rules, port_forward_fingerprint),
"one_to_one": (opnsense_client.search_one_to_one_rules, one_to_one_fingerprint),
"outbound_nat": (opnsense_client.search_outbound_nat_rules, outbound_nat_fingerprint),
}
async def _live_rows_or_none(host: OpnsenseHost, nat_type: str) -> list[dict] | None:
search_fn, _ = _SPECS[nat_type]
try:
data = await search_fn(host)
return split_rows(data)["rules"]
except OpnsenseAPIError:
return None
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]
per_host = {host.id: await _live_rows_or_none(host, nat_type) for host in hosts}
reachable_ids = {host_id for host_id, rows in per_host.items() if rows is not None}
by_fingerprint: dict[str, dict] = {}
for host_id in reachable_ids:
for row in per_host[host_id]:
fp = fingerprint_fn(row)
entry = by_fingerprint.setdefault(fp, {"fingerprint": fp, "sample": row, "present_on": set()})
entry["present_on"].add(host_id)
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)], reachable_ids