Zet een VPN-client in een container en je krijgt één naamruimte met daarin een tunnel, niet een host die al zijn verkeer via een VPN leidt. Dat zijn afzonderlijke projecten, en ze als één behandelen is precies hoe containers per ongeluk bevoorrecht raken. Deze handleiding behandelt alleen het eerste: één gecontroleerd image, één beschermd profiel en een clientproces dat beperkt blijft tot zijn eigen netwerknaamruimte.

Begin met een artefact dat iemand al heeft goedgekeurd. De stappen hieronder gaan uit van een image met het uitvoerbare bestand openvpn en een volledig clientprofiel waarvan het certificaat en het sleutelmateriaal al binnen de mount zijn opgelost. Ze bouwen geen software, geven geen inloggegevens uit en dekken geen rootloze runtime-omgevingen of native Windows-containers, die elk het antwoord veranderen.

Wat de container wel en niet krijgt

Houd het profiel en alles waarnaar het verwijst in een lokale map met beperkte rechten, read-only gemount. Relatieve paden in het profiel moeten binnen die mount worden opgelost; dat is een detail dat mensen missen wanneer een certificaat één map boven het profiel staat en de container het nooit ziet.

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"]

Dat bestand geeft drie dingen en vraagt niets anders. De imagereferentie moet op digest worden vastgezet als je deploymentbeleid dat zegt, en het profiel moet het gecontroleerde zijn, niet een kopie die iemand ter plaatse op de host heeft bewerkt.

Read-only betekent hier dat de container je bestanden op de host niet kan wijzigen; het betekent niet dat de container de privésleutel die je hebt gemount niet kan lezen. Dat is het hele punt van de mount, en daarom blijven bestandsrechten, eigendom en de manier waarop het geheim op de host is gekomen van belang. Geef nooit een wachtwoord mee op de opdrachtregel en bak het niet in een imagelaag, want beide overleven op plekken die je later niet kunt opruimen.

Capabilities, entrypoint en de val ertussen

Er zijn twee instellingen nodig en ze doen onverwante taken. NET_ADMIN staat manipulatie van interfaces en routes in de naamruimte van de container toe. De apparaatregel /dev/net/tun staat het proces toe het tunnelapparaat überhaupt te openen. Het ene zonder het andere geven levert een duidelijke fout op, wat handig is: een ontbrekend TUN-apparaat faalt bij het openen, terwijl een ontbrekende capability faalt wanneer de client de interface probeert te configureren.

Geen van beide rechtvaardigt privileged: true. Een bevoorrechte container krijgt alle capabilities plus apparaattoegang, en dat is een veel grotere toekenning dan deze workload nodig heeft. De referentie over runtimetoegangen en capabilities legt uit hoe ver die toekenning reikt. Als een chart of een blogpost erom vraagt, controleer dan eerst of de eis eigenlijk een ontbrekend apparaatpad of een ontbrekende capability was.

Het overschrijven van entrypoint is het deel dat bijt. openvpn rechtstreeks aanroepen is juist voor een gewoon image en onjuist voor elk productimage waarvan het startscript firewallregels, resolverbestanden of inloggegevens voorbereidt voordat de daemon wordt gestart. Dat script overschrijven slaat de voorbereiding over en laat je een tunnel debuggen die opkwam in een half geconfigureerde naamruimte.

Laat de client op de voorgrond draaien en het hoofdproces van de container zijn. Een proces dat daemoniseert, stopt onmiddellijk, de container herstart in een lus, en de logs die je nodig hebt zijn verspreid over herstarts die er allemaal hetzelfde uitzien.

Controleer van binnenuit de naamruimte, niet vanaf de host

Breng de stack omhoog, bevestig in de log dat de client een verbonden toestand heeft bereikt en bekijk dan de naamruimte die de tunnel daadwerkelijk bevat.

docker compose up -d
docker compose logs --tail 40 vpn
docker compose exec vpn ip -br addr
docker compose exec vpn ip route

De adressenlijst zou een tunnelinterface met een adres uit de pool moeten tonen, en de routetabel zou de gepushte bestemmingen moeten tonen die daarheen wijzen. Ontbreekt de interface, dan is de client nooit aan het maken ervan toegekomen. Bestaat hij wel maar gebruikt geen enkele route hem, dan moet je bij de push of de routeopties kijken.

Test daarna de bestemming die je belangrijk vindt, vanuit de naamruimte die de tunnel hoort te gebruiken, met de toepassing en niet alleen met een ping. Naamresolutie is een aparte fout die je expliciet moet testen: een gepusht DNS-adres werkt de resolver van een container niet vanzelf bij, en een losse client die de push negeert, lost namen op via wat het image heeft geconfigureerd.

De verbroken toestand is onderdeel van het contract

Herverbindingsgedrag heeft zijn eigen controle nodig, want een herstartbeleid bewijst dat het proces herstart en zegt niets over wat het verkeer ondertussen doet. Stop de tunnel, probeer de bedoelde bestemming en kijk of de verbinding wordt geweigerd, blijft hangen of stilzwijgend slaagt via de gewone route van de container. De laatste uitkomst is degene die ertoe doet, en die is onzichtbaar in een gezonde log.

Als de workload nooit de gewone weg mag gebruiken, ontwerp dat dan expliciet met firewallregels en een gereedheidssignaal binnen de naamruimte. Een kill switch is een beleid dat je schrijft en test, geen eigenschap die je erft door een client in een container te draaien.

Onthoud ten slotte de grens: deze routes leven in de naamruimte van de container en vervangen de routetabel van de host niet. De doorstuur-, vertaal- en firewallregels van Docker blijven deel van het buitenste pad, en een gepubliceerde poort is geen VPN-interface.

Gerelateerde artikelen