Ein VPN ist kein Produkt. Es sind zwei Aufgaben zusammengeschraubt, und deine Maschine hat für jede davon bereits ein Programm.
Die erste Aufgabe ist, einen virtuellen Link zu bauen — eine Schnittstelle, die aussieht wie eine Netzwerkkarte, IP-Pakete nimmt und sie weitergibt. Die zweite ist, die Bytes dieses Links von einem Ende zum anderen zu tragen. Kauf eine VPN-Appliance und du kaufst beide Aufgaben mit einer aufgetackerten Lizenz; mach es selbst und jede ist ein Programm, das schon installiert ist. Es gibt unter Linux zwei Wege für die erste Aufgabe, und dieser Beitrag baut beide: pppd, das ein ganzes Link-Protokoll mitbringt — ausgehandelte Adressen, eine Lebendprüfung, Framing — und ein Tap-Device, das nichts mitbringt und ein Loch im Kernel ist, in das du Frames schiebst. Verschiedene Kompromisse, keine Konkurrenten, und die zweite Hälfte stellt sie nebeneinander.
Dieses Protokoll ist PPP, und der Grund, warum das funktioniert, steht in seinem ersten Absatz: PPP ist ein Link-Protokoll für Punkt-zu-Punkt-Verbindungen1, und es legt nicht fest, woraus der Link besteht. Ein Modem und eine Telefonleitung waren die ursprüngliche Antwort. Sie waren nie die einzige. PPTP steckte PPP in GRE2. L2TP steckte es in UDP3. PPPoE steckte es in Ethernet-Frames. Jedes davon ist dasselbe Protokoll mit einem anderen Boten, und keines davon musste PPP dafür ändern.
Die Frage ist also nicht, ob du PPP über einen TCP- oder UDP-Socket fahren kannst, sondern über welchen, und was es kostet. Die kurze Antwort, mit gezeigter Rechnung: Nimm UDP. Ein Stream-Protokoll in einem zuverlässigen Stream zu fahren ist der eine Fehler hier, der auf deinem Schreibtisch gut aussieht und auf einer echten Leitung auseinanderfällt.
Ein VPN Sind Zwei Aufgaben. Du Hast Beide Schon.
Zieh jedem Tunnel das Marketing ab und drei Dinge passieren: Etwas präsentiert eine virtuelle Schnittstelle und macht aus Paketen einen Byte-Stream, etwas trägt diesen Stream über ein Netzwerk, das bereits funktioniert, und etwas verschlüsselt ihn, oder nichts tut es.
PPP erledigt die Link-Hälfte ordentlich — Adressaushandlung, eine Lebendprüfung, Header-Kompression, mehrere Netzwerkschicht-Protokolle über einen Link, ein Authentifizierungsschritt, wenn du willst — eine Menge fertige Arbeit, die in /usr/sbin liegt und nichts tut. Was es nicht tut, ist sich um den Träger zu kümmern: Gib ihm einen Dateideskriptor, der Bytes in beide Richtungen bewegt, und es läuft darüber. Netcat ist ein Programm, dessen ganzer Zweck es ist, dieser Dateideskriptor zu sein.
Die Verschlüsselung ist der Teil, den dir niemand in die Hand drückt, und der Teil, den sich der Großteil dieses Beitrags verdient, denn Netcat hat keine Antwort darauf, und so zu tun als ob ist der Weg, wie Leute Dinge bauen, die sie nicht bauen sollten.
Die Stufen, Und Warum Sie In Dieser Reihenfolge Kommen
Alles unten wird in Stufen gebaut, jede fügt der vorigen eine Sache hinzu. Das ist kein Lehrmittel. So solltest du es bauen, denn wenn Stufe sechs kaputtgeht, musst du wissen, ob Stufe zwei noch funktioniert, und das weißt du nur, wenn Stufe zwei je etwas war, das du für sich laufen lassen hast.
Der Link selbst wird genauso gebaut — die Begründung hinter der Ein-Sekunden-Rampe weiter unten: Ein Tunnel kommt roh hoch, beweist, dass er einen Frame tragen kann, und wird erst dann clever. Ein Bau, der alles auf einmal einschaltet, fällt als ein Klumpen aus, und du verbringst den Abend damit, zu raten, welche Schicht es war.
Was pppd Eigentlich Will, Ist Ein Dateideskriptor
pppd wurde für serielle Ports geschrieben, also ist die naive Lesart, dass es einen serellen Port braucht. Tut es nicht. Es braucht ein Terminal-Device, und es macht sich selbst eines:
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
Lies das noch mal — der ganze Bau steckt darin. pppd macht ein Pseudo-Terminal, behält den Slave und übergibt den Master einem Befehl als stdin und stdout. Was dieser Befehl mit den Bytes tut, ist nicht die Sache von pppd. Lass nc dort laufen, und sie gehen einen Socket hinunter.
pty benannte Befehl erbt die Master-Seite als stdin und stdout. Netcat kopiert stdin in einen Socket und den Socket nach stdout, sodass die beiden pppd-Instanzen durch das pty-Paar und das Netzwerk miteinander sprechen, ohne dass eine von ihnen weiß, dass ein Socket existiert.Es gibt einen zweiten Weg, notty, der stattdessen pppds eigenes stdin und stdout benutzt. Aber er startet einen Character-Shunt-Prozess, durch den jedes Byte läuft, sodass er „increases the latency and CPU overhead“4. Nimm pty, es sei denn, du hast einen Grund, es nicht zu tun.
Drei praktische Fakten vor dem ersten Befehl, alle auf der Kiste geprüft, auf der das geschrieben wurde — 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
Es braucht Root. Da führt kein Weg dran vorbei. Die Binary ist unter Fedora nicht setuid, /dev/ppp ist Modus 0600 und Root gehörig, und die Optionen, die du brauchst, sind ohnehin privilegiert. Das ist etwas, das du als Root oder unter einer Unit-Datei laufen lässt, nicht etwas, das ein Benutzer nebenbei tut. Das ist später wichtig, wenn wir dazu kommen, was es für deine Egress-Policy bedeutet.
Die Kernel-Seite ist modular. ppp_generic macht die Schnittstelle, ppp_async das byte-gestopfte Framing, das ein pty braucht, und ppp_deflate/bsd_comp/ppp_mppe Kompression und Verschlüsselung; sie laden bei Bedarf. Fehlt ppp_async in einem abgespeckten Container-Image, kommt der Link hoch und trägt nichts — eine elende halbe Stunde, wenn du nicht weißt, wo du hinschauen musst.
Modem-Steuerleitungen existieren auf einem pty nicht. Ohne Carrier-Detect-Pin wartet pppd auf einen Träger, der nie kommt. Die Lösung ist ein Wort, local, das ihm sagt, die Modem-Steuerleitungen zu ignorieren4. Lass es weg und nichts passiert, ohne eine lesenswerte Fehlermeldung.
Netcat Ist Nicht Ein Programm
Vor dem Bau die Falle, die die meiste Zeit frisst: „Netcat“ sind mindestens vier Programme mit inkompatiblen Flags, und welches du bekommst, hängt von deiner Distribution ab. Auf dieser Maschine ist /usr/bin/nc ein Symlink auf /usr/bin/ncat, Nmaps Neufassung. Ein Befehl, den man von einer fünfzehn Jahre alten Wiki-Seite kopiert, scheitert also aus Gründen, die mit PPP nichts zu tun haben.
| Implementierung | An einem Port lauschen | UDP | Bleibt nach Trennung eines Peers oben |
|---|---|---|---|
| Ncat (Nmap) | ncat -l 443 | -u | -k / --keep-open |
| OpenBSD netcat | nc -l 443 | -u | -k |
| Traditional netcat | nc -l -p 443 | -u | nein |
| GNU netcat | nc -l -p 443 | -u | nein |
Prüf also, welches du hast, bevor du pppd die Schuld gibst:
readlink -f "$(command -v nc)"
nc --version 2>&1 | head -1 || nc -h 2>&1 | head -1
Ich benutze unten ausdrücklich ncat. Es ist das mit eingebautem TLS, was später wichtig ist, und ausdrücklich zu sein heißt, dass die Befehle auf deiner Kiste nicht stillschweigend etwas anderes bedeuten.
Stufe Eins: Der TCP-Bau, Weil Das Jeder Zuerst Probiert
Ein Ende lauscht, ein Ende verbindet. Die Adressen hier stammen aus dem Dokumentationsbereich, du kannst sie also direkt in ein Labor kopieren, und nichts Routbares kommt zu Schaden. Der Port ist durchgehend 443, und das mit Absicht: Ausgehendes 443 ist auf fast jedem Netzwerk standardmäßig offen. Wickle den Träger ein paar Stufen weiter unten in TLS, und der Verkehr darauf ist von jeder HTTPS-Sitzung nicht zu unterscheiden. Einen Listener dort zu binden braucht Root, das das ferne Ende — die eigene Kiste des Angreifers oder ein Relay — hat. Das ist das ganze Egress-Argument in einer Portnummer, und ich komme darauf zurück.
Am lauschenden Ende:
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'
Am verbindenden Ende:
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'
Jedes Wort dort erledigt eine Aufgabe, und eines davon, noauth, richtet leisen Schaden an. Der Tunnel hat keine Zugangskontrolle, ein eigener Abschnitt später.
noauth: Sie lässt die Peer-Authentifizierung fallen, sodass der Listener annimmt, wer auch immer ankommt. nodefaultroute und noipdefault halten den Link davon ab, dein Routing still umzuschreiben, local bringt ihn überhaupt erst auf einem pty hoch, und das gepinnte Paar local:remote hindert pppd daran, während IPCP eine andere Antwort zu nehmen. Das verbindende Ende ist dieselbe Zeile ohne passive.Fahr beide Enden hoch und du bekommst auf jedem ein ppp0, eine Punkt-zu-Punkt-Route zur fernen Adresse und eine Schnittstelle, über die du pingen, routen und tcpdumpen kannst. Das ist ein VPN — kein Paket installiert, kein Daemon konfiguriert, kein Schlüssel getauscht, was das Problem ist, und ich komme darauf zurück.
Was Es Ausgehandelt Hat, Und Wie Man Zusieht
Füg debug hinzu und pppd protokolliert den Austausch der Kontrollprotokolle, was einmal lesenswert ist, auch wenn du es nie wieder liest. Der Link kommt in Stufen hoch, und jede Stufe kann für sich scheitern.
Die zwei nützlichen Debugging-Werkzeuge sind bereits installiert und keiner benutzt sie:
# log every frame in both directions to a file
pppd ... debug record /tmp/ppp-trace
# then read it back in a human-readable form
pppdump -h /tmp/ppp-trace | less
record schreibt eine mit Zeitstempeln versehene Aufzeichnung jedes Bytes, pppdump macht daraus etwas Lesbares4, und mit tcpdump -ni ppp0 siehst du beide Seiten — das Framing darunter und die Pakete darüber.
Der Frame Auf Der Leitung, Und Warum 0x7E Überall Ist
PPP über eine serielle Leitung — und ein pty ist eine, soweit es pppd angeht — benutzt asynchrones HDLC-Framing, und es zu verstehen ist der Unterschied zwischen dieses Ding einstellen und raten. Jeder Frame beginnt und endet mit demselben Byte: „Each frame begins and ends with a Flag Sequence, which is the binary sequence 01111110 (hexadecimal 0x7e)“5. Wenn 0x7e eine Grenze markiert, kann es nicht innerhalb eines Frames auftauchen, also wird es escapt. So auch das Escape-Byte 0x7d, und alles andere, worum eines der Enden gebeten hat.
Die Escape-Regel ist exakt: „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.
Dieser letzte Teil ist der Einstellknopf. Die ACCM ist 32 Bit, eines pro Steuerzeichen, und eine 1 heißt „escape das“. Frag nach allen und, bei verschlüsselten Nutzlasten, wo die Bytes praktisch zufällig sind, ist etwa jedes achte Byte unter 0x20 — rund 12 % Overhead für nichts.
Modernes pppd macht hier bereits das Richtige. Lies die Manpage statt der 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 ist also der Default, kein Trick, und ein Socket ist ein sauberer 8-Bit-Pfad, der außer den zwei Pflichtbytes kein Escapen braucht. Lass es in Ruhe; setz Bits nur, wenn etwas in der Mitte wirklich Steuerzeichen frisst — ein Terminalserver, ein serieller Konzentrator, ein schlechter Konsolen-Proxy. Die Frame-Prüfsequenz am Ende ist ein 16-Bit-CRC, und sie ist das, was den UDP-Bau funktionieren lässt — was als Nächstes kommt.
TCP Über TCP Ist Der Falsche Träger
Der Bau oben funktioniert. Auf einer Laborwerkbank, über Loopback oder ein ruhiges LAN, funktioniert er wunderbar, genau deshalb liefern ihn Leute aus.
Dann trifft er auf eine echte Leitung mit echtem Verlust. Er fällt auf eine Weise um, die nach allem aussieht außer dem, was es ist.
Das Problem sind zwei unabhängige Neuübertragungs-Timer, aufeinander gestapelt, wobei der äußere den Verlust vor dem inneren versteckt. Olaf Titz schrieb die maßgebliche Erklärung, die genau mit diesem Bau eröffnet:
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
Hier ist die Form. Beide TCPs setzen einen Neuübertragungs-Timer aus ihrer Umlaufzeit. Wenn der Träger ein Segment verliert, überträgt er neu, sodass die Daten der inneren Verbindung spät ankommen. Und das innere TCP, das den Träger nicht sehen kann, liest „spät“ als Überlastung und überträgt auch neu. Nun hat der Träger das Original und ein Duplikat über eine Leitung zuzustellen, die bereits Pakete verliert. Der innere Timer verdoppelt sich, die äußere Warteschlange wächst, und der Durchsatz bricht lange vor der Leitung zusammen. Nichts in den Logs sagt, warum.
Das ist kein feiner Effizienzpunkt. Es ist der Unterschied zwischen einem Tunnel, der elegant degradiert, und einem, der bei vielleicht 2 % Verlust aufhört, Verkehr durchzulassen, während ping über dieselbe Leitung noch gut aussieht — der Grund, UDP zu nehmen, und TCP nur für eine Leitung in Reserve zu halten, die nichts anderes durchlässt.
Also nimm UDP — als Standard, nicht als Vorliebe.
Stufe Zwei: Der UDP-Bau, Das Fundament
Dasselbe pppd, ein anderer Träger. Die einzige Änderung ist im pty-Befehl.
Lauschendes Ende:
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'
Verbindendes Ende:
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'
Zwei Dinge an einem UDP-Listener, die dich erwischen werden, und keines ist ein PPP-Problem.
Der Listener kann nicht antworten, bis man ihn anspricht. Ein UDP-Socket hat keine Verbindung anzunehmen, also kann Netcat nicht wissen, wohin es Antworten schicken soll, bis ein Datagramm ankommt. Es klinkt sich auf die erste Quelladresse und den ersten Port ein, von dem es hört, und spricht damit. Das heißt, das verbindende Ende muss zuerst senden — was pppd von selbst tut, weil das nicht-passive Ende sofort LCP-Configure-Requests abfeuert. Es heißt auch, dass, wenn sich der Quellport des Clients ändert, der Träger still mit dem falschen Ort spricht. Hinter NAT mit kurzem UDP-Timeout ist das ein Tunnel, der ohne sichtbaren Grund alle paar Minuten stirbt.
--keep-open tut auf UDP nicht, was du willst. Ncats -k hält einen TCP-Listener am Annehmen, nachdem ein Peer weg ist. Auf UDP gibt es nichts anzunehmen, also heißt Wiederherstellung, den Träger neu zu starten — wofür persist und holdoff da sind, weiter unten.
Beide sind Argumente für socat, das UDP-Peers sorgfältiger behandelt, und für einen Supervisor, statt darauf zu vertrauen, dass es oben bleibt.
Warum PPP Ein Verlorenes Datagramm Übersteht
Der offensichtliche Einwand gegen einen UDP-Träger ist, dass PPP einen Byte-Stream erwartet und UDP keiner ist: Datagramme kommen ganz oder gar nicht an und können außer der Reihe ankommen. Es funktioniert trotzdem, wegen zweier Bytes, die das Framing die ganze Zeit getragen hat.
PPP über asynchrones HDLC wurde für eine Leitung entworfen, die Bytes verfälscht. Jeder Frame trägt eine 16-Bit-Frame-Prüfsequenz; ein Frame, der sie nicht besteht, wird verworfen, und das nächste Flag-Byte synchronisiert den Empfänger neu. Umsortierung ist seltener als Verlust und ergibt dasselbe Ergebnis: eine schlechte FCS, ein verworfener Frame, ein Neusynchronisieren.
Ein verlorenes Datagramm kostet also einen PPP-Frame — ein IP-Paket, den Normalzustand jedes je gebauten Netzwerks. Der Besitzer des Pakets überträgt neu, und die Überlastkontrolle sieht einen echten Verlust und reagiert richtig. Das ist das ganze Argument für UDP: Es lässt den Verkehr im Tunnel die Wahrheit über die Leitung herausfinden.
Lass die Frames aber nicht groß genug werden, dass der Träger sie fragmentieren muss. Ein verlorenes Fragment tötet dann das ganze Datagramm und deine effektive Verlustrate vervielfacht sich. Halt die PPP-MTU deutlich unter der Pfad-MTU.
Adressen, Routen Und IPv6 Richtig Machen
ppp0 ist eine Punkt-zu-Punkt-Schnittstelle — kein Subnetz, kein ARP — also ist local:remote die ganze Adressierung, und du routest ausdrücklich darüber. Für einen einzelnen Host, der ein Netz hinter dem fernen Ende erreicht:
# on the client, after the link is up
ip route add 203.0.113.0/24 via 192.0.2.1 dev ppp0
Damit das ferne Ende im Auftrag des Clients weiterleitet, die üblichen zwei Schritte:
sysctl -w net.ipv4.ip_forward=1
nft add rule inet nat postrouting oifname "eth0" ip saddr 192.0.2.2/32 masquerade
proxyarp lässt den Server ARP für den Client auf seinem eigenen Segment beantworten4, was hübsch ist, bis ein zweiter Client dasselbe will. Dann mach den Teil, den die meisten überspringen. Fahr IPv6 darüber — ein Punkt-zu-Punkt-Link ohne NAT, ohne Broadcast-Domäne und ohne Adressknappheit ist der einfachste Ort in deinem Netzwerk, um IPv6 richtig zu machen:
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 schaltet IPV6CP ein; ipv6 <local>,<remote> setzt die zwei 64-Bit-Interface-Identifier4, der Link kommt link-local hoch, und du legst ein globales /64 oben drauf7. IPV6CP ist unabhängig von IPCP, also trägt noip nichts als IPv6 — eine vernünftige Sache, die man 2026 bauen kann, in einem Wort.
Ihn Oben Halten, Wenn Der Träger Still Stirbt
Das ist der Fehler, der einen Nachmittag verschwendet, also bekommt er einen eigenen Abschnitt.
Ein TCP-Träger, der schlecht stirbt — das ferne Ende ausgeschaltet, ein NAT-Tabelleneintrag abgelaufen, eine Middlebox, die aufgehört hat weiterzuleiten — schließt nicht. Es gibt kein FIN, kein RST, nichts. Netcat sitzt da und hält einen Socket, der nie ein weiteres Byte liefert, pppd sitzt da und hält ein pty, das nie einen weiteren Frame sieht, und ip link meldet fröhlich ppp0 als UP. Ein UDP-Träger hat überhaupt keinen Verbindungszustand, also bemerkt er nie etwas.
PPP hat die Antwort eingebaut, und sie ist standardmäßig aus:
lcp-echo-interval 10 lcp-echo-failure 3
Das sendet alle zehn Sekunden ein LCP-Echo und reißt den Link nach drei unbeantworteten ab4 — dreißig Sekunden, um einen toten Träger zu erwischen, genau in dem Fall, den die Manpage nennt, „no hardware modem control lines“4, also jedes je gemachte pty. Dann entscheide, was danach passiert:
persist maxfail 0 holdoff 5
lcp-echo-interval 10 lcp-echo-failure 3 ist der Detektor: Echo alle zehn Sekunden, Link runter nach drei verpassten. persist maxfail 0 holdoff 5 ist der Wiederaufbau: neu starten, nie aufgeben, fünf Sekunden warten, damit eine flatternde Leitung keine Fork-Bombe wird — und pppd fährt den pty-Befehl neu, sodass ein frisches netcat und ein frischer Socket mitkommen. Setz das Echo auf beiden Enden; ein Supervisor, eine systemd-Unit mit Restart=always, schlägt zwei streitende Prozesse. Leg ip route in /etc/ppp/ip-up.d/, damit das Routing mit dem Link zurückkommt.Es Hat Keine Verschlüsselung Und Keine Authentifizierung
Alles oben ist ein funktionierender Tunnel. Es ist kein sicherer, und die Lücke ist kein Detail.
Es gibt keine Verschlüsselung. Keine schwache Verschlüsselung. Keine. Jedes Paket, das du hier durchschickst, ist im Klartext auf der Leitung, in einen HDLC-Frame gewickelt, den jedes Aufzeichnungswerkzeug auf den ersten Blick dekodiert. tcpdump zeigt dir den Inhalt des Tunnels eines anderen so bereitwillig wie deinen eigenen.
Es gibt keine Authentifizierung des Peers. noauth sagt es. Ein Netcat-Listener nimmt an, was auch immer am Port ankommt: die erste Verbindung oder das erste Datagramm von irgendwo. Wer zuerst dort ist, bekommt einen gerouteten Link in dein Netzwerk. PPP hat durchaus Authentifizierung (PAP im Klartext, CHAP als Challenge-Response8), und beide beweisen, wer der Peer ist, während sie kein einziges Byte des Folgenden verschlüsseln: CHAP hier gibt dir einen Tunnel, der weiß, mit wem er spricht, und den Inhalt trotzdem an jeden auf der Leitung veröffentlicht.
Es gibt eine Verschlüsselungsoption in der PPP-Familie — MPPE9, das Modul liegt in ppp_mppe.ko. Greif nicht danach: Es ist RC4, geschlüsselt aus dem MS-CHAPv2-Austausch, seit über einem Jahrzehnt öffentlich gebrochen, und der Grund, warum PPTP tot ist. 2026 einen neuen Tunnel darauf zu bauen wählt eine bekannt gebrochene Chiffre über eine funktionierende, die nichts kostet.
Also die ehrliche Zusammenfassung bisher: ein gerouteter Link ohne Vertraulichkeit und ohne Zugangskontrolle. Gut fürs Labor, gut innerhalb eines Links, der bereits verschlüsselt ist, nirgendwo sonst gut. Die Lösung ist, den Träger zu verschlüsseln — die nächsten zwei Abschnitte, und der Grund, ncat zu nehmen statt des Netcat, das deine Distribution ausgeliefert hat.
Stufe Drei: Den Träger In TLS Wickeln
Die saubere Antwort lässt pppd genau so, wie es ist, und ersetzt den Träger durch einen, der TLS macht. pppd erfährt nie, dass sich etwas geändert hat.
Zuerst das Zertifikat. Ein selbstsigniertes reicht, solange der Client es prüft. Eine ungeprüfte TLS-Sitzung ist ein verschlüsseltes Gespräch mit jemandem, den du nicht identifiziert hast, was niemanden aufhält:
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 hat TLS eingebaut. Es kettet auch durch einen Proxy, --proxy host:port --proxy-type http|socks4|socks510, sodass der Träger an einem Relay enden kann statt am fernen Ende des Tunnels. Das ist der Punkt, um den sich der Egress-Abschnitt dreht. Lauschendes Ende:
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'
Verbindendes Ende:
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 ist das Wort, auf das es ankommt. Ohne es gibt dir --ssl Verschlüsselung gegen einen passiven Listener und nichts gegen den, der zuerst am Port antwortet; mit ihm prüft Ncat Vertrauen und Domainnamen gegen die Trust-Datei11. Ncat hat aber keine serverseitige Client-Zertifikatsprüfung, der Server kann den Client also nicht identifizieren. Kombinier es mit CHAP, oder nimm eines der nächsten zwei.
Stunnel
Der traditionelle Wrapper, und der eine, der gegenseitige Authentifizierung richtig macht:
[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 verlangt ein Client-Zertifikat, das von einer CA in CAfile signiert ist — die Zugangskontrolle, die der Netcat-Bau nie hatte. Der Client fährt stunnel im Client-Modus, und pppds pty-Befehl verbindet sich mit der lokalen Klartext-Seite.
Socat
socat macht das Ganze in einem Prozess pro Ende, mit standardmäßig eingeschalteter Prüfung:
# listening end
pppd ... pty 'socat - OPENSSL-LISTEN:443,reuseaddr,cert=tunnel.pem,cafile=clients.crt,verify=1'
# connecting end
pppd ... pty 'socat - OPENSSL:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
socat ist auf der Maschine, auf der das geschrieben wurde, nicht installiert, das stammt also aus seiner Dokumentation — prüf deine eigenen Flags. Es lohnt sich zu haben: Es ist das einzige Werkzeug in diesem Beitrag, das tun, tap, TLS und DTLS in einem Prozess macht.
Openssl, Wenn Es Nichts Anderes Gibt
openssl ist auf jeder Kiste, die überhaupt TLS hat, und s_server/s_client tragen eine Pipe:
# listening end
pppd ... pty 'openssl s_server -quiet -accept 443 -cert tunnel.crt -key tunnel.key'
# connecting end
pppd ... pty 'openssl s_client -quiet -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
-quiet unterdrückt das Banner, das sonst in deinem PPP-Stream landen würde, und -verify_return_error lässt einen Prüffehler die Verbindung schließen, statt zu warnen und weiterzumachen. Das sind Debugging-Werkzeuge, die sich auch so verhalten, aber auf einer Kiste, wo du nichts installieren kannst, bringen sie einen Link hoch.
Stufe Vier: DTLS, Weil Der Träger Immer Noch UDP Sein Sollte
Hier ist der heikle Teil, und der Grund, warum dieser Abschnitt getrennt ist. Jede TLS-Option oben läuft über TCP, sodass das Wickeln des Trägers in TLS das Argument für UDP zunichtemacht und dir den Zusammenbruch zurückgibt. Verschlüsselung und der richtige Transport sollten kein Tauschgeschäft sein. DTLS ist TLS über Datagramme, und es ist, was du willst: Es behält den Schutz der Record-Schicht, lässt die Reihenfolgen- und Neuübertragungsgarantien fallen und lässt verlorene Pakete verloren, was genau das ist, was PPPs Frame-Prüfsequenz aufzufangen gebaut ist.
Ncat kann es nicht. socat und openssl können:
# listening end
pppd ... pty 'socat - OPENSSL-DTLS-LISTEN:443,cert=tunnel.pem,cafile=clients.crt,verify=1'
# connecting end
pppd ... pty 'socat - OPENSSL-DTLS-CLIENT:tunnel.example.net:443,cafile=tunnel.crt,verify=1'
Und mit openssl allein, wo -dtls irgendeine DTLS-Version wählt:
# listening end
pppd ... pty 'openssl s_server -quiet -dtls -accept 443 -cert tunnel.crt -key tunnel.key'
# connecting end
pppd ... pty 'openssl s_client -quiet -dtls -verify_return_error -CAfile tunnel.crt -connect tunnel.example.net:443'
Achte auf die Frame-Größe. Ein DTLS-Record kann nicht so fragmentiert werden, wie sich ein TLS-Record über einen TCP-Stream verteilt, also muss alles auf einmal in die Pfad-MTU passen.
Den PPP-Bau Einstellen: MTU, Kompression Und Was Wirklich Hilft
Vier Knöpfe, alles pppd-Optionen — der Tap-Bau im nächsten Abschnitt hat keinen davon, weil er keine der Maschinerie hat, die sie einstellen.
novj über Modemgeschwindigkeit aus. Sie spart einen Rundungsfehler, kostet CPU pro Paket und bricht unter Verlust. Schalte deflate/bsdcomp aus: Die Nutzlast ist bereits verschlüsselt, und über eine Sicherheitsgrenze zu komprimieren ist ein Angriff, kein Feature.Nichts davon macht den Tunnel schneller, und der Grund ist wichtig, weil PPP dafür die Schuld bekommt und nicht sollte. PPP ist nicht der Flaschenhals. PPPoE trägt 2 Gbit auf demselben Daemon, weil sein Datenpfad nie den Kernel verlässt — ppp_generic und pppoe machen das Framing und die Weiterleitung, und pppd erledigt nur die Kontrollebene. Was dich hier kostet, ist das pty: Jedes Byte kreuzt in den Userspace, durch netcat, in einen Socket und zurück, ein Hin und Her, das PPPoE nie macht. Das gehört zum Pseudo-Terminal, nicht zu PPP.
Was ein guter Moment ist, sich die andere Art anzusehen, das zu tun, bei der es überhaupt kein pty gibt.
Der Andere Weg: Ein Tap-Device, Und Ganz Ohne PPP
Alles bisher hat PPP für die Link-Hälfte benutzt. Es gibt einen zweiten Weg, eine virtuelle Schnittstelle zu machen, und er braucht überhaupt kein Protokoll.
Der TUN/TAP-Treiber des Kernels gibt dir eine Schnittstelle und einen Dateideskriptor, aneinander gebunden: Schreib ein Paket in den Deskriptor und es erscheint auf der Schnittstelle, als käme es von einer Leitung; lies, und du bekommst ein Paket, das der Kernel senden wollte. Das ist die ganze Schnittstelle12, und sie ist seit 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
Zwei Details in diesem ersten Befehl sind mehr wert, als sie aussehen.
user damien macht das Device dauerhaft und unprivilegiert. So erstellt überlebt es den Prozess, der es benutzt, und ein benannter Benutzer kann es öffnen, ohne Root zu sein. Root erstellt das Device einmal; das Ding, das Frames schaufelt, braucht überhaupt kein Root. Behalt diesen Gedanken für den Egress-Abschnitt.
mode tun ist die andere Hälfte desselben Treibers, und es ist die, die die meisten eigentlich wollen. Tun trägt IP-Pakete. Tap trägt Ethernet-Frames. Der Unterschied ist wichtig genug für einen eigenen Abschnitt weiter unten.
Was du gegenüber PPP aufgibst, ist alles, was PPP aushandelt: kein LCP, also keine ausgehandelte Frame-Größe und keine Lebendprüfung, kein IPCP/IPV6CP, also beide Enden von Hand konfiguriert, keine Authentifizierung, keine Header-Kompression. Ein Tap-Device ist ein Loch im Kernel, und das Protokoll hindurch ist, was auch immer du hineinlegst. Was du gewinnst, ist überhaupt kein Framing-Overhead, kein Escapen, kein Kontrollkanal und eine Schnittstelle, die gebridgt werden kann.
Tap Über UDP, Wo Ein Datagramm Ein Frame Ist
Das ist die sauberste Abbildung im ganzen Beitrag, und sie fällt aus dem Design. Ein Tap-Device ist ein Datagramm-Device — ein read() liefert genau einen Frame — und ein UDP-Socket ist ein Datagramm-Socket, ein sendto() pro Datagramm. Ein Frame geht also in ein Datagramm, kommt als Frame an, und es gibt nichts zu begrenzen, zu puffern oder neu zu synchronisieren. Verlier ein Datagramm und du hast einen Frame verloren, was ein verworfenes Paket ohnehin so aussieht.
Mit socat ist jedes Ende je ein Befehl:
# listening end
socat TUN:192.0.2.1/30,tun-type=tap,tun-name=tap0,iff-up UDP-LISTEN:443
# connecting end
socat TUN:192.0.2.2/30,tun-type=tap,tun-name=tap0,iff-up UDP:198.51.100.10:443
socat ist auf der Maschine, auf der das geschrieben wurde, nicht installiert, diese zwei stammen also aus seiner Dokumentation statt aus einem Lauf hier — prüf die Adressnamen deines eigenen Builds, bevor du ihnen traust. Es lohnt sich zu haben: Es ist das einzige Werkzeug in diesem Beitrag, das tun, tap, TLS und DTLS in einem Prozess macht.
Wo du nichts installieren kannst, ist die ganze Aufgabe rund fünfzig Zeilen ohne Abhängigkeiten jenseits der Standardbibliothek. Das Herzstück, IFF_NO_PI schaltet den Vier-Byte-Header aus, den der Treiber sonst voranstellen würde:
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)
Die ganze Datei — Argument-Parsing, IPv6 über getaddrinfo, das Null-Längen-Datagramm, das einem UDP-Listener sagt, wo er antworten soll, und die Kompression, die der nächste Abschnitt hinzufügt — ist im Bundle:
peer = src bei jedem Datagramm ist der Teil, den man zweimal lesen sollte. Wer zuletzt einen Frame gesendet hat, wird zum Peer. Praktisch hinter NAT, dessen Quellport ständig wandert, und eine offene Tür in einem nicht vertrauenswürdigen Netzwerk, wo jeder, der ein Datagramm an den Port senden kann, den Tunnel übernimmt. Gut innerhalb einer DTLS-Sitzung, wohin das führt; für sich nicht gut. Die Socket-Hälfte des Skripts wurde hier über IPv6-Loopback geprüft: Der Opener kommt an, der Listener lernt den Peer, ein Frame kreuzt und die Antwort kommt zurück; die Tap-Hälfte braucht Root, der eine Teil, den ich nicht laufen lassen konnte.
Über TCP Musst Du Das Framing Selbst Erfinden
Tausch nun den Träger gegen TCP und sieh ein ganzes Problem auftauchen, das PPP 1994 still gelöst hat.
TCP ist ein Byte-Stream ohne Record-Grenzen und ohne Versprechen, wie Bytes bei der Ankunft gruppiert sind: Zwei Frames hintereinander geschrieben können in einem Lesevorgang ankommen, ein Frame in dreien. Der Empfänger hält einen Haufen Bytes ohne Ahnung, wo ein Frame endet, und ein Tap-Device nimmt nur ganze Frames an.
Über TCP schreibst du also dein eigenes Framing. Zwei Byte Big-Endian-Länge vor jedem Frame ist die übliche Antwort:
# 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)
Das funktioniert, und es ist strikt schlechter als die UDP-Variante. Es ist wieder das TCP-über-TCP-Problem; es fügt jedem Frame zwei Byte und eine Reassemblierungsschleife hinzu; und es hat keinen Weg zurück aus einem Fehler, weil ein längenpräfixierter Stream, der seinen Platz verliert, jede folgende Länge aus der Mitte eines Frames liest. HDLC synchronisiert sich am nächsten 0x7E neu; das hier kann es nicht, außer HDLC schlechter neu zu erfinden. Drittes Argument für UDP, und das stärkste: Über einen Datagramm-Träger gibt es kein Framing-Problem, weil der Träger das eine Feature, das du brauchtest, bereits hat.
Tun Oder Tap: Schicht 3, Es Sei Denn, Du Brauchst Wirklich Schicht 2
Derselbe Treiber gibt dir zwei Devices, und Leute wählen ständig das falsche, weil ein Tutorial tap sagte.
| tun | tap | |
|---|---|---|
| Was hindurchgeht | IP-Pakete | Ethernet-Frames |
| Overhead pro Paket | keiner | 14-Byte-Ethernet-Header |
| ARP, DHCP, Broadcast | nein | ja, alles davon, über den Tunnel |
| Nicht-IP-Protokolle | nein | ja |
| Kann einer Bridge beitreten | nein | ja |
| Entspricht | einem Punkt-zu-Punkt-Link, wie ppp0 | einem Netzwerkkabel |
Tun ist ein gerouteter Link — wie die PPP-Schnittstelle aus der ersten Hälfte: zwei Adressen, eine Route, Pakete rein und raus. Tap ist ein virtuelles Ethernet-Kabel, sodass jeder Broadcast, jede ARP-Anfrage und jedes bisschen Multicast-Rauschen auf dem Segment nun deinen Tunnel kreuzt und Bandbreite verbrennt.
Ändere eine Zeile im Skript zum Umschalten:
IFF_TUN = 0x0001 # instead of IFF_TAP
Nimm tap, wenn du wirklich Schicht 2 brauchst. Es gibt echte Gründe: ein Protokoll, das nicht IP ist, ein Cluster-Heartbeat, der Broadcasts sehen erwartet, ein DHCP-Server, der Clients über den Tunnel erreichen muss, oder zwei Segmente in eines zu bridgen.
Das Letzte ist der häufige Fall und der, mit dem man vorsichtig sein muss:
ip link add br0 type bridge
ip link set tap0 master br0
ip link set eth1 master br0
ip link set br0 up
Nun ist das ferne Segment Teil deines lokalen — seine Broadcasts, sein Spanning Tree, sein MAC-Gewirbel und, wenn jemand unvorsichtig war, sein DHCP-Server. Zwei Standorte zu bridgen, die beide 192.168.1.0/24 fahren, ist ein schlechter Nachmittag; einer, der über einen anderen Pfad auf dasselbe Segment zurückschleift, ist eine schlechte Woche. Standardmäßig tun, greif zu tap, wenn du das Schicht-2-Ding benennen kannst, das du brauchst, und bridge erst, nachdem du geprüft hast, was auf beiden Seiten broadcastet.
Dieselben Wrapper, Ein Prozess Pro Ende
Der Tap-Bau hat dieselbe Lücke wie der PPP-Bau: Der Träger ist im Klartext und der Listener nimmt an, wer zuerst dort ist. Die Lösung ist dieselbe, und mit socat fällt sie zu einem Befehl pro Ende zusammen, weil es das Device macht und die DTLS-Sitzung in einem einzigen Prozess beendet:
# listening end
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
# connecting end
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
Das ist der kürzeste korrekte Bau in diesem Beitrag. Eine virtuelle Schnittstelle, ein Datagramm-Träger, gegenseitige Zertifikatsauthentifizierung und Verschlüsselung. Zwei Befehle. Kein Daemon, keine Protokollaushandlung.
verify=1 ist nicht optional — derselbe Punkt wie --ssl-verify, und mit dem peer = src-Verhalten oben ist eine nicht identifizierte Partei ein Datagramm davon entfernt, deinen Tunnel zu besitzen. Wenn du beim Python-Skript festsitzt, schraub kein TLS hinein: Richt es auf einen Loopback-Port und stell den Wrapper davor, oder nimm socat. Ein selbstgebauter TLS-Wrapper um einen selbstgebauten Tunnel sind zwei Chancen, den interessanten Teil falsch zu machen.
Stufe Fünf: Den Stream Mit zstd Komprimieren
Es gibt noch eine Sache, die es wert ist, ins Backend zu setzen, und anders als die Kompressionsoptionen von pppd kann sie sich wirklich lohnen: Komprimier die Frames mit zstd, bevor sie in den Träger gehen.
Das wichtige Wort dort ist Frames, Plural. Komprimier den Stream, nicht jedes Paket für sich. Es ist der größte gemessene Effekt in diesem Beitrag.
Netzwerkverkehr ist auf eine Art wiederholend, die sich nur über Pakete hinweg zeigt — dieselben Header, Hostnamen und JSON-Schlüssel, immer wieder. Ein Kompressor, der bei jedem 1.400-Byte-Frame frisch anfängt, sieht nichts davon; einer, der sein Fenster über Frames behält, sieht alles davon.
Auf einem aktuellen Fedora braucht das nichts installiert — Python 3.14 brachte zstd in die Standardbibliothek13; diese Kiste hat 3.14.7 gegen 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)
Ein Kompressor pro Richtung, lebendig für die Lebensdauer des Links. Ein FLUSH_BLOCK pro Frame, sodass ein Frame in dem Moment rausgeht, in dem er ankommt, ohne dass etwas gepuffert wird, und das ferne Ende einen Frame pro Block zurückgibt.
Was Das Fenster Wert Ist, Gemessen
Zahlen von dieser Maschine. Dieselben 1.400-Byte-Frames, dasselbe Level 1, der einzige Unterschied ist, ob der Kompressor seine Historie behält:
| Verkehr im Tunnel | Stream, FLUSH_BLOCK | pro Paket, FLUSH_FRAME | pro Paket mit trainiertem Wörterbuch |
|---|---|---|---|
| Wiederholende API-Aufrufe und Telemetrie | 5,0 % | 14,8 % | 5,3 % |
| Log-Zeilen | 7,5 % | 24,2 % | 15,1 % |
| Klartext und Konfigurationsdateien | 37,8 % | 51,3 % | 45,3 % |
| Zufällige Bytes, stellvertretend für TLS | 100 %+ | 100,7 % | — |
Der Durchsatz bei Level 1 lief 205 MB/s auf Text und über 1 GB/s auf dem wiederholenden Verkehr — je komprimierbarer, desto schneller, weil es weniger zu kodieren gibt.
Log-Verkehr geht auf 7,5 % seiner Originalgröße, gegen 24,2 % pro Paket — dreifach, dieselben Daten, dasselbe Level, aus einem Flag. Und Level 1 ist das Level: Level 3 brachte etwa ein Prozent, Level 9 ein weiteres, während es den Durchsatz von 211 MB/s auf 61 fallen ließ.
Der Haken, Und Es Ist Derselbe Haken Wie Bei Allem Anderen Hier
Ein geteiltes Fenster heißt, jeder Frame hängt von den vorigen ab: Verlier einen und die Historie des Dekompressors passt nicht mehr, und nichts danach dekodiert. Also braucht die Stream-Kompression einen Träger, der alles der Reihe nach zustellt — TCP, oder TLS über TCP, nicht UDP oder DTLS. Das ist das eine ehrliche Argument für den TCP-Träger im ganzen Beitrag. Wenn das, was durch deinen Tunnel geht, wirklich komprimierbar ist — Syslog, Datenbankreplikation im Klartext, Telemetrie, eine geschwätzige API — bewegt eine stream-komprimierte TLS-Sitzung ein Drittel der Bytes, die ein Datagramm-Träger würde, was auf einem anständigen Pfad die Neuübertragungsstrafe schlagen kann. Miss es an deinem eigenen Verkehr.
Über UDP, Wo Ein Wörterbuch Die Aufgabe Des Fensters Macht
Wo der Träger UDP oder DTLS ist, und das sollte er standardmäßig sein, kannst du kein Fenster behalten: Jedes Datagramm steht für sich, was FLUSH_FRAME und die schwächere Spalte oben heißt. Ein trainiertes Wörterbuch gibt dem Kompressor den paketübergreifenden Kontext, den ein Fenster hätte, ohne irgendeine Abhängigkeit zwischen 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)
Auf dem wiederholenden Verkehr, der die Kompression pro Paket von 14,8 % auf 5,3 % brachte, 96 % der Lücke zur vollen Stream-Kompression zurückgewonnen, während es verlusttolerant blieb; bei Log-Zeilen 55 % der Lücke, bei allgemeinem Text 44 %.
Drei Regeln kommen damit. Beide Enden müssen dasselbe Wörterbuch laden, sonst dekodiert nichts — hier geprüft, ein wörterbuchkomprimierter Frame wirft ZstdError ohne es. Trainier auf einer Aufzeichnung des echten Verkehrs, denn ein Wörterbuch ist eine Vorannahme und eine falsche kostet dich: Das Text-Wörterbuch machte anderen Text leicht schlechter. Und lad es einmal in einen langlebigen Kompressor; es pro Aufruf zu übergeben maß 5 MB/s, was kein Tippfehler ist.
Das Header-Byte Und Die Ein-Sekunden-Rampe
Zwei kleine Dinge, die verhindern, dass das brüchig ist.
Jedes Datagramm trägt ein Ein-Byte-Header, und es benennt den Modus, statt nur „komprimiert“ zu sagen: 0x00 der Frame, wie er ist, 0x01 ein eigenständiger zstd-Frame, 0x02 ein Block aus einem fortlaufenden Stream. Ein Empfänger kann dann dekodieren, was auch immer das ferne Ende wählte, ohne dafür konfiguriert zu sein, was das Byte allein wert ist.
Im Frame-Modus sendet der Sender die komprimierte Form nur, wenn sie tatsächlich kleiner ist, denn Kompression ist nicht immer ein Gewinn: Zufällige Bytes kamen auf 100,7 % heraus, und ein 64-Byte-TCP-ACK komprimiert auf 73 — das Zehn-Byte-zstd-Header auf einem Paket, an dem nichts zu quetschen ist. Paketzahlen auf einer echten Leitung werden von kleinen Paketen dominiert, also würdest du ohne diese Prüfung die Mehrheit deines Verkehrs aufblähen, um die Minderheit zu schrumpfen. Im Stream-Modus sendet er immer die komprimierte Form, denn eine zu überspringen brächte die zwei Fenster aus dem Tritt.
Und der Link startet roh: In der ersten Sekunde geht jeder Frame unkomprimiert raus, egal was die Einstellungen sagen, und jede Richtung rampt für sich hoch:
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
Das ist die Stufen-Idee vom Anfang des Beitrags, angewandt auf einen einzelnen Link. Der Tunnel kommt auf dem einfachsten Pfad hoch, den er hat, beweist, dass er einen Frame tragen kann, und fängt erst dann an, etwas Cleveres zu tun. Wenn er kaputtgeht, weißt du, in welcher Sekunde er kaputtging.
Stufe Sechs: Komprimiert Und Verschlüsselt, Und Was Die Reihenfolge Kostet
Die Reihenfolge ist wichtig, und nur eine funktioniert: erst komprimieren, dann verschlüsseln. Chiffretext lässt sich nicht komprimieren, wie die 100,7-%-Zeile zeigt — weshalb auch TLS 1.3 seine eigene Kompression entfernte und deine Schicht als den einzigen Ort ließ, es zu tun. Diese Reihenfolge hat ein bekanntes Problem, dasselbe, das ich gegen deflate anführte: Vor dem Verschlüsseln zu komprimieren leckt Klartext über die Länge des Chiffretexts, und wo ein Angreifer gewählte Daten neben ein Geheimnis einschleusen und die Größen beobachten kann, ist dieses Leck mehr als einmal zu einem funktionierenden Angriff geworden — CRIME und BREACH gegen TLS14, und VORACLE gegen genau diese Form15.
Das ist kein Grund, nie zu komprimieren. Es ist ein Grund zu wissen, in welchem Fall du bist:
- Ein Link, der deinen eigenen Verkehr zwischen zwei Kisten trägt, die dir gehören — Replikation, Backups, Logs, Telemetrie — hat keinen vom Angreifer gewählten Klartext, der mit Geheimnissen reist. Komprimier ihn, und komprimier den Stream.
- Ein Link, der beliebiges Nutzer-Browsing trägt, wo der Web-Inhalt eines anderen und deine Zugangsdaten zusammen gehen, ist der Fall, über den VORACLE geschrieben wurde. Lass es aus.
Der Grund, warum es mir wohl ist, das in den Tap-Bau zu setzen und nicht in den PPP-Bau, ist kein Prinzip: Hier wählst du bewusst einen modernen Algorithmus, für Verkehr, den du dir angesehen hast, während pppds deflate standardmäßig alles mit einem von 1996 komprimiert, ob der Fall passt oder nicht.
Was Der Tap-Bau Gewinnt Und Was Er Aufgibt
Stell die zwei Hälften nebeneinander, denn sie konkurrieren nicht. Es sind verschiedene Kompromisse.
| pppd über einen Socket | tap oder tun über einen Socket | |
|---|---|---|
| Framing | eingebaut (HDLC, synchronisiert sich nach Beschädigung neu) | keins über UDP, weil keins nötig ist; erfinde es über TCP |
| Adress-Setup | von IPCP und IPV6CP ausgehandelt | von Hand an beiden Enden konfiguriert |
| Lebendigkeit | LCP-Echo, eingebaut | keine; du fügst sie hinzu oder der Link stirbt still |
| Authentifizierung | PAP oder CHAP verfügbar, beide schwach | überhaupt keine |
| Schicht | nur 3 | 3 mit tun, 2 mit tap |
| Bridging | nein | ja, mit tap |
| Overhead | Flag, Header und FCS pro Frame, plus Escapen | nichts, oder 14 Byte mit tap |
| Kompression | deflate und BSD, standardmäßig an, von 1996 | keine, oder zstd pro Frame, das du hinzufügst und kontrollierst |
| Root nötig | ja, durchgehend | um das Device zu erstellen; nicht um es zu benutzen |
| Zeilen an beweglichen Teilen | ein Daemon, dreißig Jahre alt | ein Dateideskriptor |
PPP gibt dir einen ausgehandelten, selbstüberwachenden Link und berechnet dafür ein Protokoll. Ein Tap-Device gibt dir ein rohes Loch für nichts, und du lieferst die fehlenden Teile oder kommst ohne aus. Für einen Tunnel, der läuft, ist die fehlende Lebendprüfung die, die beißt: pppd bemerkt einen toten Träger in dreißig Sekunden und baut ihn neu auf, der Tap-Bau bemerkt nichts, weil nichts darin zusah. Füg ein Keepalive hinzu, lass es unter etwas laufen, das es neu startet, oder nimm den Bau, der schon eines hat.
Wann Das Das Richtige Werkzeug Ist Und Wann Nicht
Es ist nie wirklich das richtige Werkzeug, und ich tue nicht so, als wäre es das. Alles oben funktioniert, und nichts davon ist, was du in Produktion fahren solltest. Die ehrliche Version einer Anleitung enthält den Teil, wo du das Werkzeug weglegst, und das ist dieser Teil.
Wofür es wirklich gut ist, ist dir zu zeigen, wie eine Sache funktioniert. Ein VPN in seine Teile zerlegt und, im nächsten Abschnitt, wie sich Egress tatsächlich verhält, sobald jemand mit Root in deinem Netzwerk ist. Das sind die Gründe, das gelesen zu haben. Die engen Fälle unten sind echt, aber sie sind nicht, warum der Beitrag existiert.
Greif zum PPP-Bau, wenn:
- Der Träger überhaupt nicht IP ist — eine serielle Konsole, ein USB-Gadget, eine Funkstrecke, eine benannte Pipe, ein SSH-Kanal.
pppdist es egal, worüber die Bytes reisen, und ein Tap-Device kann dir hier nicht helfen. - Du etwas rettest: eine Maschine mit einer seriellen Konsole, ohne Netzwerk, und eine Aufgabe, die heute Abend fertig werden muss. PPP über diese Konsole ist ein gerouteter Link, an beiden Enden installiert, ohne dass etwas hinüberkopiert werden muss.
- Du willst, dass der Link auf sich selbst aufpasst. LCP-Echo, Adressaushandlung und Neustart sind gratis; sie selbst zu schreiben ist der Weg, wie der Tap-Bau zu einem kleinen unzuverlässigen Produkt wird.
Greif zum Tap-Bau, wenn:
- Du Schicht 2 brauchst — ein Nicht-IP-Protokoll, ein Cluster-Heartbeat, der Broadcasts will, DHCP über den Tunnel, oder zwei Segmente, die eines sein müssen.
- Du die wenigsten beweglichen Teile willst: Über UDP mit DTLS sind es zwei Befehle, kein Daemon, keine Aushandlung, nichts zu escapen.
- Root knapp ist. Erstell das Device einmal mit
user, und der Prozess, der Frames bewegt, braucht nie wieder Privilegien.
Beide sind das richtige Werkzeug, wenn du lernst. Jede Schicht ist sichtbar und einzeln austauschbar, und es gibt keinen besseren Weg zu verstehen, was ein VPN-Produkt tut, als eines aus den Teilen zu bauen und jedem beim Hochkommen zuzusehen.
Keines ist das richtige Werkzeug, wenn du ein VPN willst. Dafür nimm WireGuard. Es ist im Kernel, ein Bruchteil des Codes, es macht die Krypto ordentlich, ohne etwas auszuhandeln und ohne etwas, das man falsch machen kann, und es ist von Grund auf ein Datagramm-Protokoll. ssh -w gibt dir ein tun-Device über eine bestehende SSH-Sitzung in einem Befehl, und OpenVPN ist die reife, auditierte Version der DTLS-über-tap-Form oben. Alle drei sind darin besser als alles hier Gebaute.
Bau das, weil du wissen willst, was in der Kiste steckt, die du kaufst. Nicht, weil es clever war.
Was Das Wirklich Zeigt: Egress Von Der Angreiferseite
Das ist der Grund, einen Bau-Beitrag zu lesen, den man dir gerade zu meiden gesagt hat. Dreh ihn um und sieh von innerhalb deines eigenen Netzwerks, als der, der gerade mit Root dort gelandet ist. Jeder echte Einbruch endet dort, durch einen gestohlenen Schlüssel, einen Container-Ausbruch, einen ungepatchten Dienst, einen Insider. Die Frage, die dann entscheidet, wie schlimm der Tag wird, ist nicht „was können sie ausführen“, denn sie können alles ausführen. Es ist „was kann raus, und hattest du das entschieden, bevor sie ankamen.“
Wenn ausgehend standardmäßig offen ist, ist die Antwort: alles, und du kannst nun wenig tun. Nichts hier war exotisch. pppd, ncat, socat und ip sind signierte Distributionspakete, schon auf der Kiste; der Tap-Shim ist fünfzig Zeilen Standardbibliothek. Ein erlaubter ausgehender Port — und es ist 443, der eine, den jedes Netzwerk standardmäßig öffnet — und es gibt einen gerouteten Link von deinem Netzwerk zu dem eines anderen, verschlüsselt, authentifiziert, Neustarts überlebend, IPv4 und IPv6 tragend, und an deiner Grenze nicht zu unterscheiden von jeder HTTPS-Sitzung, die deine Nutzer zehntausendmal am Tag machen. Mach es tap und bridge es, und was das Gebäude verließ, ist keine Route. Es ist das Segment.
Es gibt nichts, das ein Scanner erwischen könnte: keine Malware-Signatur, weil es keine Malware gibt, kein seltsames Protokoll, weil es ein normaler TLS-Handshake auf 443 ist, keine ungewöhnliche Binary, weil dein eigener Paketmanager jede installierte. Der Proxy protokolliert eine Verbindung und einen Byte-Zähler, und beide sehen aus wie Arbeit.
Und das Ziel steht nicht einmal fest. Ein Socket muss nicht dort enden, wo die Pakete landen, weil der Träger durch einen Proxy gelenkt werden kann — ein Relay, das nichts weiter ist als zwei Verbindungen und eine Pipe, was der nächste Abschnitt in einer Zeile Shell baut. ncat nimmt --proxy mit --proxy-type http, socks4 oder socks5, sodass die TLS-Sitzung, die deine Grenze sieht, an dem endet, wozu der Angreifer ihr sagte, sich zu verbinden durch — ein interner Sprunghost, ein erlaubter SaaS-Endpunkt, der zufällig CONNECT weiterleitet, ein Cloud-Relay — und der Tunnel reitet von dort weiter zu einem Ort, den du nie siehst. HTTP CONNECT und SOCKS tun das beide von Entwurf her, denn dafür ist ein Proxy da. Ein Allow-List-Eintrag für ein Ziel, dem du traust, ist also immer nur Vertrauen in das Ziel und in alles, wohin es weiterleitet, was du nicht kontrollierst und nicht aufzählen kannst. Der Endpunkt in deinem Firewall-Log ist der Proxy. Er war nie das ferne Ende.
Also die unbequeme Wahrheit: Sobald jemand mit Root drin ist und ausgehend freizügig ist, ist der Tunnel nicht das, was du verhindern kannst. Die Teile sind installiert, der Ausgang ist offen, und er führt nicht einmal dorthin, wo er hinzuführen scheint. Deine eine Chance, das schwer zu machen, war bevor der Angreifer ankam, an der Grenze, indem du entschiedest, was raus darf.
Das ist Default-Deny-Egress, und es ist die ganze Lektion. Ausgehend standardmäßig blockiert; eine kurze, benannte Allow-List, wo ein Mensch jedes Ziel und jeden Port begründet hat; alles andere abgelehnt, protokolliert und alarmiert. Nicht, weil es einen entschlossenen Angreifer kalt stoppt — ein erlaubtes Ziel ist ein erlaubter Tunnel — sondern weil die Alternative ist, überhaupt keine Entscheidung zu haben, die man durchsetzt. Eine Egress-Policy, geschrieben als Liste erlaubter Ports, ist eine Policy über Portnummern. Sie war nie eine Policy darüber, was rausgeht, und sobald jemand Root hat, sind Portnummern alles, was sie schützt.
Das ist dasselbe Ergebnis wie der Ping-Beitrag, der den Tunnel aus ICMP-Echo baut, und der Protokoll-Helfer-Beitrag, wo deine Firewall die Löcher selbst öffnet. Drei Wege hinein, eine Schlussfolgerung: Die Kontrolle, die du zu haben glaubtest, war über Protokolle, und nicht eines dieser Protokolle ist, was es vorgibt. Die Grenze, im Voraus entschieden und Default-Deny, ist die einzige Kontrolle, die je real war.
Ein Proxy Sind Zwei Verbindungen Und Eine Pipe
Es lohnt sich zu sehen, wie wenig ein Relay ist, denn es erklärt, warum du vom nahen Ende nicht auf das ferne schließen kannst. Ein Proxy ist keine besondere Software. Es ist eine Verbindung, mit einer anderen durch eine Pipe verbunden. Die älteste Form benutzt eine benannte Pipe, eine FIFO, um die Rückrichtung zu tragen: Ein ncat lauscht, ein anderes verbindet weiter, und die FIFO verdrahtet den Antwortpfad zwischen ihnen.
mkfifo backpipe
ncat -l 7000 0<backpipe | ncat farend.example.net 7100 1>backpipe
Lies es als Klempnerei: Die Ausgabe des Listeners läuft in das zweite ncat und weiter zum fernen Ende, und die Antworten kommen durch die FIFO zurück zum Client. Zwei Sockets, eine Pipe, beide Richtungen, und die Verbindung des Clients endet hier, am Relay, während die Pakete zu farend und zurück weiterreisen. Ich habe genau das auf Loopback mit einem dritten ncat laufen lassen, das am fernen Ende zurückwarf, und eine hineingeschickte Zeile kam zurück, nachdem sie die ganze Reise gemacht hatte.
Ncat macht dasselbe in einem Prozess, indem es die Weiterverbindung für jeden ankommenden Client exect:
ncat -l 7000 --keep-open --sh-exec 'ncat farend.example.net 7100'
Dieselbe Form, weniger Teile: Der Socket des Listeners ist mit dem des exec’ten ncat durch die Pipe verbunden, die die Shell dazwischensetzt. Kette drei und der Tunnel kreuzt drei Netzwerke, endet und startet an jedem neu, wobei die Firewall jedes Sprungs eine saubere lokale Verbindung zur nächsten protokolliert und nichts darüber hinaus.
Das ist der ganze Trick, und deshalb ist das Grenz-Log kein Beweis für ein Ziel. Jedes Relay ist das ferne Ende, soweit die Kiste davor es erkennen kann, und das echte andere Ende ist so viele Pipes entfernt, wie niemand zusah.
Setz nun diese Relays auf Maschinen, die nicht dem Angreifer gehören.
Jeder Besitzer entlang dieser Kette sieht nur eine Verbindung vom Sprung davor zum Sprung danach: kein Ursprung, kein Ziel, nur Mitte. Das ist keine neue Idee, die ich jemandem in die Hand gebe. So haben Pivot-Ketten und C2-Netze seit zwanzig Jahren funktioniert, und warum das Ausgehende eines kompromittierten Hosts so oft zu einem weiteren Opfer führt statt zum Angreifer. Deine Logs zeigen, dass du mit einer Kiste in irgendeinem Rechenzentrum gesprochen hast. Wessen, und wohin sie weiterleitet, stand nie darin.
Das defensive Gewicht ist eine Zeile: Du kannst einem Ziel, das du nicht im Voraus eingeschränkt hast, weder zuschreiben noch trauen. Wenn der Verkehr geht, sagt dir die Adresse, zu der er geht, fast nichts, denn es ist ein Relay auf der Maschine eines anderen, und der echte Endpunkt ist dahinter gewaschen.
Der Link War Nie Das Produkt
Was mir auffällt, nachdem ich das auseinandergenommen habe, ist, wie wenig davon neu ist und wie viel davon verkauft wird.
RFC 1661 ist von 1994. pppd ist seit dreißig Jahren in jeder Linux-Distribution, die Kernel-Module sind acht Dateien in einem Verzeichnis, und das Ganze, was ein VPN zu einem VPN macht — eine virtuelle Schnittstelle, ein ausgehandelter Link, ein verschlüsselter Träger, eine Route — sind vier Programme und ein Zertifikat. Nichts davon ist schwer oder geheim. Sie haben jedes Byte dokumentiert und es verschenkt, und eine Industrie wuchs zwischen dir und ihm, die dieselben vier Teile in einer Kiste mit einer Lizenz pro Platz und einem Supportvertrag verkauft, der abläuft. Die Teile wurden nicht besser. Sie wurden eingewickelt.
Das ist kein Argument, das in Produktion zu fahren. Ich habe dir gerade gesagt, es nicht zu tun. Es ist ein Argument zu wissen, was in der Kiste steckt, die du kaufst, denn an dem Tag, an dem der Hersteller die Lizenzierung ändert, aufgekauft wird oder dein Modell beendet, ist der Unterschied zwischen einem schlechten Quartal und einem schlechten Jahr, ob irgendwer bei dir weiß, woraus das Ding gemacht war.
Nimm dir einen Abend und bau den Tunnel aus den Teilen. Sieh LCP aushandeln, brich den Link und sieh ihn zurückkommen, zieh das Zertifikat und sieh, was aufhört zu funktionieren. Dann lies das Datenblatt deines VPN-Herstellers noch mal, und sieh, wie viel davon du wiedererkennst.
Derselbe Abend kauft die andere Hälfte. Wenn ein gerouteter Link aus deinem Netzwerk vier installierte Programme und ein Zertifikat ist, wird der, der gerade auf einer deiner Kisten Root bekam, nicht davon aufgehalten, wie schwer der Tunnel zu bauen ist. Er ist nicht schwer. Sie werden nur davon aufgehalten, was du entschieden hast, bevor sie ankamen, dass es raus durfte. Bau es einmal und du hörst auf, Egress als etwas zu denken, das ein Produkt durchsetzt, und fängst an, es als eine Entscheidung zu denken, die du entweder getroffen hast oder nicht.
Nicht viel davon ist Magie. Das meiste davon ist 1994 mit einem Anstrich, und an 1994 ist nichts falsch. Es funktionierte, es war dokumentiert, und es läuft noch.
RFC 1661 — The Point-to-Point Protocol (PPP), 1994. Definiert den Link, LCP und die Familie der Netzwerk-Kontrollprotokolle, die darauf sitzen. ↩︎
RFC 2637 — Point-to-Point Tunneling Protocol (PPTP), das PPP in GRE trägt. ↩︎
RFC 3931 — Layer Two Tunneling Protocol version 3, der Standards-Track-Nachfahre des Protokolls, das PPP in UDP trägt. ↩︎
pppd(8) — die Manpage des PPP-Daemons. Quelle für
pty,notty,local,passive,noipdefault,proxyarp,record,receive-all, die LCP-Echo-Optionen und den asyncmap-Default: „If no asyncmap option is given, the default is zero, so pppd will ask the peer not to escape any control characters.“ Die Zitate hier wurden ausman pppdauf ppp 2.5.1 gelesen. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎RFC 1662 — PPP in HDLC-like Framing. Die Flag-Sequenz, die Octet-Stuffing-Regel und die 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 — die Standarderklärung des Neuübertragungs-Stapelns, die genau mit diesem Bau eröffnet. Die ursprüngliche URL liefert den Artikel nicht mehr; das ist eine Momentaufnahme des Internet Archive. ↩︎
RFC 5072 — IP Version 6 over PPP. IPV6CP und die 64-Bit-Interface-Identifier, unabhängig von IPCP. ↩︎
RFC 1994 — PPP Challenge Handshake Authentication Protocol (CHAP). Beweist die Identität des Peers; verschlüsselt nichts. ↩︎
RFC 3079 — Deriving Keys for use with Microsoft Point-to-Point Encryption (MPPE). Die RC4-Konstruktion, geschlüsselt aus dem MS-CHAP-Austausch, und der Grund, warum PPTP keine lebende Option ist. ↩︎
Ncat Users’ Guide — connecting through a proxy —
--proxyund--proxy-typefür HTTP CONNECT und SOCKS 4/5, sodass der TLS-Träger am Proxy endet, nicht am fernen Ende des Tunnels. ↩︎Ncat Users’ Guide — die Optionen
--ssl,--ssl-verifyund--ssl-trustfilesowie die Listen- und UDP-Modi. Hier getestete Version: Ncat 7.92. ↩︎Universal TUN/TAP device driver — die eigene Dokumentation des Kernels für
/dev/net/tun,TUNSETIFFund den Unterschied zwischen tun und tap. ↩︎PEP 784 — Adding Zstandard to the standard library, weshalb
compression.zstdunter Python 3.14 kein Paket braucht. Hier gegen Python 3.14.7 und zstd 1.5.7 gemessen. ↩︎RFC 7457 — Summarizing Known Attacks on TLS and DTLS, das CRIME und die allgemeine Form eines Kompression-vor-Verschlüsselung-Längenlecks abdeckt. ↩︎
OpenVPN — the VORACLE attack — dasselbe Leck gegen ein VPN, das vor dem Verschlüsseln komprimiert, und der Grund, warum OpenVPN jetzt von Kompression abrät. ↩︎