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

VPN Standort A - Tailscale Subnet Router

Inhaltsverzeichnis

VPN Standort A
#

Verbindet den Cluster als WireGuard-Client mit einem entfernten Standort und macht die dort erreichbaren Netze über Headscale im gesamten Tailnet verfügbar.

Nicht zu verwechseln mit apps/wireguard: dort läuft ein WireGuard-Server mit Web-Portal für Mitarbeiter-Zugänge. Hier geht es ausschließlich um eine Site-to-Site-Anbindung. Die Schwester-App apps/vpn-site-b setzt dasselbe Muster mit OpenVPN als Transport um.

Funktion
#

1Tailnet-Client ──(WireGuard/DERP)──> Headscale ──> Pod vpn-site-a
2                                                    ├── tailscale0  (Subnet Router)
3                                                    └── wg0         (Tunnel zum Standort)
4                                                                     └──> Netze des Standorts
  • 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 an Headscale an und annonciert die Netze des Standorts über TS_ROUTES.

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.

--snat-subnet-routes sorgt dafür, dass Pakete aus dem Tailnet auf die Tunnel-IP dieses Peers ge-SNAT-tet werden. Die Gegenstelle braucht dadurch keine Rückroute in den Tailnet-Adressbereich.

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.

Konfiguration
#

Vor dem ersten Deploy müssen drei Stellen gefüllt werden:

  1. wg/wg0.conf - privater Schlüssel dieses Peers, Tunnel-Adresse, Endpoint und PublicKey der Gegenstelle sowie AllowedIPs.
  2. TS_ROUTES in values.yaml - die ins Tailnet zu annoncierenden Netze.
  3. TS_AUTHKEY in kustomization.yaml - Pre-Auth-Key aus Headscale, siehe unten.

Der Key wird nur beim ersten Start verbraucht. Danach liegt der Node-State im Secret vpn-site-a-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. Nur die konkreten Netze der Gegenstelle eintragen.
  • Kein DNS = ... in wg0.conf. Das überschreibt /etc/resolv.conf im Pod und zerstört Cluster-DNS sowie die Namensauflösung des Headscale-Servers.
  • 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.
  • Routen müssen in Headscale freigegeben werden. Annoncierte Routen sind bis zur Freigabe inaktiv - entweder über autoApprovers in der Policy oder manuell:
1kubectl -n headscale exec deploy/headscale -- headscale nodes list
2kubectl -n headscale exec deploy/headscale -- headscale nodes approve-routes --identifier <id> --routes <netze>

Zugriffskontrolle
#

Der Node trägt tag:site-a 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-a --expiration 8760h

Serverseitig gesetzt ist der Tag autoritativ und vom Client nicht überschreibbar. Schickt der Client zusätzlich --advertise-tags=tag:site-a, prüft Headscale diese Anforderung gegen tagOwners und lehnt die Registrierung mit requested tags [...] are invalid or not permitted ab, solange die Policy den Tag nicht kennt. Deshalb steht in TS_EXTRA_ARGS kein --advertise-tags.

Freigegeben wird ausschließlich über eine explizite Regel für group:site-a in apps/headscale/config-patch.yaml:

1{
2  "action": "accept",
3  "src": ["group:site-a"],
4  "dst": ["<netz-des-standorts>:*"]
5}

Subnetz-Routen werden über die Ziel-CIDR gematcht, nicht über den Tag des Subnet Routers. Ein "dst": ["tag:site-a:*"] würde nur den Router-Node selbst freigeben, nicht die Netze dahinter. Deshalb stehen die CIDRs direkt im dst.

Da die Policy keine Catch-all-Regel hat, gilt Default-Deny: alle anderen Tailnet-Mitglieder sehen die Routen nicht - auch dann nicht, wenn sie sich per OIDC anmelden können. Zu beachten ist nur die bestehende Regel group:user → tag:router:*; dieser Node trägt deshalb bewusst nicht tag:router.

Wegen --snat-subnet-routes sieht die Gegenstelle sämtlichen Traffic mit der Tunnel-IP dieses Peers als Quelle. Eine Filterung nach Benutzer ist dort also nicht mehr möglich - die Zugriffskontrolle muss vollständig in der Headscale-ACL stattfinden.

Einschränkung auf einzelne Geräte
#

group:site-a erlaubt den Zugriff von allen Geräten der genannten Benutzer. Soll nur ein bestimmter Rechner zugreifen dürfen, muss dieses Gerät getaggt und der Tag als src verwendet werden:

1"tagOwners": { "tag:site-a-client": ["group:admin"] },
2"acls": [
3  { "action": "accept", "src": ["tag:site-a-client"], "dst": ["<netz-des-standorts>:*"] }
4]

Getaggte Geräte verlieren allerdings die Benutzerzuordnung und ihr Node-Key läuft nicht mehr ab.

Überlappende Adressbereiche
#

Mehrere Standorte nutzen typischerweise denselben RFC1918-Raum. Annoncieren zwei Subnet Router dieselbe Prefix, ist das Ziel im Tailnet mehrdeutig - welcher Router zum Zug kommt, ist nicht deterministisch.

Als Zwischenlösung lässt sich die Überlappung über Longest-Prefix-Match auflösen: der Standort mit dem kleineren tatsächlichen Bedarf annonciert ein engeres Prefix, das gegen das weitere des anderen gewinnt. Wichtig dabei: eingegrenzt wird nur TS_ROUTES, nicht AllowedIPs - Letzteres steuert, was der Tunnel transportiert, nicht was ins Tailnet annonciert wird. Aus dem Pod heraus bleibt der volle Bereich erreichbar.

Diese Lösung ist fragil: neue statische Adressen am eingegrenzten Standort müssen innerhalb des annoncierten Prefixes bleiben, sonst werden sie zum anderen Standort geroutet und sind nicht erreichbar. Ein solcher Fehler ist schwer zuzuordnen, wenn man die Aufteilung nicht kennt.

Tragfähig ist stattdessen 4via6: jeder Standort bekommt eine Site-ID, die Route wird als IPv6-/96 unter fd7a:115c:a1e0:b1a::/64 annonciert, das die IPv4-Adresse einbettet. Kollisionen sind konstruktionsbedingt ausgeschlossen. Spätestens ab dem dritten Standort führt kein Weg daran vorbei. Die Headscale-Unterstützung ist vorher zu verifizieren.

Endpoint mit dynamischer IP
#

WireGuard löst den Endpoint-Hostnamen ausschließlich beim wg-quick up auf und danach nie wieder. Sitzt die Gegenstelle an einem Anschluss mit wechselnder IP hinter DynDNS, sendet der Tunnel nach der Zwangstrennung dauerhaft an die alte Adresse. Der Tunnel wirkt dann bestehend, überträgt aber nichts, und nur ein Neustart des Pods hilft.

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 nach:

1wg set wg0 peer <pubkey> <endpoint-name:port>

wg set resolvt den Namen dabei selbst — das ist der einzige Weg, eine geänderte Gegenstellen-IP zu übernehmen, ohne den Tunnel komplett neu aufzubauen. Der Sidecar nutzt dasselbe Image wie der Hauptcontainer, weil wg dort bereits enthalten ist, und läuft mit readOnlyRootFilesystem und nur NET_ADMIN.

Im Log ist der Vorgang nachvollziehbar:

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

Bleibt die Adresse gleich, meldet er das ebenfalls — dann ist die Gegenstelle schlicht offline und ein Neuauflösen hilft nicht.

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, nicht dessen bloße Existenz. Der Unterschied ist wesentlich: der Zeitstempel bleibt nach dem ersten Handshake für immer stehen, eine Prüfung auf „größer null" würde einen toten Tunnel also nie bemerken. Mit PersistentKeepalive 25 erneuert WireGuard den Handshake etwa alle zwei Minuten; die Schwelle liegt bei 150 Sekunden.

Die Liveness-Probe prüft weiterhin nur, ob das Interface existiert. Sie bewusst nicht auf das Handshake-Alter umzustellen hat einen Grund: der Netzwerk-Namespace überlebt einen Container-Neustart, wg0 existiert danach also noch. Ob wg-quick up das sauber übernimmt, hängt daran, dass beim SIGTERM das Herunterfahren des Interfaces durchläuft. Schlägt das fehl, entsteht statt einer Selbstheilung ein CrashLoop. Das Nachfassen übernimmt deshalb der Sidecar, der ohne Neustart auskommt.

1kubectl -n vpn-site-a exec deploy/vpn-site-a -c wireguard -- wg show
2kubectl -n vpn-site-a exec deploy/vpn-site-a -c tailscale -- tailscale status

Installation
#

Die Anwendung wird mittels Kustomize und Helm durch ArgoCD bereitgestellt; der Eintrag steht in manifests.yaml.

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

Abhängigkeiten
#

  • Laufender Headscale (apps/headscale).
  • Node-Kernel mit WireGuard-Unterstützung; /lib/modules wird read-only eingehängt, damit das Modul bei Bedarf nachgeladen werden kann.
  • /dev/net/tun auf dem Node für den Kernel-Modus von tailscaled und wg-quick.
  • Erreichbarer WireGuard-Endpoint der Gegenstelle (UDP ausgehend).

Offene Punkte
#

Der private WireGuard-Schlüssel und der Headscale-Pre-Auth-Key liegen im Klartext im Repository - konsistent mit dem restlichen Repo, aber weiterhin 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