OpenVPN in Docker: der Laufzeitvertrag, den ein minimaler Client braucht
Ein geprüftes Image, ein geschütztes Profil, TUN-Zugriff und NET_ADMIN. Was der Container bekommt, was er nicht bekommen darf und wie man beweist, dass der Tunnel funktioniert.
Steckt man einen VPN-Client in einen Container, erhält man einen Namespace mit einem Tunnel darin, nicht einen Host, der seinen gesamten Datenverkehr über ein VPN leitet. Das sind getrennte Projekte, und sie als eines zu behandeln ist der Weg, auf dem Container versehentlich privilegiert werden. Dieses Runbook behandelt nur das erste: ein geprüftes Image, ein geschütztes Profil und einen Clientprozess, der auf seinen eigenen Netzwerk-Namespace beschränkt ist.
Beginne mit einem Artefakt, das jemand bereits freigegeben hat. Die folgenden Schritte setzen ein Image voraus, das die ausführbare Datei openvpn und ein vollständiges Clientprofil enthält, dessen Zertifikat und Schlüsselmaterial bereits innerhalb des Mounts aufgelöst sind. Sie bauen keine Software, stellen keine Zugangsdaten aus und decken weder rootlose Laufzeitumgebungen noch native Windows-Container ab, die jeweils die Antwort ändern.
Was der Container bekommt und was nicht
Halte das Profil und alles, worauf es verweist, in einem lokalen Verzeichnis mit eingeschränkten Rechten, schreibgeschützt eingebunden. Relative Pfade im Profil müssen sich innerhalb dieses Mounts auflösen; das ist ein Detail, das Leute übersehen, wenn ein Zertifikat ein Verzeichnis über dem Profil liegt und der Container es nie sieht.
services:
vpn:
image: ${VPN_CLIENT_IMAGE:?set the reviewed image reference}
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- ./vpn:/vpn:ro
working_dir: /vpn
entrypoint: ["openvpn"]
command: ["--config", "/vpn/client.ovpn"]
Diese Datei gewährt drei Dinge und verlangt nichts weiter. Die Image-Referenz sollte per Digest festgelegt werden, wenn die Deployment-Richtlinie das vorsieht, und das Profil sollte das geprüfte sein, nicht eine Kopie, die jemand direkt auf dem Host bearbeitet hat.
Schreibgeschützt bedeutet hier, dass der Container deine Dateien auf dem Host nicht ändern kann; es bedeutet nicht, dass der Container den eingebundenen privaten Schlüssel nicht lesen kann. Genau das ist der Sinn des Mounts, und deshalb spielen Dateirechte, Eigentum und die Art, wie das Geheimnis auf den Host gelangt ist, weiterhin eine Rolle. Übergib niemals ein Passwort auf der Kommandozeile und backe es nicht in eine Image-Ebene ein, denn beides überlebt an Stellen, die du später nicht bereinigen kannst.
Capabilities, Entrypoint und die Falle dazwischen
Zwei Einstellungen sind nötig, und sie erledigen unabhängige Aufgaben. NET_ADMIN erlaubt die Manipulation von Schnittstellen und Routen im Namespace des Containers. Der Geräteeintrag /dev/net/tun erlaubt dem Prozess überhaupt erst, das Tunnelgerät zu öffnen. Gewährt man eines ohne das andere, entsteht ein klarer Fehler, was praktisch ist: ein fehlendes TUN-Gerät scheitert beim Öffnen, während eine fehlende Capability scheitert, wenn der Client die Schnittstelle konfigurieren will.
Keines von beidem rechtfertigt privileged: true. Ein privilegierter Container erhält alle Capabilities plus Gerätezugriff, und das ist eine viel größere Gewährung, als diese Arbeitslast braucht. Die Referenz zu Laufzeitprivilegien und Capabilities erklärt, wie weit diese Gewährung reicht. Wenn ein Chart oder ein Blogbeitrag danach verlangt, prüfe zuerst, ob die Anforderung nicht eigentlich ein fehlender Gerätepfad oder eine fehlende Capability war.
Das Überschreiben von entrypoint ist der Teil, der beißt. openvpn direkt aufzurufen ist für ein gewöhnliches Image richtig und für jedes Produktimage falsch, dessen Startskript Firewall-Regeln, Resolver-Dateien oder Zugangsdaten vorbereitet, bevor es den Daemon startet. Dieses Skript zu überschreiben überspringt die Vorbereitung und lässt dich einen Tunnel debuggen, der in einem halb konfigurierten Namespace hochkam.
Lass den Client im Vordergrund laufen und der Hauptprozess des Containers sein. Ein Prozess, der daemonisiert, beendet sich sofort, der Container startet in einer Schleife neu, und die benötigten Logs verteilen sich über Neustarts, die alle gleich aussehen.
Prüfe von innerhalb des Namespace, nicht vom Host aus
Bring den Stack hoch, bestätige im Log, dass der Client einen verbundenen Zustand erreicht hat, und untersuche dann den Namespace, der den Tunnel tatsächlich hält.
docker compose up -d
docker compose logs --tail 40 vpn
docker compose exec vpn ip -br addr
docker compose exec vpn ip route
Die Adressliste sollte eine Tunnelschnittstelle mit einer Adresse aus dem Pool zeigen, und die Routentabelle sollte die gepushten Ziele zeigen, die darauf verweisen. Fehlt die Schnittstelle, hat der Client den Punkt ihrer Erstellung nie erreicht. Existiert sie, nutzt aber keine Route sie, sucht man beim Push oder bei den Routenoptionen.
Teste dann das Ziel, das dir wichtig ist, aus dem Namespace, der den Tunnel nutzen soll, mit der Anwendung und nicht nur mit einem Ping. Namensauflösung ist ein eigener Fehler, den es sich lohnt, ausdrücklich zu testen: Eine gepushte DNS-Adresse aktualisiert den Resolver eines Containers nicht von selbst, und ein eigenständiger Client, der den Push ignoriert, löst Namen über das auf, was das Image konfiguriert hat.
Der getrennte Zustand ist Teil des Vertrags
Das Wiederverbindungsverhalten braucht eine eigene Prüfung, denn eine Neustartrichtlinie beweist, dass der Prozess neu startet, und sagt nichts darüber, was der Datenverkehr dazwischen tut. Stoppe den Tunnel, versuche das beabsichtigte Ziel und beobachte, ob die Verbindung abgelehnt wird, hängt oder still über die normale Route des Containers gelingt. Das letzte Ergebnis ist das entscheidende, und es ist in einem gesunden Log unsichtbar.
Wenn die Arbeitslast niemals den normalen Weg nutzen darf, entwirf das ausdrücklich mit Firewall-Regeln und einem Bereitschaftssignal innerhalb des Namespace. Ein Kill-Switch ist eine Richtlinie, die du schreibst und testest, keine Eigenschaft, die du erbst, indem du einen Client in einem Container laufen lässt.
Denke schließlich an die Grenze: Diese Routen leben im Namespace des Containers und ersetzen nicht die Routing-Tabelle des Hosts. Dockers Weiterleitungs-, Übersetzungs- und Firewall-Regeln bleiben Teil des äußeren Wegs, und ein veröffentlichter Port ist keine VPN-Schnittstelle.