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.
86 lines
3.8 KiB
Python
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
|