Zum Hauptinhalt springen
  1. Beiträge/
  2. Kleines Homelab/
  3. k3s-prod: Kubernetes Cluster Konfiguration und Anwendungsbereitstellung/

VPN Standort C - Anbindung an das CDP-Tailnet

Inhaltsverzeichnis

VPN Standort C
#

Verbindet den Cluster als WireGuard-Client mit dem WireGuard-Server im CDP-Cluster (vpn.casa-due-pur.de). Die Adressen des dortigen Tailnets (100.64.2.0/23, fd7a:115c:a1e0:2::/64) werden über Headscale im eigenen Tailnet erreichbar.

Nicht zu verwechseln mit apps/wireguard: dort läuft ein WireGuard-Server mit Web-Portal für Mitarbeiter-Zugänge. Die Schwester-Apps apps/vpn-site-a (WireGuard) und apps/vpn-site-b (OpenVPN) setzen dasselbe Muster für andere Standorte um.

Funktion
#

1zyria-Tailnet-Client
2  └─> Pod vpn-site-c                           (zyria-Cluster)
3        ├── tailscale0  (Subnet Router, annonciert das CDP-Tailnet)
4        └── wg0         (MASQUERADE auf 10.26.15.1)
5              └─> vpn.casa-due-pur.de:51194    (CDP-Cluster, apps/wireguard)
6                    wg0 ──> tailscale0         (Gateway-Container, MASQUERADE)
7                              └─> CDP-Tailnet-Node 100.64.2.x
  • wireguard: linuxserver/wireguard im Client-Modus. Sobald unter /config/wg_confs eine *.conf liegt, führt der Entrypoint lediglich wg-quick up wg0 aus und startet keinen Server.
  • tailscale: meldet sich mit einem Pre-Auth-Key am zyria-Headscale an und annonciert die CDP-Tailnet-Bereiche über TS_ROUTES.
  • coredns: beantwortet die Zone hsn.casa-due-pur.de für das eigene Tailnet und holt die Antworten durch den Tunnel beim CDP-Gateway, siehe Namensauflösung.
  • Gegenstelle: Auf der CDP-Seite reicht der Container tailscale im Controller main von apps/wireguard die Pakete ins CDP-Tailnet weiter. Ohne ihn endet der Verkehr am WireGuard-Server. Die Freigabe ist dort auf die Tunnel-IP dieses Peers beschränkt (GW_PEERS_V4/GW_PEERS_V6).

Warum ein Pod mit zwei Containern
#

Container eines Pods teilen sich den Netzwerk-Namespace. Dadurch sieht tailscaled das von wg-quick angelegte Interface wg0 samt Routen und kann Traffic aus dem Tailnet direkt in den Tunnel weiterleiten. Mit zwei getrennten Deployments wäre das nicht möglich, der Tunnel läge in einem fremden Namespace.

Ein Init-Container setzt net.ipv4.ip_forward und net.ipv6.conf.all.forwarding. Diese Sysctls sind netzwerk-namespaced und betreffen daher nur diesen Pod, nicht den Node.

Besonderheiten dieser Anbindung
#

Das Ziel ist hier ein anderes Tailnet und kein RFC1918-Netz. Daraus ergeben sich drei Abweichungen von vpn-site-a.

--netfilter-mode=off statt --snat-subnet-routes. Im Standardmodus installiert tailscaled in ts-forward die Regel -s 100.64.0.0/10 -o tailscale0 -j DROP. Die Antworten aus dem CDP-Tailnet kommen über wg0 mit einer Quelle aus 100.64.2.0/23 und würden genau dort verworfen: Der Hinweg funktioniert, jede Antwort verschwindet. Eine vorgeschaltete ACCEPT-Regel hält nicht, weil tailscaled seine Hooks bei jedem Neustart wieder an Position 1 setzt. Das Maskieren auf die Tunnel-IP übernimmt deshalb PostUp in wg/wg0.conf.

IPv6-Policy-Regel. tailscaled legt fd7a:115c:a1e0::/48 in Routing-Tabelle 52 ab. Deren Regel (Priorität 5270) greift vor main. Das CDP-Netz fd7a:115c:a1e0:2::/64 liegt in diesem Bereich und liefe ohne Gegenmaßnahme zurück in tailscale0. PostUp setzt deshalb ip -6 rule add to fd7a:115c:a1e0:2::/64 lookup main priority 5200. Für IPv4 ist das nicht nötig: Tabelle 52 enthält dort nur die /32 der eigenen Nodes aus 100.64.0.0/23.

Kein Adresskonflikt, aber enge Nachbarschaft. Das eigene Tailnet nutzt 100.64.0.0/23 und fd7a:115c:a1e0::/48 mit sequentieller Vergabe aus fd7a:115c:a1e0::/64. Würde eines der beiden Headscale-Präfixe erweitert oder verschoben, kollidiert es mit dem CDP-Tailnet.

Konfiguration
#

  1. wg/wg0.conf: Peer „Fabrice Zuhause“ aus wg-portal auf vpn.casa-due-pur.de, ergänzt um PostUp/PostDown. Die vom Portal generierte DNS-Zeile muss bei jedem Neu-Export wieder entfernt werden.
  2. TS_ROUTES in values.yaml: identisch mit AllowedIPs in wg0.conf, bis auf 10.26.15.112/32. Diese Adresse braucht nur der DNS-Forwarder, sie darf nicht ins Tailnet annonciert werden.
  3. TS_AUTHKEY in kustomization.yaml: Pre-Auth-Key mit Forced Tag tag:site-c, siehe unten.

Der Key wird nur beim ersten Start verbraucht. Danach liegt der Node-State im Secret vpn-site-c-tailscale-state, das tailscaled selbst pflegt (siehe rbac.yaml).

Fallstricke
#

  • AllowedIPs niemals 0.0.0.0/0. wg-quick baut dann Policy-Routing mit fwmark auf und kappt die Verbindung des Pods zum Cluster-Netz und zu Headscale.
  • Kein DNS = ... in wg0.conf. wg-quick ruft damit resolvconf auf und überschreibt /etc/resolv.conf für alle Container des Pods. tailscaled kann danach kubernetes.default.svc nicht mehr auflösen und bricht beim Laden seines State-Secrets ab. Die geänderte Datei überlebt Container-Neustarts, erst ein neuer Pod behebt es.
  • PostUp-Befehle idempotent halten. wg-quick führt Hooks mit set -e aus. Ein Befehl, der beim zweiten Start fehlschlägt, reißt den Tunnel ab.
  • TS_ACCEPT_ROUTES bleibt false. Sonst übernimmt tailscaled die Subnet-Routen des eigenen Tailnets in Tabelle 52, darunter 159.69.238.80/28 vom Node wireguard. Tabelle 52 greift vor main, und der WireGuard-Endpoint 159.69.238.91 liegt in genau diesem Netz: Die Tunnelpakete liefen dann selbst durch das Tailnet und hingen an einem fremden Router. Dasselbe gilt für den Gateway-Container auf cdp-Seite.
  • TS_ACCEPT_DNS bleibt false. Headscale ist mit override_local_dns konfiguriert; ein Router-Pod, der die Tailnet-Nameserver übernimmt, kann seinen eigenen Control-Server nicht mehr auflösen.
  • TS_ROUTES ohne Leerzeichen nach dem Komma. Tailscale splittet nur an , und übergibt das Ergebnis ungetrimmt an netip.ParsePrefix.

Zugriffskontrolle
#

Der Node trägt tag:site-c als Forced Tag vom Pre-Auth-Key, nicht über --advertise-tags:

1kubectl -n headscale exec deploy/headscale -- \
2  headscale preauthkeys create --user <user> --reusable --tags tag:site-c --expiration 8760h

Serverseitig gesetzt ist der Tag autoritativ und vom Client nicht überschreibbar. Schickt der Client zusätzlich --advertise-tags, prüft Headscale das gegen tagOwners und lehnt die Registrierung ab, solange die Policy den Tag nicht kennt.

In apps/headscale/config-patch.yaml stehen dafür:

  • group:site-c mit einer Regel auf 100.64.2.0/23:* und fd7a:115c:a1e0:2::/64:*. Subnetz-Routen werden über die Ziel-CIDR gematcht, nicht über den Tag des Routers.
  • tag:site-c in tagOwners.
  • autoApprovers für beide Präfixe auf tag:site-c.

Die Zugriffskontrolle ist zweistufig: Die zyria-ACL entscheidet, wer den Tunnel benutzen darf. Die CDP-ACL entscheidet, was der Gateway-Node dort erreicht. Wegen der doppelten Maskierung sieht die CDP-Seite jeden Zugriff als Verkehr des Gateway-Nodes. Eine Unterscheidung nach zyria-Benutzer ist dort nicht möglich.

Namensauflösung für das CDP-Tailnet
#

Die MagicDNS-Namen des CDP-Tailnets (*.hsn.casa-due-pur.de) kennt nur dessen Headscale. Ein CoreDNS im Pod holt sie live durch den Tunnel:

1zyria-Client ──> 100.100.100.100 (Split-DNS hsn.casa-due-pur.de) ──> 100.64.0.12:53
2  └─> coredns im Pod vpn-site-c
3        └─> wg0 ──> 10.26.15.112:53            (wg0-Adresse des CDP-WireGuard-Servers)
4              └─> DNAT im Gateway-Container ──> 100.100.100.100 (MagicDNS des CDP-Gateways)
  • Die Zone und die Zieladresse stehen in dns.nameservers.split von apps/headscale/values.yaml. Freigegeben ist der Zugriff auf tag:site-c:53 nur für group:site-c.
  • 10.26.15.112/32 steht in AllowedIPs, damit CoreDNS den Server durch den Tunnel erreicht. In TS_ROUTES fehlt die Adresse bewusst.
  • Auf CDP-Seite leitet der Gateway-Container von apps/wireguard Anfragen an GW_DNS_V4:53 auf sein MagicDNS weiter. Die umgeleiteten Pakete durchlaufen dieselbe Sperre wg-gateway: Nur die freigeschalteten Peers bekommen Antworten.
  • Neue oder umbenannte CDP-Nodes sind sofort auflösbar, höchstens verzögert um den Cache von 120 Sekunden. Es gibt nichts zu pflegen.
  • Ohne Tunnel liefert die Zone SERVFAIL. Einen öffentlichen Fallback gibt es nicht, weil die Namen nur im CDP-Tailnet existieren.
  • Kurznamen wie winmgr funktionieren nicht, nur vollständige Namen. Eine Suchdomain würde mit gleichnamigen Nodes im eigenen Tailnet kollidieren (addc, wireguard, rustdesk).

Prüfen:

1kubectl -n vpn-site-c exec deploy/vpn-site-c -c wireguard -- nslookup winmgr.hsn.casa-due-pur.de 127.0.0.1
1dig +short winmgr.hsn.casa-due-pur.de @100.100.100.100

Endpoint mit dynamischer IP
#

WireGuard löst den Endpoint-Hostnamen ausschließlich beim wg-quick up auf und danach nie wieder. Ändert sich die Adresse der Gegenstelle, sendet der Tunnel dauerhaft an die alte Adresse.

Ein Sidecar-Container prüft deshalb alle 30 Sekunden das Alter des letzten Handshakes. Überschreitet es 180 Sekunden, löst er den Namen neu auf und setzt den Endpoint per wg set wg0 peer <pubkey> endpoint <name:port> nach. Der Sidecar nutzt dasselbe Image wie der Hauptcontainer und läuft mit readOnlyRootFilesystem und nur NET_ADMIN.

1[STALE] Handshake 187s alt, Endpoint aktuell <alte-adresse>
2[FIXED] Endpoint neu aufgeloest: <alte-adresse> -> <neue-adresse>

Betrieb
#

Der Pod läuft mit replicas: 1 und strategy: Recreate. Zwei gleichzeitig laufende Pods würden sich Tunnel-IP und Headscale-Node streitig machen.

Die Readiness-Probe prüft das Alter des letzten Handshakes (Schwelle 150 Sekunden), nicht dessen bloße Existenz. Die Liveness-Probe prüft nur, ob wg0 existiert. Das Nachfassen bei veraltetem Handshake übernimmt der Sidecar ohne Neustart.

1kubectl -n vpn-site-c exec deploy/vpn-site-c -c wireguard -- wg show
2kubectl -n vpn-site-c exec deploy/vpn-site-c -c tailscale -- tailscale status
3kubectl -n vpn-site-c exec deploy/vpn-site-c -c wireguard -- ip -6 rule
4kubectl -n vpn-site-c exec deploy/vpn-site-c -c wireguard -- iptables -t nat -S POSTROUTING

Installation
#

Die Anwendung wird mittels Kustomize und Helm bereitgestellt.

1kubectl kustomize --enable-helm apps/vpn-site-c | kubectl apply -f -

Abhängigkeiten
#

  • Laufender Headscale (apps/headscale) mit den Policy-Einträgen für tag:site-c.
  • Auf der CDP-Seite: apps/wireguard mit Gateway-Container und dieser Peer-IP in GW_PEERS_V4/GW_PEERS_V6.
  • Node-Kernel mit WireGuard-Unterstützung; /lib/modules wird read-only eingehängt.
  • /dev/net/tun auf dem Node für den Kernel-Modus von tailscaled und wg-quick.
  • Ausgehend UDP 51194 zu vpn.casa-due-pur.de.

Offene Punkte
#

Der private WireGuard-Schlüssel und der Headscale-Pre-Auth-Key liegen im Klartext im Repository. Das ist konsistent mit dem restlichen Repo, bleibt aber die offene Security-Aufgabe (SOPS oder Sealed Secrets), die auch in .pre-commit-config.yaml vermerkt ist.

Fabrice Kirchner
Autor
Fabrice Kirchner
stolzer Vater, Nerd, Admin