VPN Standort B #
Verbindet den Cluster als OpenVPN-Client mit einem entfernten Standort und macht die dort erreichbaren Netze über Headscale im Tailnet verfügbar.
Gleiches Muster wie apps/vpn-site-a, nur mit OpenVPN statt WireGuard als Transport. Die dortige README beschreibt die gemeinsamen Grundlagen ausführlicher; hier stehen die Unterschiede.
Funktion #
1Tailnet-Client ──(WireGuard/DERP)──> Headscale ──> Pod vpn-site-b
2 ├── tailscale0 (Subnet Router)
3 └── tun0 (OpenVPN zum Standort)Container eines Pods teilen sich den Netzwerk-Namespace. Dadurch sieht tailscaled das von OpenVPN angelegte tun0 samt Routen und kann Traffic aus dem Tailnet direkt in den Tunnel weiterleiten.
Anders als bei WireGuard läuft OpenVPN rein im Userspace über /dev/net/tun. Der Container braucht deshalb weder die SYS_MODULE-Capability noch einen hostPath auf /lib/modules, läuft mit readOnlyRootFilesystem und droppt alle Capabilities außer NET_ADMIN.
Das Image openvpn-docker sucht die erste *.ovpn unter /etc/openvpn und startet sie via exec openvpn --config .... OpenVPN läuft damit als PID 1 — stirbt der Prozess, startet Kubernetes den Container neu.
Vorbereitung #
Zertifikat und Zugangsdaten gehören nach vpn/. Das Verzeichnis wird vollständig nach /etc/openvpn gemountet:
| Datei | Inhalt |
|---|---|
config.ovpn |
Tunnel-Konfiguration |
zertifikat.p12 |
Client-Zertifikat (PKCS#12) |
passfile |
Benutzername und Passwort, je eine Zeile |
askpass |
Passwort des PKCS#12-Containers, eine Zeile |
Ohne askpass fragt OpenVPN das Zertifikatspasswort interaktiv auf der Konsole ab und der Container bleibt beim Start hängen. Es ist in der Regel nicht identisch mit dem Passwort in der passfile.
An der gelieferten config.ovpn sind drei Anpassungen nötig:
1pkcs12 /etc/openvpn/zertifikat.p12
2auth-user-pass /etc/openvpn/passfile
3askpass /etc/openvpn/askpass
4
5pull-filter ignore "redirect-gateway"
6pull-filter ignore "dhcp-option DNS"Absolute Pfade sind Pflicht. Relative Pfade löst OpenVPN gegen das Arbeitsverzeichnis auf, nicht gegen das Config-Verzeichnis — und das ist bei exec openvpn --config ... schlicht /.
Warum die pull-filter Zeilen #
redirect-gatewayist das OpenVPN-Äquivalent zuAllowedIPs = 0.0.0.0/0. Pusht der Server das, wandert die Default-Route des Pods in den Tunnel und die Verbindung zu Cluster und Headscale ist weg. Anders als bei WireGuard kann die Gegenstelle das erzwingen — deshalb der Filter.dhcp-option DNSist ohne--up-Skript zwar wirkungslos, wird aber vorsorglich verworfen, damit/etc/resolv.confim Pod unangetastet bleibt.
persist-tun sollte gesetzt sein: ohne die Option reißt OpenVPN bei jedem Reconnect tun0 ab, die Routen sind weg und tailscaled annonciert ins Leere. persist-key ist ab OpenVPN 2.6 wirkungslos — Schlüssel werden immer über Neustarts gehalten, die Option wird nur noch mit einer Deprecation-Meldung quittiert.
Der Container läuft mit readOnlyRootFilesystem: true. OpenVPN besteht aber auf einem beschreibbaren --tmp-dir und bricht sonst schon beim Parsen der Optionen ab. Dafür ist ein eigenes emptyDir auf /tmp eingehängt, statt das read-only-Root aufzugeben.
Routen #
Die vom Server gepushten Routen gehören nach TS_ROUTES in values.yaml — ohne Leerzeichen nach dem Komma, da Tailscale nur an , splittet und ungetrimmt an netip.ParsePrefix übergibt. Was tatsächlich ankommt:
1kubectl -n vpn-site-b exec deploy/vpn-site-b -c openvpn -- ip route show dev tun0Nicht zu annoncieren ist die Hostroute auf den p2p-Peer, die bei topology net30 mitkommt — sie ist nur innerhalb des Tunnels relevant.
Zu prüfen ist außerdem, dass der VPN-Endpoint selbst in keinem der annoncierten Netze liegt. Sonst schneidet sich der Tunnel den eigenen Weg ab.
Die gepushten Routen können sich serverseitig ändern, ohne dass TS_ROUTES mitzieht. Wer es deterministisch mag, ergänzt in der config.ovpn route-nopull plus explizite route-Zeilen; dann stehen die Netze ausschließlich in Git. Umgekehrt gilt: Netze, die der Server nicht routet, lassen sich client-seitig auch nicht erzwingen.
Überlappende Adressbereiche #
Nutzt ein weiterer Standort denselben RFC1918-Bereich, ist das Ziel im Tailnet mehrdeutig. Der Abschnitt „Überlappende Adressbereiche" in apps/vpn-site-a/README.md beschreibt die Auflösung über Longest-Prefix-Match und warum 4via6 die tragfähige Lösung ist.
DNS #
Pusht die Gegenstelle Nameserver und Suchdomains, bleibt das im Container wirkungslos — es gibt kein --up-Skript, und der pull-filter verwirft es zusätzlich. Namensauflösung für den Standort wird stattdessen als Split-DNS gebaut.
Der Forwarder muss im Tunnel-Pod laufen. Die Resolver der Gegenstelle sind ausschließlich über tun0 erreichbar; ein gewöhnlicher Cluster-Pod hat keine Route dorthin. Der Pod enthält deshalb einen dritten Container mit CoreDNS, der die Zone des Standorts an deren Resolver weiterleitet:
1Tailnet-Client ──> Headscale split-DNS ──> Tailscale-Adresse des Pods
2 └── coredns :53
3 ├── tun0 ──> Resolver des Standorts
4 └── Fallback: öffentliche ResolverDie Weiterleitung steht in dns.nameservers.split in apps/headscale, nicht im LAN-weiten Resolver. Damit erfährt kein LAN-Gerät, dass die Zone existiert, und niemand dort bekommt interne Adressen zu sehen.
Der Forwarder lauscht auf 53, weil Headscale-Split-DNS keinen anderen Port kennt. Mit tailscaled im selben Netzwerk-Namespace kollidiert das nicht: dessen 100.100.100.100 wird im Tunnel behandelt, ohne Socket im Host-Stack. Port 53 ist privilegiert, der Container braucht deshalb NET_BIND_SERVICE — Capabilities werden unabhängig von der UID gedroppt, runAsUser: 0 allein genügt nicht. Alles außerhalb der Zone beantwortet er mit REFUSED; ein allgemeiner Resolver ist er nicht.
Auflösung und Zugriff sind entkoppelt. Split-DNS in Headscale gilt tailnet-weit und kennt keine Gruppen. Ohne Gegenmaßnahme liefe die Anfrage eines Unberechtigten in den Paketfilter und die Zone wäre für ihn gar nicht mehr auflösbar — auch ihr öffentlicher Teil nicht, den er vorher problemlos erreicht hat. Eine eigene ACL-Regel gibt deshalb allen Benutzern Port 53 auf dem Router-Node frei, während die Subnetze weiterhin nur den Berechtigten offenstehen:
1{ "action": "accept", "src": ["group:user"], "dst": ["tag:site-b:53"] }Hier ist ein Tag als dst korrekt — Ziel ist der Node selbst, nicht eines seiner annoncierten Subnetze.
Wartungsfalle: Split-DNS akzeptiert nur IP-Adressen, keine MagicDNS-Namen. In apps/headscale steht damit die Tailscale-Adresse des Pods fest verdrahtet. Wird der Node gelöscht und neu registriert, ändert sie sich und die Auflösung bricht still.
Eine Zone genügt: CoreDNS matcht per Suffix, Sub-Zonen sind damit abgedeckt.
Zu beachten: Die Antworten sind interne Adressen des Standorts. Wer sie auflösen kann, aber laut ACL keinen Zugriff hat, bekommt eine IP, die er nicht erreicht. Namensauflösung ersetzt die Zugriffsfreigabe nicht.
Wenn die Gegenstelle selbst Split-Horizon betreibt #
Der Regelfall bei AD-Umgebungen: dieselbe Zone existiert intern mit anderen Records als öffentlich. Vor dem Einrichten lohnt ein Vergleich beider Sichten:
1kubectl -n <namespace> exec deploy/<app> -c openvpn -- nslookup <name> <interner-resolver>
2dig +short <name> @1.1.1.1Drei Dinge sind dabei zu klären:
Ist der interne Resolver rekursiv? Liefert er für öffentliche Namen der Zone auch die öffentliche Antwort, ist das Weiterleiten der ganzen Zone unkritisch. Antwortet er nur für interne Namen, brechen die öffentlichen — dann darf nur weitergeleitet werden, was intern existiert.
Wo liegt die AD-Domain? Ist der Zonen-Apex selbst die AD-Domain, hängen alle internen Hosts direkt darunter und die Zone muss komplett weitergeleitet werden. Nur Sub-Zonen weiterzuleiten funktioniert lediglich, wenn die internen Namen ausschließlich dort liegen. Prüfen über die SRV-Records des Domain-Controller-Locators:
1nslookup -type=SRV _ldap._tcp.<zone> <interner-resolver>Was passiert bei Tunnel-Ausfall? Ohne Vorkehrung wird ein VPN-Ausfall zum DNS-Ausfall für die gesamte Zone — auch für öffentlich erreichbare Dienste und für alle Clients im LAN, nicht nur die mit Zugriff. Deshalb stehen in apps/nameserver hinter der ClusterIP zusätzlich öffentliche Resolver als Fallback, mit policy sequential und health_check. Fällt der Tunnel aus, liefern interne Namen schnell NXDOMAIN statt nach Timeout SERVFAIL, und alles öffentlich Auflösbare funktioniert weiter.
Reichweite: Die Weiterleitung sitzt im LAN-weiten Resolver und gilt damit für alle Clients, nicht nur für die mit Zugriff auf den Standort. Eine Eingrenzung nach Client-IP wäre über das view-Plugin von CoreDNS denkbar, scheitert hier aber daran, dass Tailnet-Clients wegen --snat-subnet-routes mit der Adresse des Subnet Routers ankommen und nicht unterscheidbar sind. Wer die Reichweite dennoch begrenzen will, verlagert die Weiterleitung nach dns.nameservers.split in Headscale — dann erreicht sie nur Tailnet-Clients, dafür keine LAN-Geräte mehr.
Zugriffskontrolle #
Der Node trägt tag:site-b 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-b --expiration 8760hSchickt der Client zusätzlich --advertise-tags, prüft Headscale das gegen tagOwners und lehnt die Registrierung ab, solange die Policy den Tag nicht kennt.
Freigegeben wird über eine explizite Regel in apps/headscale/config-patch.yaml. src deckt zwei Fälle ab:
1{
2 "action": "accept",
3 "src": ["group:site-b", "tag:site-b-client"],
4 "dst": ["<netze-des-standorts>:*"]
5}- Benutzer über
group:site-b— erlaubt den Zugriff von allen Geräten dieser Person. - Einzelne Geräte über
tag:site-b-client— nur die so getaggte Maschine kommt durch:
1kubectl -n headscale exec deploy/headscale -- headscale nodes tag --identifier <id> --tags tag:site-b-clientGetaggte Geräte verlieren allerdings die Benutzerzuordnung und ihr Node-Key läuft nicht mehr ab.
Subnetz-Routen werden über die Ziel-CIDR gematcht, nicht über den Tag des Subnet Routers. Ein "dst": ["tag:site-b:*"] würde nur den Router-Node selbst freigeben, nicht die Netze dahinter.
Da die Policy keine Catch-all-Regel hat, gilt Default-Deny. 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 nicht möglich — die Zugriffskontrolle muss vollständig in der Headscale-ACL stattfinden.
Betrieb #
Der Pod läuft mit replicas: 1 und strategy: Recreate.
1kubectl -n vpn-site-b logs deploy/vpn-site-b -c openvpn
2kubectl -n vpn-site-b exec deploy/vpn-site-b -c tailscale -- tailscale statusDie Readiness-Probe prüft, ob über tun0 Routen stehen. Das ist schwächer als der Handshake-Check bei WireGuard, weil persist-tun das Interface über Verbindungsabbrüche hinweg bestehen lässt. Sobald die Zielnetze feststehen, ist ein Ping auf einen Host der Gegenstelle die bessere Probe.
Installation #
1kubectl kustomize --enable-helm apps/vpn-site-b | kubectl apply -f -Abhängigkeiten #
- Laufender Headscale (
apps/headscale). - Image
git.zyria.de/pyrox/openvpn-docker. /dev/net/tunauf dem Node.- Ausgehend UDP zum VPN-Endpoint der Gegenstelle.
Offene Punkte #
- Der Image-Tag steht auf
latest. Sobald das Tag-Schema der Registry feststeht, pinnen, damit Renovate greift. - Zertifikat,
passfileundaskpassliegen unverschlüsselt im Repo — dieselbe offene SOPS-/Sealed-Secrets-Aufgabe wie beim Rest des Repos. Alternativ das Secret manuell im Cluster anlegen und densecretGeneratoraus derkustomization.yamlentfernen.