Das Problem
Vier Server hängen auf derselben Bridge, alle mit demselben Default-Gateway. Drei erreichen das Core-Netz. Einer nicht.
srv-c3po:~# nc -z -w3 10.99.99.64 3100 && echo offen || echo zu
offen
srv-r2d2:~# nc -z -w3 10.99.99.64 3100 && echo offen || echo zu
zu
Kein Timeout, kein „connection refused" — einfach zu. Port 22 genauso. Port 80 genauso. Interessanterweise funktioniert das Internet von dem Host aus einwandfrei, DNS auch, Pakete gehen raus.
Der Reflex ist jetzt: lokale Firewall prüfen. Auf einem Proxmox-Host also pve-firewall, iptables -S OUTPUT, nft list ruleset. Ergebnis: alles ACCEPT, keine pve-Regeln, ip rule zeigt nur die Standard-Tabellen. Ausgeschlossen.
Der zweite Reflex: Routing. ip route zeigt default via 192.168.66.1 und die Route ins Core-Netz über den WireGuard-Tunnel. ip route get 10.99.99.64 liefert genau diese Route. Sieht korrekt aus.
Die Diagnose
Wenn ein Host im eigenen Subnetz alles erreicht und cross-subnet nur ein bestimmter Host nicht, ist der Vergleich mit einem funktionierenden Nachbarn der schnellste Weg. Beide haben dieselbe Bridge, dasselbe Gateway, dieselbe Route.
Also auf dem Gateway nachsehen, was mit dieser einen Quell-IP passiert. Auf OPNsense/pfSense ist pfctl die Wahrheit, nicht die GUI:
gw-vader:~ # pfctl -sr | grep 192.168.66.44
pass in quick on vtnet0 route-to (vtnet1 203.0.113.1) inet from 192.168.66.44 to any flags S/SA keep state
Da ist es. Eine Regel, die allen Verkehr dieser IP auf die WAN-Schnittstelle umleitet. Nicht blockiert — umleitet. Deshalb sieht es von außen wie „Netz weg" aus, während das Internet weiterläuft: route-to schickt die Pakete zum Provider-Router, und der weiß mit 10.99.99.0/24 nichts anzufangen.
Der Gegenbeweis steht in der State-Tabelle. Ein frischer Verbindungsversuch, während der Nachbarhost eine offene Verbindung hat:
gw-vader:~ # pfctl -ss | grep 10.99.99.64
all tcp 10.99.99.64:3100 <- 192.168.66.46:46194 ESTABLISHED:ESTABLISHED
all tcp 10.99.99.64:3100 <- 192.168.66.44:36208 CLOSED:SYN_SENT
SYN_SENT gegen ESTABLISHED — der eine Host fragt, bekommt aber nie eine Antwort, weil seine Pakete über den falschen Weg rausgehen.
Die Ursache
Die Regel stammt aus der Migration. Ihr Kommentar sagt es selbst:
vorerst WAN (Site-to-Site noch nicht) 192.168.66.44/32
Damals war der Tunnel noch nicht fertig, also hat jemand diesen Host per Policy-Route über den WAN-Uplink geschickt, damit er überhaupt Internet hat. Der Tunnel läuft inzwischen, die anderen Server nutzen ihn — nur diese eine Regel blieb stehen. Und quick heißt: sie gewinnt, bevor die normale Route überhaupt gefragt wird.
Aufgefallen ist es nie, weil ein Host ohne Core-Zugriff erstaunlich unauffällig ist. Internet geht, Monitoring läuft (in die andere Richtung), Backups laufen lokal. Erst als die Logs zentral gesammelt werden sollten, fiel auf: dieser Host liefert seit Wochen nur Kernel-Fragmente aus einem Nebentunnel.
Das perfide
Der Fix ist eigentlich trivial: eine Regel davor, die nur das eine Ziel erlaubt, ohne Gateway-Override. Der Weg dorthin war es nicht.
# Regel per PHP in die OPNsense-Config geschrieben, Struktur von einer bestehenden geklont
php /tmp/add-rule.php
WRITTEN uuid=9dfa57b2-e3a7-48f4-8b27-d091b1cdd1d6
# Reload
configctl filter reload
OK
Reload sagt OK. Die Config sagt 24 Regeln. Alles grün.
Nur pfctl hat eine Regel weniger als vorher:
gw-vader:~ # pfctl -sr | grep 192.168.66.44
pass in quick on vtnet0 inet proto tcp from 192.168.66.44 to 10.99.99.64 port = 1514 ...
pass in quick on vtnet0 inet proto udp from 192.168.66.44 to 10.99.99.64 port = 1514 ...
Die route-to-Regel ist verschwunden. Genau die, die den Host ausgesperrt hat — und mit ihr die Sperre. Für zwanzig Minuten hatte der Host vollen Zugriff auf das Core-Netz, ohne dass irgendwo eine Fehlermeldung stand.
Die Ursache: In der OPNsense-Config ist die uuid einer Regel ein XML-Attribut, kein Kind-Element:
<rule uuid="8a6a4ff9-cf8a-463f-b51c-3a610f1a6ee4">
<enabled>1</enabled>
<sequence>40</sequence>
...
Wenn man die Regel als PHP-Array klont und $neu['uuid'] = ... setzt, erzeugt man ein zweites Feld und lässt das Attribut stehen. Zwei Regeln, eine uuid. Das MVC-Modell dedupliziert nach uuid — und wirft eine der beiden still weg. Nicht die neue, sondern die bestehende.
Ein configctl filter reload mit Rückgabecode 0 beweist dabei nur, dass der Reload durchlief. Es beweist nicht, dass die Regelbasis noch die ist, die man erwartet.
Die Lösung
Erst die Regel richtig schreiben: uuid ins Attribut, kein Kind-Element.
$neu = $vorlage;
$neu["@attributes"] = ["uuid" => $neueUuid];
unset($neu["uuid"]);
$neu["sequence"] = "19";
$neu["protocol"] = "TCP/UDP";
$neu["source_net"] = "192.168.66.44/32";
$neu["destination_net"] = "10.99.99.64/32";
$neu["destination_port"] = "1514";
$neu["gateway"] = ""; # leer = normale Routing-Tabelle, also Tunnel
Dann prüfen, bevor man reloadet:
python3 - <<'PY'
import xml.etree.ElementTree as ET, collections
rules = ET.parse('/conf/config.xml').getroot().find('./OPNsense/Firewall/Filter/rules')
uuids = [r.get('uuid') for r in rules]
print('Regeln:', len(uuids), 'eindeutig:', len(set(uuids)))
print('doppelt:', [u for u, c in collections.Counter(uuids).items() if c > 1])
PY
Expected Output (OK):
Regeln: 24 eindeutig: 24
doppelt: []
Nach dem Reload die kompilierte Regelbasis gegen die vorherige vergleichen, nicht nur den Rückgabecode ansehen:
pfctl -sr | grep 192.168.66.44
Expected Output (OK) — drei Zeilen, Ausnahme vor der Policy-Route:
pass in quick on vtnet0 inet proto tcp from 192.168.66.44 to 10.99.99.64 port = 1514 ...
pass in quick on vtnet0 inet proto udp from 192.168.66.44 to 10.99.99.64 port = 1514 ...
pass in quick on vtnet0 route-to (vtnet1 203.0.113.1) inet from 192.168.66.44 to any ...
Und dann der eigentliche Test, vom betroffenen Host:
srv-r2d2:~# nc -z -w3 10.99.99.64 1514 && echo offen # offen
srv-r2d2:~# nc -z -w3 10.99.99.64 22 && echo offen # zu, wie gewollt
srv-r2d2:~# nc -z -w4 1.1.1.1 443 && echo offen # offen, Internet unberührt
Der zweite stille Ausfall
Beim Aufräumen fiel noch etwas auf: Der Kernel-Logging-Parameter zeigte per MAC noch auf die alte Firewall-Hardware.
options netconsole netconsole=6665@192.168.66.44/vmbr0,1514@10.99.99.64/00:0d:b9:45:7e:29
Diese MAC existiert seit der Migration nicht mehr. netconsole ist fire-and-forget — es gibt keinen Fehler, keinen Retry, keine Warnung. Die Crash-Forensik war monatelang „aktiv" und lieferte nichts. Nach dem Wechsel auf die MAC der neuen Gateway-VM kam beim Modul-Reload sofort wieder eine Zeile an.
Genau das ist das Muster, das diesem Blog seinen Namen gibt: grün heißt nicht funktionierend. Ein Dienst, der läuft, ist kein Beweis, dass Daten ankommen. Ein Reload mit Rückgabecode 0 ist kein Beweis, dass die Regelbasis stimmt. Ein nc auf einen offenen Port ist kein Beweis, dass die Anwendung antwortet.
Lessons Learned
- „Erreichbar im eigenen Subnetz, tot cross-subnet" zuerst gegen Policy-Routen prüfen (
pfctl -sr | grep <quell-ip>), bevor man Firewall-Regeln, ARP oder die Routing-Tabelle zerlegt.route-totaucht inip routenicht auf — es ist Gateway-Konfiguration, nicht Host-Konfiguration. - Der pf-State verrät den Weg.
CLOSED:SYN_SENTbeim Ziel, während ein NachbarESTABLISHEDhat, heißt: die Pakete gehen raus, aber falsch. - Nach jedem Config-Schreibzugriff auf Router/Firewall die kompilierte Regelbasis diffen. Zählung,
pfctl -sr,rules.debug— nicht nur den Reload-RC. - Beim Klonen von Regeln die uuid prüfen. In OPNsense ist sie ein Attribut; ein doppeltes uuid-Paar lässt das Modell still eine Regel verlieren. Zehn Sekunden Zählung sparen zwanzig Minuten Ausfall.
- Migrationsreste haben ein Verfallsdatum. „vorerst WAN" ohne Ticket und ohne Datum ist eine Zeitbombe. Wenn der Tunnel läuft, fliegt die Ausnahme raus — an einem Tag, an dem man Zeit hat, nicht an dem, an dem sie stört.
- Hardware-Tausch bricht mehr als die IP. MAC-Adressen in
netconsole,wakeonlan, DHCP-Reservierungen und Switch-ACLs überleben die Migration nicht, aber niemand merkt es, weil nichts fehlschlägt.
Checkliste: Host erreicht das VPN-Netz nicht
- Vom Host:
nc -z -w3 <ziel> <port>— nur dieses Ziel betroffen oder alle? - Internet vom Host testen — trennt „Netz weg" von „Route falsch"
- Lokale Firewall prüfen (
iptables -S,nft list ruleset,pve-firewall status) - Auf dem Gateway:
pfctl -sr | grep <quell-ip>— Policy-Route/route-tovorhanden? -
pfctl -ss | grep <ziel>—SYN_SENTvs.ESTABLISHEDim Vergleich mit einem Nachbarn - Nach Config-Änderung: uuid-Eindeutigkeit zählen,
pfctl -srdiffen - Nach Hardware-Tausch des Gateways: MAC in
netconsole/wakeonlanaktualisieren - Logs gegenprüfen: kommt wirklich etwas an, oder ist der Shipper nur „aktiv"?