Een VPN is geen product. Het zijn twee taken aan elkaar geschroefd, en jouw machine heeft voor allebei al een programma.
De eerste taak is een virtuele link maken — een interface die eruitziet als een netwerkkaart, IP-pakketten aanneemt en ze elders aflevert. De tweede is de bytes van die link van het ene eind naar het andere dragen. Koop een VPN-appliance en je koopt beide taken met een licentie erop geniet; doe het zelf en elk ervan is één programma dat al geïnstalleerd is. Er zijn onder Linux twee manieren om de eerste taak te doen, en dit stuk bouwt ze allebei: pppd, dat een heel linkprotocol meebrengt — onderhandelde adressen, een levenscheck, framing — en een tap-device, dat niets meebrengt en een gat in de kernel is waar je frames in duwt. Verschillende afwegingen, geen concurrenten, en de tweede helft zet ze naast elkaar.
Dat protocol is PPP, en de reden dat dit werkt staat in zijn eerste alinea: PPP is een linkprotocol voor punt-tot-punt-verbindingen1, en het legt niet vast waar de link van gemaakt is. Een modem en een telefoonlijn waren het oorspronkelijke antwoord. Ze waren nooit het enige. PPTP stopte PPP in GRE2. L2TP stopte het in UDP3. PPPoE stopte het in ethernetframes. Stuk voor stuk hetzelfde protocol met een andere koerier, en geen van alle vroeg om een wijziging aan PPP.
De vraag is dus niet óf je PPP over een TCP- of UDP-socket kunt draaien, maar welke van de twee, en wat het kost. Het korte antwoord, met het rekenwerk erbij: neem UDP. Een streamprotocol binnen een betrouwbare stream draaien is hier de ene fout die op je bureau prima oogt en op een echte lijn uit elkaar valt.
Een VPN Is Twee Taken. Je Hebt Ze Allebei Al.
Trek van welke tunnel dan ook de marketing af en er gebeuren drie dingen: iets presenteert een virtuele interface en maakt van pakketten een bytestream, iets draagt die stream over een netwerk dat al werkt, en iets versleutelt hem, of niets doet dat.
PPP doet de linkhelft netjes — adresonderhandeling, een levenscheck, headercompressie, meerdere netwerklaagprotocollen over één link, een authenticatiestap als je die wilt — een hoop afgerond werk dat in /usr/sbin niets ligt te doen. Wat het niet doet, is zich druk maken over de drager: geef het een filedescriptor die bytes twee kanten op beweegt en het draait eroverheen. Netcat is een programma waarvan het hele bestaansrecht is om die filedescriptor te zijn.
De versleuteling is het deel dat niemand je aanreikt, en het is het deel waar het meeste van dit stuk aan opgaat, want netcat heeft er geen antwoord op en doen alsof van wel is hoe mensen dingen bouwen die ze niet moeten bouwen.
De Fasen, En Waarom Ze In Deze Volgorde Komen
Alles hieronder wordt in fasen gebouwd, elke fase voegt één ding toe aan de vorige. Dat is geen lesmethode. Zo hoor je het te bouwen, want als fase zes stukgaat moet je weten of fase twee nog werkt, en dat weet je alleen als fase twee ooit iets was dat je op zichzelf hebt gedraaid.
De link zelf wordt op dezelfde manier gebouwd — de redenering achter de oploop van één seconde staat verderop: een tunnel komt rauw op, bewijst dat hij een frame kan dragen, en wordt pas daarna slim. Een bouw die alles tegelijk aanzet faalt als één klomp, en dan ben je de avond kwijt aan raden welke laag het deed.
Wat pppd Eigenlijk Wil Is Een Filedescriptor
pppd is voor seriële poorten geschreven, dus de naïeve lezing is dat het er een nodig heeft. Dat is niet zo. Het heeft een terminaldevice nodig, en het maakt er zelf een aan:
pty script — Specifies that the command
scriptis to be used to communicate rather than a specific terminal device. Pppd will allocate itself a pseudo-tty master/slave pair and use the slave as its terminal device. The script will be run in a child process with the pseudo-tty master as its standard input and output.4
Lees dat nog eens — de hele bouw zit erin. pppd maakt een pseudo-terminal, houdt de slave, en geeft de master aan een commando als stdin en stdout. Wat dat commando met de bytes doet, gaat pppd niets aan. Draai daar nc en ze gaan een socket in.
pty noemt erft de master-kant als stdin en stdout. Netcat kopieert stdin naar een socket en de socket naar stdout, dus de twee pppd-instanties praten met elkaar via het pty-paar en het netwerk, zonder dat een van beide weet dat er een socket bestaat.Er is een tweede route, notty, die in plaats daarvan pppd’s eigen stdin en stdout gebruikt. Maar die start een tekenschuifproces waar elke byte doorheen gaat, dus het verhoogt “the latency and CPU overhead”4. Neem pty, tenzij je een reden hebt om dat niet te doen.
Drie praktische feiten vóór het eerste commando, alle drie gecontroleerd op de machine waarop dit geschreven is — Fedora 44, ppp-2.5.1-7.fc44:
$ pppd --version
pppd version 2.5.1
$ ls -l /usr/bin/pppd /dev/ppp
-rwxr-xr-x. 1 root root 393784 Jan 17 2026 /usr/bin/pppd
crw-------. 1 root root 108, 0 Sep 14 08:34 /dev/ppp
$ pppd noauth nodetach pty 'true'
pppd: using the noauth option requires root privilege
Het heeft root nodig. Daar is niet omheen te komen. Het binary is op Fedora niet setuid, /dev/ppp staat op modus 0600 en is van root, en de opties die je nodig hebt zijn sowieso privileged. Dit is iets dat je als root draait of onder een unit-bestand, niet iets dat een gebruiker er even bij doet. Dat is later van belang, als we bij de betekenis voor je egressbeleid komen.
De kernelkant is modulair. ppp_generic doet de interface, ppp_async de byte-gestopte framing die een pty nodig heeft, en ppp_deflate/bsd_comp/ppp_mppe compressie en versleuteling; ze laden op aanvraag. Ontbreekt ppp_async in een uitgeklede containerimage, dan komt de link op en draagt hij niets — een beroerd halfuur als je niet weet waar je moet kijken.
Modembesturingslijnen bestaan niet op een pty. Zonder carrier-detectpin wacht pppd op een drager die nooit komt. De oplossing is één woord, local, dat het zegt de modembesturingslijnen te negeren4. Laat je het weg, dan gebeurt er niets, zonder foutmelding waar je iets aan hebt.
Netcat Is Niet Één Programma
Vóór de bouw eerst de valkuil die de meeste tijd opeet: “netcat” is minstens vier programma’s met onverenigbare vlaggen, en welke je krijgt hangt af van je distributie. Op deze machine is /usr/bin/nc een symlink naar /usr/bin/ncat, de herschrijving van Nmap. Een commando dat je van een vijftien jaar oude wikipagina plukt faalt dus om redenen die niets met PPP te maken hebben.
| Implementatie | Luisteren op een poort | UDP | Open blijven nadat een peer weggaat |
|---|---|---|---|
| Ncat (Nmap) | ncat -l 443 | -u | -k / --keep-open |
| OpenBSD netcat | nc -l 443 | -u | -k |
| Traditional netcat | nc -l -p 443 | -u | nee |
| GNU netcat | nc -l -p 443 | -u | nee |
Kijk dus welke je hebt voordat je pppd de schuld geeft:
readlink -f "$(command -v nc)"
nc --version 2>&1 | head -1 || nc -h 2>&1 | head -1
Hieronder gebruik ik expliciet ncat. Dat is degene met TLS ingebouwd, wat later uitmaakt, en expliciet zijn betekent dat de commando’s op jouw machine niet stilletjes iets anders betekenen.
Fase Eén: De TCP-bouw, Want Dat Is Wat Iedereen Eerst Probeert
Eén eind luistert, één eind verbindt. De adressen hier komen uit het documentatiebereik, dus plak ze zo in een lab en er sneuvelt niets routeerbaars. De poort is overal 443, en dat is opzet: uitgaand 443 staat op vrijwel elk netwerk standaard open. Wikkel de drager een paar fasen verderop in TLS en het verkeer erop is niet te onderscheiden van welke HTTPS-sessie dan ook. Daar een listener binden vereist root, en dat heeft het verre eind — de machine van de aanvaller zelf, of een relay. Dat is het hele egressargument in één poortnummer, en ik kom erop terug.
Op het luisterende eind:
pppd nodetach noauth local passive \
nodefaultroute noipdefault \
192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --listen --keep-open 443'
Op het verbindende eind:
pppd nodetach noauth local \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat 198.51.100.10 443'
Elk woord daar doet werk, en één ervan, noauth, richt in stilte schade aan. De tunnel heeft geen toegangscontrole, en dat krijgt verderop een eigen sectie.
noauth: dat laat peerauthenticatie vallen, dus de listener accepteert wie er ook binnenkomt. nodefaultroute en noipdefault houden de link ervan af om stiekem je routering te herschrijven, local zorgt dat hij überhaupt op een pty opkomt, en het vastgezette lokaal:extern-paar houdt pppd ervan af tijdens IPCP een ander antwoord te accepteren. Het verbindende eind is dezelfde regel zonder passive.Breng beide einden op en je hebt aan weerskanten een ppp0, een punt-tot-punt-route naar het verre adres, en een interface waar je overheen kunt pingen, routeren en tcpdumpen. Dat is een VPN — geen pakket geïnstalleerd, geen daemon geconfigureerd, geen sleutel uitgewisseld, en dat laatste is het probleem, waar ik op terugkom.
Wat Het Onderhandeld Heeft, En Hoe Je Meekijkt
Voeg debug toe en pppd logt de uitwisseling van het stuurprotocol, wat één keer lezen waard is, ook als je het daarna nooit meer leest. De link komt in stappen op, en elke stap kan op zichzelf falen.
De twee nuttige debuggereedschappen zijn al geïnstalleerd en niemand gebruikt ze:
# log elk frame in beide richtingen naar een bestand
pppd ... debug record /tmp/ppp-trace
# lees het daarna terug in leesbare vorm
pppdump -h /tmp/ppp-trace | less
record schrijft een capture met tijdstempels van elke byte, pppdump maakt daar iets leesbaars van4, en met tcpdump -ni ppp0 zie je beide kanten — de framing eronder en de pakketten erbovenop.
Het Frame Op De Lijn, En Waarom 0x7E Overal Zit
PPP over een seriële lijn — en een pty is er een, wat pppd betreft — gebruikt asynchrone HDLC-framing, en dat begrijpen is het verschil tussen dit afstemmen en gokken. Elk frame begint en eindigt met dezelfde byte: “Each frame begins and ends with a Flag Sequence, which is the binary sequence 01111110 (hexadecimal 0x7e)”5. Als 0x7e een grens markeert, mag hij niet binnen een frame voorkomen, dus wordt hij geëscaped. De escapebyte 0x7d ook, en al het andere waar een van beide einden om vraagt.
De escaperegel is exact: “Each Flag Sequence, Control Escape octet, and any octet which is flagged in the sending Async-Control-Character-Map (ACCM), is replaced by a two octet sequence consisting of the Control Escape octet followed by the original octet exclusive-or’d with hexadecimal 0x20”5.
Dat laatste deel is de afstemknop. De ACCM is 32 bits, één per stuurteken, en een 1 betekent “escape deze”. Vraag om alle 32 en op versleutelde payloads, waar de bytes in feite willekeurig zijn, valt ruwweg één byte op de acht onder 0x20 — zo’n 12% overhead voor niets.
Moderne pppd doet hier al het juiste. Lees de man-pagina in plaats van de folklore:
If no asyncmap option is given, the default is zero, so pppd will ask the peer not to escape any control characters.4
asyncmap 0 is dus de standaard, geen truc, en een socket is een schoon 8-bits pad dat geen escaping nodig heeft buiten de twee verplichte bytes. Laat het met rust; zet alleen bits als er echt iets onderweg stuurtekens opeet — een terminalserver, een seriële concentrator, een slechte consoleproxy. De frame check sequence aan het eind is een 16-bits CRC, en dat is het ding dat de UDP-bouw laat werken — die nu volgt.
TCP Over TCP Is De Verkeerde Drager
De bouw hierboven werkt. Op een labtafel, over loopback of een rustig LAN, werkt hij prachtig, en precies daarom sturen mensen hem de deur uit.
Dan komt hij een echt pad met echt verlies tegen. Hij valt om op een manier die op van alles lijkt behalve op wat het is.
Het probleem zijn twee onafhankelijke retransmissietimers die op elkaar gestapeld staan, waarbij de buitenste het verlies voor de binnenste verbergt. Olaf Titz schreef de definitieve uitleg, en opent precies op deze bouw:
A frequently occurring idea for IP tunneling applications is to run a protocol like PPP, which encapsulates IP packets in a format suited for a stream transport (like a modem line), over a TCP-based connection. […] Unfortunately, it doesn’t work well. Long delays and frequent connection aborts are to be expected.6
Zo ziet het eruit. Beide TCP’s zetten een retransmissietimer op basis van hun rondereistijd. Verliest de drager een segment, dan verstuurt hij opnieuw, dus de data van de binnenste verbinding komt laat aan. En de binnenste TCP, die de drager niet kan zien, leest laat als congestie en verstuurt ook opnieuw. Nu heeft de drager het origineel en een duplicaat af te leveren over een lijn die al pakketten kwijtraakt. De binnenste timer verdubbelt, de buitenste wachtrij groeit, en de doorvoer stort in ver voordat de lijn dat doet. Niets in de logs zegt waarom.
Dit is geen subtiel efficiëntiepuntje. Het is het verschil tussen een tunnel die netjes terugvalt en een die bij misschien 2% verlies geen verkeer meer doorlaat terwijl ping over hetzelfde pad er nog prima uitziet — de reden om UDP te nemen, en TCP alleen achter de hand te houden voor een pad dat niets anders doorlaat.
Neem dus UDP — als standaard, niet als voorkeur.
Fase Twee: De UDP-bouw, Het Fundament
Dezelfde pppd, een andere drager. Het enige dat verandert is het pty-commando.
Luisterend eind:
pppd nodetach noauth local passive \
nodefaultroute noipdefault \
192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --udp --listen 198.51.100.10 443'
Verbindend eind:
pppd nodetach noauth local \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 \
mtu 1400 mru 1400 \
pty 'ncat --udp 198.51.100.10 443'
Twee dingen aan een UDP-listener die je te grazen nemen, en geen van beide is een PPP-probleem.
De listener kan niet antwoorden tot er tegen hem gesproken is. Een UDP-socket heeft geen verbinding om te accepteren, dus netcat kan niet weten waarheen het moet antwoorden tot er een datagram binnenkomt. Het klikt vast op het eerste bronadres en de eerste bronpoort die het hoort en praat daartegen. Dat betekent dat het verbindende eind eerst moet zenden — wat pppd uit zichzelf doet, want het niet-passieve eind begint meteen LCP-configure-requests af te vuren. Het betekent ook dat als de bronpoort van de client verandert, de drager stilletjes tegen de verkeerde plek praat. Achter NAT met een korte UDP-timeout is dat een tunnel die om de paar minuten sterft zonder zichtbare reden.
--keep-open doet op UDP niet wat je wilt. Ncat’s -k houdt een TCP-listener accepterend nadat een peer weggaat. Op UDP valt er niets te accepteren, dus herstel betekent de drager herstarten — waar persist en holdoff verderop voor zijn.
Allebei zijn het argumenten voor socat, dat zorgvuldiger met UDP-peers omgaat, en voor een supervisor in plaats van erop vertrouwen dat het blijft draaien.
Waarom PPP Een Verloren Datagram Overleeft
Het voor de hand liggende bezwaar tegen een UDP-drager is dat PPP een bytestream verwacht en UDP dat niet is: datagrammen komen heel aan of helemaal niet, en kunnen in de verkeerde volgorde aankomen. Het werkt toch, dankzij twee bytes die de framing altijd al meedroeg.
PPP over asynchroon HDLC is ontworpen voor een lijn die bytes verminkt. Elk frame draagt een 16-bits frame check sequence; een frame dat daarop faalt wordt weggegooid, en de volgende vlagbyte synchroniseert de ontvanger opnieuw. Herordening is zeldzamer dan verlies en levert dezelfde uitkomst op: een foute FCS, een weggegooid frame, een hersynchronisatie.
Een verloren datagram kost dus één PPP-frame — één IP-pakket, de normale toestand van elk netwerk dat ooit gebouwd is. De eigenaar van het pakket verstuurt opnieuw, en de congestieregeling ziet echt verlies en reageert correct. Dat is het hele argument voor UDP: het laat het verkeer binnen de tunnel de waarheid over het pad ontdekken.
Laat frames wel niet zo groot worden dat de drager ze moet fragmenteren. Eén verloren fragment maakt dan het hele datagram kapot en je effectieve verliespercentage vermenigvuldigt zich. Houd de PPP-MTU ruim onder de pad-MTU.
Adressen, Routes, En IPv6 Fatsoenlijk Doen
ppp0 is een punt-tot-punt-interface — geen subnet, geen ARP — dus lokaal:extern is de hele adressering en je routeert er expliciet overheen. Voor één host die een netwerk achter het verre eind wil bereiken:
# op de client, nadat de link op is
ip route add 203.0.113.0/24 via 192.0.2.1 dev ppp0
Wil het verre eind namens de client doorsturen, dan de gebruikelijke twee stappen:
sysctl -w net.ipv4.ip_forward=1
nft add rule inet nat postrouting oifname "eth0" ip saddr 192.0.2.2/32 masquerade
proxyarp laat de server op zijn eigen segment ARP beantwoorden namens de client4, wat aardig is tot een tweede client hetzelfde wil. Doe dan het deel dat de meesten overslaan. Draai er IPv6 overheen — een punt-tot-punt-link zonder NAT, zonder broadcastdomein en zonder adresschaarste is de makkelijkste plek in je netwerk om IPv6 fatsoenlijk te doen:
pppd nodetach noauth local \
+ipv6 ipv6 ::1,::2 \
nodefaultroute noipdefault \
192.0.2.2:192.0.2.1 \
pty 'ncat --udp 198.51.100.10 443'
+ipv6 zet IPV6CP aan; ipv6 <lokaal>,<extern> zet de twee 64-bits interface-identifiers4, de link komt link-local op, en je zet er een globaal /64 bovenop7. IPV6CP staat los van IPCP, dus noip draagt niets anders dan IPv6 — een redelijk ding om in 2026 te bouwen, in één woord.
Hem Overeind Houden Als De Drager Stil Sterft
Dit is de storing die een middag opeet, dus die krijgt een eigen sectie.
Een TCP-drager die lelijk sterft — het verre eind uitgezet, een NAT-tabelregel verlopen, een middlebox die stopte met doorsturen — sluit niet af. Er is geen FIN, geen RST, niets. Netcat zit daar met een socket die nooit meer een byte zal afleveren, pppd zit daar met een pty die nooit meer een frame zal zien, en ip link meldt monter dat ppp0 UP is. Een UDP-drager heeft helemaal geen verbindingstoestand, dus die merkt sowieso nooit iets.
PPP heeft het antwoord ingebouwd, en het staat standaard uit:
lcp-echo-interval 10 lcp-echo-failure 3
Dat stuurt elke tien seconden een LCP-echo en breekt de link af nadat er drie onbeantwoord blijven4 — dertig seconden om een dode drager te betrappen, op precies het geval dat de man-pagina noemt, “no hardware modem control lines”4, wat elke pty is die ooit gemaakt is. Beslis daarna wat er daarna gebeurt:
persist maxfail 0 holdoff 5
lcp-echo-interval 10 lcp-echo-failure 3 is de detector: elke tien seconden een echo, link omlaag na drie gemiste. persist maxfail 0 holdoff 5 is de herbouw: herstarten, nooit opgeven, vijf seconden wachten zodat een flapperend pad geen forkbom wordt — en pppd draait het pty-commando opnieuw, dus er komt een verse netcat en socket mee. Zet de echo aan beide einden; één supervisor, een systemd-unit met Restart=always, verslaat twee processen die ruziën. Zet ip route in /etc/ppp/ip-up.d/ zodat de routering met de link terugkomt.Het Heeft Geen Versleuteling En Geen Authenticatie
Alles hierboven is een werkende tunnel. Het is geen veilige, en het gat is geen detail.
Er is geen versleuteling. Geen zwakke versleuteling. Geen. Elk pakket dat je hierdoor stuurt staat in klare taal op de lijn, verpakt in een HDLC-frame dat elk capturegereedschap meteen decodeert. tcpdump laat je de inhoud van iemand anders’ tunnel net zo makkelijk zien als van die van jezelf.
Er is geen authenticatie van de peer. noauth zegt dat. Een netcat-listener accepteert wat er ook op de poort binnenkomt: de eerste verbinding of het eerste datagram vanaf waar dan ook. Wie er het eerst is, krijgt een gerouteerde link je netwerk in. PPP kent wel authenticatie (PAP in klare taal, CHAP als challenge-response8), en allebei bewijzen ze wie de peer is terwijl ze geen byte van wat daarna komt versleutelen: CHAP levert je hier een tunnel die weet tegen wie hij praat en de inhoud nog steeds publiceert aan iedereen op het pad.
Er ís een versleutelingsoptie in de PPP-familie — MPPE9, de module in ppp_mppe.ko. Grijp er niet naar: het is RC4 met een sleutel uit de MS-CHAPv2-uitwisseling, al meer dan tien jaar in het openbaar gebroken, en de reden dat PPTP dood is. Daar in 2026 een nieuwe tunnel op bouwen kiest een bekend gebroken cijfer boven een werkend cijfer dat niets kost.
De eerlijke samenvatting tot hier: een gerouteerde link zonder vertrouwelijkheid en zonder toegangscontrole. Prima voor een lab, prima binnen een link die al versleuteld is, nergens anders prima. De oplossing is de drager versleutelen — de komende twee secties, en de reden om ncat te nemen in plaats van welke netcat je distributie ook meestuurde.
Fase Drie: De Drager In TLS Wikkelen
Het nette antwoord laat pppd precies zoals het is en vervangt de drager door een die TLS doet. pppd komt er nooit achter dat er iets veranderd is.
Eerst het certificaat. Een zelfondertekend exemplaar is genoeg, zolang de client het verifieert. Een niet-geverifieerde TLS-sessie is een versleuteld gesprek met iemand die je niet geïdentificeerd hebt, en dat houdt niemand tegen:
openssl req -x509 -newkey rsa:4096 -days 825 -nodes \
-keyout tunnel.key -out tunnel.crt \
-subj "/CN=tunnel.example.net" \
-addext "subjectAltName=DNS:tunnel.example.net"
Ncat
Ncat heeft TLS ingebouwd. Het ketent ook door een proxy, --proxy host:poort --proxy-type http|socks4|socks510, zodat de drager bij een relay kan eindigen in plaats van bij het verre eind van de tunnel. Daar draait de egresssectie om. Luisterend eind:
pppd nodetach noauth local passive \
nodefaultroute noipdefault 192.0.2.1:192.0.2.2 \
lcp-echo-interval 10 lcp-echo-failure 3 mtu 1400 mru 1400 \
pty 'ncat --listen --keep-open --ssl --ssl-cert tunnel.crt --ssl-key tunnel.key 443'
Verbindend eind:
pppd nodetach noauth local \
nodefaultroute noipdefault 192.0.2.2:192.0.2.1 \
lcp-echo-interval 10 lcp-echo-failure 3 mtu 1400 mru 1400 \
pty 'ncat --ssl --ssl-verify --ssl-trustfile tunnel.crt tunnel.example.net 443'
--ssl-verify is het woord dat ertoe doet. Zonder dat levert --ssl je versleuteling tegen een passieve meeluisteraar en niets tegen wie de poort als eerste beantwoordt; mét dat verifieert Ncat vertrouwen en domeinnaam tegen het trustbestand11. Ncat heeft aan de serverkant echter geen clientcertificaatcontrole, dus de server kan de client niet identificeren. Combineer het met CHAP, of neem een van de volgende twee.
Stunnel
De traditionele wikkelaar, en degene die wederzijdse authenticatie netjes doet:
[ppp]
accept = 443
connect = 127.0.0.1:6001
cert = /etc/stunnel/tunnel.crt
key = /etc/stunnel/tunnel.key
CAfile = /etc/stunnel/clients.crt
verify = 2
verify = 2 vereist een clientcertificaat dat ondertekend is door een CA in CAfile — de toegangscontrole die de netcat-bouw nooit had. De client draait stunnel in clientmodus, en het pty-commando van pppd verbindt met de lokale kant in klare taal.
Socat
socat doet het hele ding in één proces per eind, met verificatie standaard aan:
# luisterend eind
pppd ... pty 'socat - OPENSSL-LISTEN:443,reuseaddr,cert=tunnel.pem,cafile=clients.crt,verify=1'
# verbindend eind
pppd ... pty 'socat - OPENSSL:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
socat staat niet op de machine waarop dit geschreven is, dus dat komt uit de documentatie — controleer je eigen vlaggen. Het is degene waar ik naar zou grijpen, want het is de enige van de vier die ook DTLS spreekt.
Openssl, als er niets anders is
openssl staat op elke machine die überhaupt TLS heeft, en s_server/s_client dragen een pipe:
# luisterend eind
pppd ... pty 'openssl s_server -quiet -accept 443 -cert tunnel.crt -key tunnel.key'
# verbindend eind
pppd ... pty 'openssl s_client -quiet -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
-quiet onderdrukt de banner die anders in je PPP-stream zou landen, en -verify_return_error zorgt dat een verificatiefout de verbinding sluit in plaats van waarschuwt en doorgaat. Dit zijn debuggereedschappen die zich ook zo gedragen, maar op een machine waar je niets kunt installeren krijgen ze een link op.
Fase Vier: DTLS, Want De Drager Hoort Nog Steeds UDP Te Zijn
Hier komt het ongemakkelijke deel, en de reden dat deze sectie apart staat. Alle TLS-opties hierboven draaien over TCP, dus de drager in TLS wikkelen maakt het argument voor UDP ongedaan en geeft je de instorting terug. Versleuteling en het juiste transport horen geen afweging te zijn. DTLS is TLS over datagrammen, en dat is wat je wilt: het houdt de bescherming van de recordlaag, laat de garanties voor volgorde en hertransmissie vallen, en laat verloren pakketten verloren, wat precies is wat de frame check sequence van PPP kan opvangen.
Ncat kan het niet. socat en openssl wel:
# luisterend eind
pppd ... pty 'socat - OPENSSL-DTLS-LISTEN:443,cert=tunnel.pem,cafile=clients.crt,verify=1'
# verbindend eind
pppd ... pty 'socat - OPENSSL-DTLS-CLIENT:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
En met alleen openssl, waar -dtls elke DTLS-versie kiest:
# luisterend eind
pppd ... pty 'openssl s_server -quiet -dtls -accept 443 -cert tunnel.crt -key tunnel.key'
# verbindend eind
pppd ... pty 'openssl s_client -quiet -dtls -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
Let op de framegrootte. Een DTLS-record kan niet gefragmenteerd worden zoals een TLS-record zich over een TCP-stream uitsmeert, dus alles moet in één keer binnen de pad-MTU passen.
De PPP-bouw Afstemmen: MTU, Compressie, En Wat Echt Helpt
Vier knoppen, allemaal pppd-opties — de tap-bouw in de volgende sectie heeft er geen van, want hij heeft niets van de machinerie die ze instellen.
novj boven modemsnelheid. Het bespaart een afrondingsfout, kost CPU per pakket en gaat stuk onder verlies. Zet deflate/bsdcomp uit: de payload is al versleuteld, en comprimeren over een beveiligingsgrens heen is een aanval, geen functie.Niets ervan maakt de tunnel sneller, en de reden doet ertoe, want PPP krijgt er de schuld van en dat hoort niet. PPP is niet de bottleneck. PPPoE draagt 2 Gbit op dezelfde daemon, want zijn datapad verlaat de kernel nooit — ppp_generic en pppoe doen de framing en het doorsturen, en pppd doet alleen het stuurvlak. Wat je hier kost is de pty: elke byte gaat de userspace in, door netcat, een socket in en terug, een rondje dat PPPoE nooit maakt. Dat hoort bij de pseudo-terminal, niet bij PPP.
Wat een goed moment is om naar de andere manier te kijken, waar helemaal geen pty is.
De Andere Manier: Een Tap-device, En Helemaal Geen PPP
Alles tot hier gebruikte PPP voor de linkhelft. Er is een tweede manier om een virtuele interface te maken, en die heeft helemaal geen protocol nodig.
De TUN/TAP-driver van de kernel geeft je een interface en een filedescriptor die aan elkaar vastzitten: schrijf een pakket in de descriptor en het verschijnt op de interface alsof het van een draad kwam; lees en je krijgt een pakket dat de kernel wilde versturen. Dat is de hele interface12, en hij zit al sinds 1999 in Linux.
ip tuntap add dev tap0 mode tap user damien group damien
ip link set tap0 mtu 1400 up
ip addr add 192.0.2.1/30 dev tap0
Twee details in dat eerste commando zijn meer waard dan ze lijken.
user damien maakt het device persistent en onbevoorrecht. Zo aangemaakt overleeft het het proces dat het gebruikt, en een genoemde gebruiker kan het openen zonder root te zijn. Root maakt het device één keer aan; het ding dat frames schept heeft helemaal geen root nodig. Houd die gedachte vast voor de egresssectie.
mode tun is de andere helft van dezelfde driver, en die is degene die de meesten eigenlijk willen. Tun draagt IP-pakketten. Tap draagt ethernetframes. Het verschil doet genoeg ertoe om hieronder een eigen sectie te krijgen.
Wat je ten opzichte van PPP opgeeft is alles waar PPP over onderhandelt: geen LCP, dus geen afgesproken framegrootte en geen levenscheck, geen IPCP/IPV6CP, dus beide einden met de hand ingesteld, geen authenticatie, geen headercompressie. Een tap-device is een gat in de kernel, en het protocol erdoorheen is wat jij erin stopt. Wat je wint is geen framingoverhead, geen escaping, geen stuurkanaal, en een interface die je kunt bridgen.
Tap Over UDP, Waar Eén Datagram Eén Frame Is
Dit is de schoonste afbeelding in het hele stuk, en hij valt uit het ontwerp. Een tap-device is een datagramdevice — één read() levert precies één frame — en een UDP-socket is een datagramsocket, één sendto() per datagram. Een frame gaat dus een datagram in, komt als frame aan, en er valt niets af te bakenen, te bufferen of te hersynchroniseren. Raak een datagram kwijt en je bent één frame kwijt, wat toch al is hoe een gedropt pakket eruitziet.
Met socat is elk eind één commando:
# luisterend eind
socat TUN:192.0.2.1/30,tun-type=tap,tun-name=tap0,iff-up UDP-LISTEN:443
# verbindend eind
socat TUN:192.0.2.2/30,tun-type=tap,tun-name=tap0,iff-up UDP:198.51.100.10:443
socat staat niet op de machine waarop dit geschreven is, dus die twee komen uit de documentatie en niet uit een run hier — controleer de adresnamen van je eigen build voordat je ze vertrouwt. Het is de moeite waard om te hebben: het is het enige gereedschap in dit stuk dat tun, tap, TLS en DTLS in één proces doet.
Waar je niets kunt installeren is de hele klus zo’n vijftig regels zonder afhankelijkheden buiten de standaardbibliotheek. De kern ervan, met IFF_NO_PI dat de header van vier bytes uitzet die de driver er anders voor zou plakken:
TUNSETIFF, IFF_TAP, IFF_NO_PI = 0x400454CA, 0x0002, 0x1000 # IFF_TUN is 0x0001
def open_tap(name):
fd = os.open("/dev/net/tun", os.O_RDWR)
fcntl.ioctl(fd, TUNSETIFF, struct.pack("16sH", name.encode(), IFF_TAP | IFF_NO_PI))
return fd
# ... learn the peer from the first datagram (UDP has no accept), then shovel:
while True:
ready, _, _ = select.select([tap, sock], [], [])
if tap in ready and peer:
sock.sendto(os.read(tap, MTU), peer) # one read = one frame = one datagram
if sock in ready:
frame, src = sock.recvfrom(MTU)
peer = src # last speaker wins — see the note below
if frame:
os.write(tap, frame)
Het hele bestand — argumentafhandeling, IPv6 via getaddrinfo, het datagram van lengte nul dat een UDP-listener vertelt waar hij moet antwoorden, en de compressie die de volgende sectie toevoegt — zit in de bundel:
peer = src bij elk datagram is het deel dat je twee keer moet lezen. Wie het laatst een frame stuurde wordt de peer. Handig achter NAT waarvan de bronpoort blijft verspringen, en een open deur op een onvertrouwd netwerk, waar iedereen die één datagram naar de poort kan sturen de tunnel overneemt. Prima binnen een DTLS-sessie, en daar gaat dit naartoe; op zichzelf niet prima. De sockethelft van het script is hier over IPv6-loopback beproefd: de opener komt binnen, de listener leert de peer, een frame steekt over en het antwoord komt terug; de tap-helft heeft root nodig, het ene deel dat ik niet kon draaien.
Over TCP Moet Je De Framing Zelf Verzinnen
Ruil nu de drager om voor TCP en kijk hoe er een heel probleem opduikt dat PPP in 1994 al stilletjes oploste.
TCP is een bytestream zonder recordgrenzen en zonder belofte over hoe bytes bij aankomst gegroepeerd zijn: twee frames die achter elkaar geschreven worden kunnen in één read aankomen, één frame in drie. De ontvanger houdt een stapel bytes vast zonder idee waar het ene frame ophoudt, en een tap-device accepteert alleen hele frames.
Over TCP schrijf je dus je eigen framing. Twee bytes big-endian lengte voor elk frame is het gebruikelijke antwoord:
# sending
sock.sendall(struct.pack("!H", len(frame)) + frame)
# receiving
def recv_exactly(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("carrier closed")
buf += chunk
return buf
length = struct.unpack("!H", recv_exactly(sock, 2))[0]
frame = recv_exactly(sock, length)
Dat werkt, en het is ronduit slechter dan de UDP-versie. Het is opnieuw het TCP-over-TCP-probleem; het voegt per frame twee bytes en een samenstellus toe; en het kan niet terug van een fout, want een stream met lengteprefixen die zijn plek kwijtraakt leest elke volgende lengte uit het midden van een frame. HDLC hersynchroniseert op de volgende 0x7E; dit kan dat niet, tenzij je HDLC slechter opnieuw uitvindt. Derde argument voor UDP, en het sterkste: over een datagramdrager is er geen framingprobleem, want de drager heeft de enige functie die je nodig had al.
Tun Of Tap: Laag 3, Tenzij Je Echt Laag 2 Nodig Hebt
Dezelfde driver geeft je twee devices, en mensen kiezen aan de lopende band de verkeerde omdat een tutorial tap zei.
| tun | tap | |
|---|---|---|
| Wat er overgaat | IP-pakketten | ethernetframes |
| Overhead per pakket | geen | 14 bytes ethernetheader |
| ARP, DHCP, broadcast | nee | ja, alles, over de tunnel |
| Niet-IP-protocollen | nee | ja |
| Kan bij een bridge | nee | ja |
| Vergelijkbaar met | een punt-tot-punt-link, zoals ppp0 | een netwerkkabel |
Tun is een gerouteerde link — zoals de PPP-interface uit de eerste helft: twee adressen, een route, pakketten erin en eruit. Tap is een virtuele ethernetkabel, dus elke broadcast, elke ARP-vraag en alle multicastruis op het segment steekt nu je tunnel over en verstookt bandbreedte.
Verander één regel in het script om te wisselen:
IFF_TUN = 0x0001 # instead of IFF_TAP
Neem tap als je echt laag 2 nodig hebt. Daar zijn echte redenen voor: een protocol dat geen IP is, een clusterhartslag die broadcasts verwacht te zien, een DHCP-server die clients over de tunnel moet bereiken, of twee segmenten die één moeten worden.
Dat laatste is het gebruikelijke geval en het geval om voorzichtig mee te zijn:
ip link add br0 type bridge
ip link set tap0 master br0
ip link set eth1 master br0
ip link set br0 up
Nu is het externe segment onderdeel van je lokale — met zijn broadcasts, zijn spanning tree, zijn MAC-gedoe en, als iemand onoplettend was, zijn DHCP-server. Twee locaties bridgen die allebei 192.168.1.0/24 draaien is een slechte middag; een die naar hetzelfde segment terugloopt is een slechte week. Neem standaard tun, grijp naar tap als je het laag 2-ding kunt benoemen dat je nodig hebt, en bridge pas nadat je gekeken hebt wat er aan beide kanten staat te broadcasten.
Dezelfde Wikkelaars, Eén Proces Per Eind
De tap-bouw heeft hetzelfde gat als de PPP-bouw: de drager is in klare taal en de listener accepteert wie er het eerst is. De oplossing is dezelfde, en met socat klapt hij samen tot één commando per eind, want het maakt het device aan én sluit de DTLS-sessie af in één proces:
# luisterend eind
socat TUN:192.0.2.1/30,tun-type=tap,tun-name=tap0,iff-up \
OPENSSL-DTLS-LISTEN:443,cert=tunnel.pem,cafile=clients.crt,verify=1
# verbindend eind
socat TUN:192.0.2.2/30,tun-type=tap,tun-name=tap0,iff-up \
OPENSSL-DTLS-CLIENT:tunnel.example.net:443,cafile=tunnel.crt,verify=1
Dat is de kortste correcte bouw in dit stuk. Een virtuele interface, een datagramdrager, wederzijdse certificaatauthenticatie en versleuteling. Twee commando’s. Geen daemon, geen protocolonderhandeling.
verify=1 is niet optioneel — hetzelfde punt als bij --ssl-verify, en met het peer = src-gedrag hierboven is een niet-geïdentificeerde partij één datagram verwijderd van het bezit van je tunnel. Zit je vast aan het Python-script, bouw er dan geen TLS in: richt het op een loopbackpoort en zet de wikkelaar ervoor, of neem socat. Een zelfgebreide TLS-wikkel om een zelfgebreide tunnel is twee kansen om het interessante deel fout te doen.
Fase Vijf: De Stream Comprimeren Met zstd
Er is nog één ding dat de moeite waard is om achterin te zetten, en anders dan de compressieopties van pppd kan dit echt lonen: comprimeer de frames met zstd voordat ze de drager in gaan.
Het belangrijke woord daar is frames, meervoud. Comprimeer de stream, niet elk pakket op zichzelf. Het is het grootste gemeten effect in dit stuk.
Netwerkverkeer is repetitief op een manier die alleen over pakketten heen zichtbaar wordt — dezelfde headers, hostnamen en JSON-sleutels, keer op keer. Een compressor die bij elk frame van 1.400 bytes opnieuw begint ziet daar niets van; een die zijn venster over frames heen vasthoudt ziet het allemaal.
Op een actuele Fedora hoef je hiervoor niets te installeren — Python 3.14 bracht zstd naar de standaardbibliotheek13; deze machine heeft 3.14.7 tegen zstd 1.5.7:
from compression.zstd import ZstdCompressor, ZstdDecompressor # Python 3.14+
_c, _d = ZstdCompressor(level=1), ZstdDecompressor()
# FLUSH_BLOCK: emit everything so far, keep the window for the next frame.
out = _c.compress(frame, mode=ZstdCompressor.FLUSH_BLOCK)
frame = _d.decompress(out)
Eén compressor per richting, in leven voor de duur van de link. Eén FLUSH_BLOCK per frame, zodat een frame het moment dat het aankomt naar buiten gaat zonder dat er iets gebufferd wordt, en het verre eind één frame per blok teruggeeft.
Wat Het Venster Waard Is, Gemeten
Cijfers van deze machine. Dezelfde frames van 1.400 bytes, hetzelfde niveau 1, het enige verschil is of de compressor zijn historie vasthoudt:
| Verkeer in de tunnel | stream, FLUSH_BLOCK | per pakket, FLUSH_FRAME | per pakket met getraind woordenboek |
|---|---|---|---|
| Repetitieve API-aanroepen en telemetrie | 5,0% | 14,8% | 5,3% |
| Logregels | 7,5% | 24,2% | 15,1% |
| Platte tekst en configuratiebestanden | 37,8% | 51,3% | 45,3% |
| Willekeurige bytes, als proxy voor TLS | 100%+ | 100,7% | — |
De doorvoer op niveau 1 haalde 205 MB/s op tekst en ruim 1 GB/s op het repetitieve verkeer — hoe comprimeerbaarder, hoe sneller, want er valt minder te coderen.
Logverkeer gaat naar 7,5% van zijn oorspronkelijke omvang, tegen 24,2% per pakket — drie keer zoveel, dezelfde data, hetzelfde niveau, door één vlag. En niveau 1 is het niveau: niveau 3 kocht ongeveer één procent, niveau 9 er nog een terwijl de doorvoer van 211 MB/s naar 61 zakte.
De Adder Onder Het Gras, En Het Is Dezelfde Als Overal Hier
Een gedeeld venster betekent dat elk frame afhangt van de frames ervoor: raak er een kwijt en de historie van de decompressor klopt niet meer, en niets daarna decodeert nog. Dus streamcompressie heeft een drager nodig die alles in volgorde aflevert — TCP, of TLS over TCP, niet UDP of DTLS. Dat is het enige eerlijke argument voor de TCP-drager in dit hele stuk. Is wat door je tunnel gaat echt comprimeerbaar — syslog, databasereplicatie in klare taal, telemetrie, een babbelzieke API — dan verplaatst een streamgecomprimeerde TLS-sessie een derde van de bytes die een datagramdrager zou verplaatsen, wat op een fatsoenlijk pad de hertransmissieboete kan verslaan. Meet het op je eigen verkeer.
Over UDP, Waar Een Woordenboek Het Werk Van Het Venster Doet
Waar de drager UDP of DTLS is, en dat hoort standaard zo te zijn, kun je geen venster vasthouden: elk datagram staat op zichzelf, wat FLUSH_FRAME betekent en de zwakkere kolom hierboven. Een getraind woordenboek geeft de compressor de context over pakketten heen die een venster zou hebben gegeven, zonder enige afhankelijkheid tussen datagrammen:
# capture a few thousand real frames off the link first, one per file
zstd --train frames/* -o tunnel.dict --maxdict=110000
```[^zstddict]
```python
from compression.zstd import ZstdCompressor, ZstdDecompressor, ZstdDict
d = ZstdDict(open("tunnel.dict", "rb").read())
_c = ZstdCompressor(level=1, zstd_dict=d) # load it ONCE, not per frame
out = _c.compress(frame, mode=ZstdCompressor.FLUSH_FRAME)
Op het repetitieve verkeer bracht dat compressie per pakket van 14,8% naar 5,3%, waarmee 96% van het gat naar volledige streamcompressie goedgemaakt werd terwijl het verliestolerant bleef; op logregels 55% van het gat, op algemene tekst 44%.
Er horen drie regels bij. Beide einden moeten hetzelfde woordenboek laden of er decodeert niets — hier gecontroleerd, een met woordenboek gecomprimeerd frame werpt zonder dat woordenboek een ZstdError. Train op een capture van het echte verkeer, want een woordenboek is een aanname en een verkeerde kost je: het tekstwoordenboek maakte andere tekst iets slechter. En laad het één keer in een langlevende compressor; het per aanroep meegeven mat 5 MB/s, en dat is geen typefout.
De Headerbyte, En De Oploop Van Eén Seconde
Twee kleine dingen die dit minder broos maken.
Elk datagram draagt een header van één byte, en die noemt de modus in plaats van alleen maar “gecomprimeerd” te zeggen: 0x00 het frame zoals het is, 0x01 een op zichzelf staand zstd-frame, 0x02 een blok uit een doorlopende stream. Een ontvanger kan dan decoderen wat het verre eind ook koos, zonder dat hij daarop ingesteld hoeft te zijn, en dat is de byte op zich al waard.
In framemodus stuurt de zender de gecomprimeerde vorm alleen als die echt kleiner is, want comprimeren wint niet altijd: willekeurige bytes kwamen op 100,7% uit, en een TCP-ACK van 64 bytes comprimeert naar 73 — de zstd-header van tien bytes op een pakket waar niets te persen valt. De pakketaantallen op een echte lijn worden gedomineerd door kleine pakketten, dus zonder die controle zou je het merendeel van je verkeer opblazen om de minderheid te laten krimpen. In streammodus stuurt hij altijd de gecomprimeerde vorm, want er een overslaan zou de twee vensters uit de pas brengen.
En de link begint rauw: de eerste seconde gaat elk frame ongecomprimeerd naar buiten, wat de instellingen ook zeggen, en elke richting loopt op zijn eigen houtje op:
RAMP = 1.0 # seconds of raw frames before compression starts
def pack(self, frame):
if self.c is None or time.monotonic() - self.started < RAMP:
return RAW + frame
out = self.c.compress(frame, mode=ZstdCompressor.FLUSH_BLOCK)
return ZSTD + out if len(out) < len(frame) else RAW + frame
Dat is het fase-idee van bovenaan het stuk, toegepast op één link. De tunnel komt op via het simpelste pad dat hij heeft, bewijst dat hij een frame kan dragen, en begint pas daarna iets slims te doen. Gaat hij stuk, dan weet je in welke seconde dat gebeurde.
Fase Zes: Gecomprimeerd En Versleuteld, En Wat De Volgorde Kost
De volgorde doet ertoe, en maar één werkt: comprimeren, dan versleutelen. Versleutelde tekst comprimeert niet, zoals de regel met 100,7% laat zien — en dat is ook waarom TLS 1.3 zijn eigen compressie geschrapt heeft, waardoor jouw laag de enige plek is om het te doen. Die volgorde heeft een bekend probleem, hetzelfde dat ik tegen deflate inbracht: comprimeren vóór versleutelen lekt klare tekst via de lengte van de versleutelde tekst, en waar een aanvaller gekozen data naast een geheim kan injecteren en de omvang kan bekijken, is dat lek al meer dan eens een werkende aanval geweest — CRIME en BREACH tegen TLS14, en VORACLE tegen precies deze vorm15.
Dat is geen reden om nooit te comprimeren. Het is een reden om te weten in welk geval je zit:
- Een link die je eigen verkeer tussen twee machines van jezelf draagt — replicatie, back-ups, logs, telemetrie — heeft geen door een aanvaller gekozen klare tekst die met geheimen meereist. Comprimeer die, en comprimeer de stream.
- Een link die willekeurig gebruikersgesurf draagt, waar andermans webinhoud en jouw inloggegevens samen gaan, is het geval waar VORACLE over geschreven is. Laat het uit.
De reden dat ik dit wel in de tap-bouw zet en niet in de PPP-bouw is geen principe: hier kies je bewust een modern algoritme, voor verkeer waar je naar gekeken hebt, terwijl deflate van pppd standaard alles comprimeert met een algoritme uit 1996, of het geval er nu bij past of niet.
Wat De Tap-bouw Wint, En Wat Hij Opgeeft
Zet de twee helften naast elkaar, want ze concurreren niet. Het zijn andere afwegingen.
| pppd over een socket | tap of tun over een socket | |
|---|---|---|
| Framing | ingebouwd (HDLC, hersynchroniseert na schade) | geen over UDP, want geen nodig; over TCP zelf verzinnen |
| Adressen instellen | onderhandeld door IPCP en IPV6CP | met de hand aan beide einden |
| Levenscheck | LCP-echo, ingebouwd | geen; je voegt hem toe of de link sterft stil |
| Authenticatie | PAP of CHAP beschikbaar, allebei zwak | helemaal geen |
| Laag | alleen 3 | 3 met tun, 2 met tap |
| Bridgen | nee | ja, met tap |
| Overhead | vlag, header en FCS per frame, plus escaping | niets, of 14 bytes met tap |
| Compressie | deflate en BSD, standaard aan, uit 1996 | geen, of zstd per frame die jij toevoegt en beheert |
| Root nodig | ja, overal | om het device te maken; niet om het te gebruiken |
| Bewegende delen | één daemon, dertig jaar oud | één filedescriptor |
PPP geeft je een onderhandelde, zichzelf bewakende link en rekent er een protocol voor. Een tap-device geeft je voor niets een rauw gat, en jij levert de ontbrekende delen of doet het zonder. Voor een tunnel die blijft draaien is de ontbrekende levenscheck degene die bijt: pppd merkt een dode drager binnen dertig seconden op en bouwt hem opnieuw, de tap-bouw merkt niets omdat er niets in zit dat kijkt. Voeg een keepalive toe, draai hem onder iets dat hem herstart, of neem de bouw die er al een heeft.
Wanneer Dit Het Juiste Gereedschap Is, En Wanneer Niet
Het is nooit echt het juiste gereedschap, en ik ga niet doen alsof van wel. Alles hierboven werkt, en niets ervan is wat je in productie hoort te draaien. De eerlijke versie van een how-to bevat het deel waarin je het gereedschap neerlegt, en dit is dat deel.
Waar het echt goed voor is, is je laten zien hoe iets werkt. Een VPN uit elkaar getrokken tot zijn onderdelen, en, in de volgende sectie, hoe egress zich werkelijk gedraagt zodra iemand met root in je netwerk zit. Dat zijn de redenen om dit gelezen te hebben. De smalle gevallen hieronder zijn echt, maar ze zijn niet waarom dit stuk bestaat.
Grijp naar de PPP-bouw als:
- De drager helemaal geen IP is — een seriële console, een USB-gadget, een radioverbinding, een named pipe, een SSH-kanaal.
pppdmaakt het niet uit waar de bytes over reizen, en een tap-device helpt je hier niet. - Je iets aan het redden bent: een machine met een seriële console, geen netwerk, en een klus die vanavond af moet. PPP over die console is een gerouteerde link, aan beide einden geïnstalleerd zonder dat je iets hoeft over te zetten.
- Je wilt dat de link voor zichzelf zorgt. LCP-echo, adresonderhandeling en herstart zijn gratis; ze zelf schrijven is hoe de tap-bouw een klein onbetrouwbaar product wordt.
Grijp naar de tap-bouw als:
- Je laag 2 nodig hebt — een protocol dat geen IP is, een clusterhartslag die broadcasts wil, DHCP over de tunnel, of twee segmenten die één moeten zijn.
- Je zo min mogelijk bewegende delen wilt: over UDP met DTLS zijn het twee commando’s, geen daemon, geen onderhandeling, niets om te escapen.
- Root schaars is. Maak het device één keer aan met
user, en het proces dat frames verplaatst heeft nooit meer een privilege nodig.
Allebei zijn ze het juiste gereedschap als je aan het leren bent. Elke laag is zichtbaar en apart te verwisselen, en er is geen betere manier om te begrijpen wat een VPN-product doet dan er zelf een uit de onderdelen bouwen en elk onderdeel zien opkomen.
Geen van beide is het juiste gereedschap als je een VPN wilt. Neem daarvoor WireGuard. Het zit in de kernel, het is een fractie van de code, het doet de cryptografie netjes met niets om over te onderhandelen en niets om fout te doen, en het is van ontwerp een datagramprotocol. ssh -w geeft je met één commando een tun-device over een bestaande SSH-sessie, en OpenVPN is de volwassen, geauditeerde versie van de DTLS-over-tap-vorm hierboven. Alle drie zijn ze hier beter in dan wat hier ook gebouwd is.
Bouw dit omdat je wilt weten wat er in het ding zit dat je koopt. Niet omdat het slim was.
Wat Dit Werkelijk Laat Zien: Egress Vanaf De Kant Van De Aanvaller
Dit is de reden om een bouwstuk te lezen waarvan je net te horen kreeg dat je het niet moet gebruiken. Draai het om en kijk vanuit je eigen netwerk, als degene die daar net met root geland is. Elke echte inbraak eindigt daar, via een gestolen sleutel, een containerontsnapping, een ongepatchte dienst, een insider. De vraag die dan bepaalt hoe erg de dag wordt is niet “wat kunnen ze draaien”, want ze kunnen alles draaien. Het is “wat mag eruit, en had jij dat besloten voordat ze er waren.”
Staat uitgaand standaard open, dan is het antwoord alles, en kun je er weinig meer aan doen. Niets hier was exotisch. pppd, ncat, socat en ip zijn ondertekende distributiepakketten die al op de machine staan; de tap-shim is vijftig regels standaardbibliotheek. Eén toegestane uitgaande poort — en het is 443, degene die elk netwerk standaard openzet — en er is een gerouteerde link van jouw netwerk naar dat van iemand anders, versleuteld, geauthenticeerd, bestand tegen herstarts, die IPv4 en IPv6 draagt, en die aan jouw grens niet te onderscheiden is van welke HTTPS-sessie je gebruikers ook tienduizend keer per dag maken. Maak er tap van en bridge het en wat het pand verliet is geen route. Het is het segment.
Er is niets voor een scanner om te betrappen: geen malwaresignatuur want er is geen malware, geen raar protocol want het is een normale TLS-handdruk naar 443, geen ongewoon binary want je eigen pakketbeheerder installeerde elk ervan. De proxy logt een verbinding en een bytetelling, en allebei zien eruit als werk.
En de bestemming ligt niet eens vast. Een socket hoeft niet te eindigen waar de pakketten uitkomen, want de drager kan door een proxy gestuurd worden — een relay dat niets meer is dan twee verbindingen en een pipe, wat de volgende sectie in één regel shell bouwt. ncat neemt --proxy met --proxy-type http, socks4 of socks5, dus de TLS-sessie die jouw grens ziet eindigt bij wat de aanvaller hem ook opgaf om doorheen te verbinden — een interne springhost, een toegestaan SaaS-eindpunt dat toevallig CONNECT doorstuurt, een cloudrelay — en de tunnel rijdt van daaraf verder naar ergens dat jij nooit ziet. HTTP CONNECT en SOCKS doen dit allebei van ontwerp, want daar is een proxy voor. Een toelatingsregel voor een bestemming die je vertrouwt is dus altijd alleen vertrouwen in die bestemming én in alles waar die naartoe doorstuurt, en dat beheers je niet en kun je niet opsommen. Het eindpunt in je firewalllog is de proxy. Het was nooit het verre eind.
Dus de ongemakkelijke waarheid: zodra iemand binnen is met root en uitgaand ruimhartig is, is de tunnel niet het ding dat jij nog kunt voorkomen. De onderdelen staan er, de uitgang staat open, en hij leidt niet eens waarheen hij lijkt te leiden. Je ene kans om dit lastig te maken was voordat de aanvaller kwam, aan de grens, door te besluiten wat eruit mag.
Dat is default-deny-egress, en dat is de hele les. Uitgaand standaard geblokkeerd; een korte, benoemde toelatingslijst waarop iemand elke bestemming en poort verantwoord heeft; al het andere geweigerd, gelogd en gemeld. Niet omdat het een vastberaden aanvaller koud stopt — een toegestane bestemming is een toegestane tunnel — maar omdat het alternatief is dat je helemaal geen besluit hebt om af te dwingen. Een egressbeleid dat geschreven is als een lijst toegestane poorten is een beleid over poortnummers. Het was nooit een beleid over wat eruit gaat, en zodra iemand root heeft, zijn poortnummers alles wat het beschermt.
Dit is dezelfde bevinding als in het ping-stuk, dat de tunnel uit ICMP-echo bouwt, en het stuk over protocolhelpers, waar je firewall de gaten zelf openzet. Drie manieren naar binnen, één conclusie: de controle waarvan je dacht dat je hem had ging over protocollen, en geen van deze protocollen is wat het zegt te zijn. De grens, vooraf besloten en default-deny, is de enige controle die ooit echt was.
Een Proxy Is Twee Verbindingen En Een Pipe
Het is de moeite waard om te zien hoe weinig een relay is, want het verklaart waarom je vanaf het nabije eind niets over het verre eind kunt zeggen. Een proxy is geen bijzondere software. Het is één verbinding aan een andere geknoopt met een pipe. De oudste vorm gebruikt een named pipe, een FIFO, om de retourrichting te dragen: één ncat luistert, een andere verbindt verder, en de FIFO bedraadt het antwoordpad ertussen.
mkfifo backpipe
ncat -l 7000 0<backpipe | ncat farend.example.net 7100 1>backpipe
Lees het als leidingwerk: de uitvoer van de listener loopt de tweede ncat in en door naar het verre eind, en de antwoorden komen via de FIFO terug naar de client. Twee sockets, één pipe, beide richtingen, en de verbinding van de client eindigt hier, bij het relay, terwijl de pakketten doorgaan naar farend en terug. Ik heb precies dit op loopback gedraaid met een derde ncat die aan het verre eind echode, en een regel die erin ging kwam terug nadat hij de hele reis gemaakt had.
Ncat doet hetzelfde in één proces, door voor elke client die binnenkomt de verderop liggende verbinding te exec’en:
ncat -l 7000 --keep-open --sh-exec 'ncat farend.example.net 7100'
Dezelfde vorm, minder onderdelen: de socket van de listener zit vast aan die van de ge-exec’te ncat via de pipe die de shell ertussen zet. Keten er drie en de tunnel steekt drie netwerken over, waarbij hij bij elk afsluit en opnieuw begint, en de firewall van elke hop een keurige lokale verbinding naar de volgende logt en niets daarachter.
Dat is de hele truc, en daarom is het grenslog geen bewijs van een bestemming. Elk relay is het verre eind voor zover de machine ervoor kan zien, en het echte andere eind ligt zoveel pipes verderop als niemand gadesloeg.
Zet die relays nu op machines die niet van de aanvaller zijn.
Elke eigenaar in die keten ziet alleen een verbinding van de hop ervoor naar de hop erna: geen oorsprong, geen bestemming, alleen midden. Dit is geen nieuw idee dat ik hier aan iemand aanreik. Zo werken pivotketens en C2-netwerken al twintig jaar, en daarom leidt het uitgaande verkeer van een gecompromitteerde host zo vaak naar een volgend slachtoffer in plaats van naar de aanvaller. Je logs laten zien dat je met een machine in een datacentrum ergens gepraat hebt. Van wie die is, en waar hij naartoe doorstuurt, stond er nooit in.
Het verdedigende gewicht is één regel: je kunt een bestemming die je niet vooraf beperkt hebt niet toeschrijven en niet vertrouwen. Tegen de tijd dat het verkeer vertrekt zegt het adres waarheen het vertrekt je bijna niets, want het is een relay op de machine van iemand anders en het echte eindpunt is erachter witgewassen.
De Link Was Nooit Het Product
Wat me opvalt, nu ik dit uit elkaar heb getrokken, is hoe weinig ervan nieuw is en hoeveel ervan verkocht wordt.
RFC 1661 is uit 1994. pppd zit al dertig jaar in elke Linux-distributie, de kernelmodules zijn acht bestanden in één map, en alles wat een VPN een VPN maakt — een virtuele interface, een onderhandelde link, een versleutelde drager, een route — is vier programma’s en een certificaat. Niets ervan is moeilijk of geheim. Ze documenteerden elke byte en gaven het weg, en er groeide een bedrijfstak tussen jou en dat spul in die diezelfde vier onderdelen in een doos verkoopt met een licentie per gebruiker en een supportcontract dat afloopt. De onderdelen werden niet beter. Ze werden ingepakt.
Dat is geen argument om dit in productie te draaien. Ik heb je net gezegd dat niet te doen. Het is een argument om te weten wat er in de doos zit die je koopt, want op de dag dat de leverancier de licentievoorwaarden verandert, overgenomen wordt of jouw model uitfaseert, is het verschil tussen een slecht kwartaal en een slecht jaar of er bij jou iemand weet waar dat ding van gemaakt was.
Neem een avond en bouw de tunnel uit de onderdelen. Kijk hoe LCP onderhandelt, breek de link en kijk hoe hij terugkomt, trek het certificaat eruit en kijk wat er stopt. Lees daarna de datasheet van je VPN-leverancier nog eens, en kijk hoeveel ervan je herkent.
Diezelfde avond koopt de andere helft. Als een gerouteerde link je netwerk uit vier geïnstalleerde programma’s en een certificaat is, dan wordt degene die net root kreeg op een van je machines niet tegengehouden door hoe moeilijk de tunnel te bouwen is. Hij is niet moeilijk. Ze worden alleen tegengehouden door wat jij, voordat ze er waren, besloot dat eruit mocht. Bouw het één keer en je stopt met egress te zien als iets dat een product afdwingt, en begint het te zien als een besluit dat je wel of niet genomen hebt.
Veel magie zit er niet in. Het meeste is 1994 met een laagje verf, en er is nowt mis met 1994. Het werkte, het was gedocumenteerd, en het draait nog steeds.
RFC 1661 — The Point-to-Point Protocol (PPP), 1994. Definieert de link, LCP, en de familie netwerkstuurprotocollen die erbovenop zitten. ↩︎
RFC 2637 — Point-to-Point Tunneling Protocol (PPTP), dat PPP in GRE draagt. ↩︎
RFC 3931 — Layer Two Tunneling Protocol version 3, de standards-track-afstammeling van het protocol dat PPP in UDP draagt. ↩︎
pppd(8) — de handleiding van de PPP-daemon. Bron voor
pty,notty,local,passive,noipdefault,proxyarp,record,receive-all, de LCP-echo-opties, en de asyncmap-standaard: “If no asyncmap option is given, the default is zero, so pppd will ask the peer not to escape any control characters.” De citaten hier zijn gelezen uitman pppdop ppp 2.5.1. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎RFC 1662 — PPP in HDLC-like Framing. De vlagreeks, de octet-stuffingregel en de Async-Control-Character-Map: “Each frame begins and ends with a Flag Sequence, which is the binary sequence 01111110 (hexadecimal 0x7e).” ↩︎ ↩︎
Olaf Titz, Why TCP Over TCP Is A Bad Idea — de standaarduitleg van gestapelde hertransmissie, die op precies deze bouw opent. De oorspronkelijke URL serveert het artikel niet meer; dit is een opname van het Internet Archive. ↩︎
RFC 5072 — IP Version 6 over PPP. IPV6CP en de 64-bits interface-identifiers, onafhankelijk van IPCP. ↩︎
RFC 1994 — PPP Challenge Handshake Authentication Protocol (CHAP). Bewijst de identiteit van de peer; versleutelt niets. ↩︎
RFC 3079 — Deriving Keys for use with Microsoft Point-to-Point Encryption (MPPE). De RC4-constructie met sleutels uit de MS-CHAP-uitwisseling, en de reden dat PPTP geen levende optie is. ↩︎
Ncat Users’ Guide — connecting through a proxy —
--proxyen--proxy-typevoor HTTP CONNECT en SOCKS 4/5, zodat de TLS-drager bij de proxy eindigt en niet bij het verre eind van de tunnel. ↩︎Ncat Users’ Guide — de opties
--ssl,--ssl-verifyen--ssl-trustfile, en de listen- en UDP-modi. Hier geteste versie: Ncat 7.92. ↩︎Universal TUN/TAP device driver — de eigen documentatie van de kernel voor
/dev/net/tun,TUNSETIFF, en het verschil tussen tun en tap. ↩︎PEP 784 — Adding Zstandard to the standard library, en daarom heeft
compression.zstdop Python 3.14 geen pakket nodig. Hier gemeten tegen Python 3.14.7 en zstd 1.5.7. ↩︎RFC 7457 — Summarizing Known Attacks on TLS and DTLS, dat CRIME behandelt en de algemene vorm van een lengtelek door comprimeren vóór versleutelen. ↩︎
OpenVPN — the VORACLE attack — hetzelfde lek tegen een VPN dat comprimeert voordat het versleutelt, en de reden dat OpenVPN compressie nu afraadt. ↩︎