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.

Jeder Tunnel ist eine Link-Hälfte, ein Träger und etwas KryptoJedes Mal dieselben drei Teile. Nur der Träger und die Krypto ändern sich.TUNNELLINK-HÄLFTETRÄGERKRYPTOWähl-PPP, 1994PPPModem und TelefonleitungkeinePPPoEPPPEthernet-FrameskeinePPTPPPPGREMPPE — gebrochen, lass esL2TP über IPsecPPPUDPIPsecDieser Beitrag, Bau einsPPP über ein ptyUDP-Socket, über NetcatDTLSDieser Beitrag, Bau zweitun- oder tap-DeviceUDP-Socket, über socatDTLSWireGuardKernel-SchnittstelleUDPNoise, und nichts auszuhandeln
Jeder Tunnel auf dieser Liste hat dieselbe Form. Die Unterschiede sind, in welchen Träger die Bytes gehen und ob überhaupt etwas verschlüsselt. PPP erledigt die Link-Hälfte seit 1994, weshalb es unter dreien davon auftaucht, ohne für eines davon umgebaut worden zu sein. Ein tun- oder tap-Device erledigt dieselbe Hälfte ganz ohne Protokoll, und das ist der zweite Bau in diesem Beitrag.

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.

Sechs Stufen, jede fügt eine Sache hinzu, die du einzeln testen kannstBau es in dieser Reihenfolge und du kannst immer wieder herunterlaufen1Roh, über TCPpppd mit einem pty, oder ein Tap-Device, und ein schlichter Netcat-Listener.Geht auf der Werkbank. Bricht auf echtem Pfad zusammen — zwei streitende Neuübertragungs-Timer.2Roh, über UDPDerselbe Link, Datagramm-Träger. Ein verlorenes Datagramm ist ein verlorenes Paket, mehr nicht.Das ist das Fundament. Alles darüber ist optional; das hier nicht.3TLS, über TCPncat, stunnel oder openssl wickeln den Träger ein. Prüf das Zertifikat an beiden Enden,sonst hast du Verschlüsselung ohne Identität und gar keine Zugangskontrolle.4DTLS, über UDPsocat oder openssl, und die Form, auf die man zielt: verschlüsselt, authentifiziert, immer noch Datagramme.Ein verlorenes Paket bleibt ein verlorenes Paket, statt dass zwei Stacks darüber streiten.5Komprimiertzstd zwischen Schnittstelle und Träger. Halt das Fenster über Frames hinweg, wo derTräger in Reihenfolge zustellt; ein eigenständiger Frame pro Datagramm plus Wörterbuch, wo nicht.6Beides, in dieser FolgeKomprimieren, dann verschlüsseln. Nie andersherum — Geheimtext komprimiert nicht.Und kenn den Handel: Komprimieren vor dem Verschlüsseln verrät die Klartextlänge. Auf eigenem Verkehr in Ordnung.
Sechs Stufen, jede fügt der darunter eine Sache hinzu. Roh über TCP ist, wo jeder anfängt, und die einzige Sprosse, die eine Sackgasse ist: Sie läuft auf der Werkbank und schmilzt auf einer echten Leitung. Roh über UDP ist das Fundament, auf dem alles andere sitzt. Dann Verschlüsselung, dann Kompression, dann beides zusammen. Jede Stufe ist ein Link, den du hochfahren, über den du pingen und den du laufen lassen kannst, sodass du, wenn die Spitze der Leiter zickt, sie Sprosse für Sprosse wieder hinuntergehen kannst.

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 script is 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.

pppd, ein Pseudo-Terminal, Netcat und ein SocketEin Paket, von ppp0 zu ppp0, und jede Hand, durch die es gehtHOST AHOST Bppp0Kernel-SchnittstellepppdHDLC-Framingpty-PaarSlave an pppdMaster an das Kindncatstdin und stdoutpty-PaarMaster an das KindSlave an pppdncatstdin und stdoutpppdHDLC-Framingppp0Kernel-Schnittstelleder Socket — TCP oder UDPDie zwei Kästen in der Mitte sind der einzige Teil, den du wählst. Alles zu beiden Seiten bleibt gleich, ob der Träger eine Telefonleitung,eine serielle Konsole, ein TCP-Stream, ein UDP-Datagrammfluss oder eine TLS-Sitzung ist — deshalb kostet der Trägertausch später ein Wort.Ein Pseudo-Terminal hat keinen Carrier-Detect-Pin, also wartet pppd auf einen Träger, der nie kommt. Die Option `local` ist, was das stoppt.
pppd spricht asynchrones HDLC in die Slave-Seite eines Pseudo-Terminals, das es sich selbst zugeteilt hat. Der von 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.

ImplementierungAn einem Port lauschenUDPBleibt nach Trennung eines Peers oben
Ncat (Nmap)ncat -l 443-u-k / --keep-open
OpenBSD netcatnc -l 443-u-k
Traditional netcatnc -l -p 443-unein
GNU netcatnc -l -p 443-unein

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.

Jedes Wort auf der pppd-Zeile, und was es tutDie Kommandozeile des UDP-Baus, Wort für WortnodetachVordergrund, damit du die Ausgabe siehst und es unter einen Supervisor stellen kannstnoauthkeine Peer-Authentifizierung — das ist das Loch; der Tunnel hat keine Zugangskontrollelocalignoriere die Modem-Steuerleitungen, die ein pty nicht hat. Ohne das kommt nichts hochpassivewarte auf ein gültiges LCP-Paket, statt sich zu beenden — Server-Socket-Verhaltennodefaultroutegreif die Default-Route nicht, wenn IPCP fertig ist. Setz die Route, die du willst, selbstnoipdefaultbiete nicht die eigene Adresse der Maschine als lokales Ende an192.0.2.1:192.0.2.2lokal:fern, festgenagelt — pppd lehnt während IPCP jede andere Antwort ablcp-echo-interval / -failuredie Lebendprüfung — eigener Abschnitt weiter untenmtu / mru 1400lass Platz für alles, was der Träger um jeden Frame wickeltpty '...'der Befehl, dessen stdin und stdout der Link werden — hier ein Netcat-SocketDas verbindende Ende ist dieselbe Zeile ohne `passive`: Es initiiert, statt zu warten.
Jede Option auf der Zeile und was sie tut. Die eine, die man im Auge behalten muss, ist 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.

LCP, dann Authentifizierung, dann ein Steuerprotokoll pro AdressfamilieDrei Stufen, und jede scheitert aus eigenem Grund1LCP — der Link selbstMaximum Receive Unit, die Steuerzeichen-Map,eine magische Zahl gegen eine Leitungsschleife,und ob ein Ende Authentifizierung verlangt.Scheitert es hier, reicht der Träger Bytes nichtsauber in beide Richtungen durch. Prüf den Socket,nicht die PPP-Optionen.2Authentifizierung — optionalPAP schickt ein Passwort im Klartext. CHAP machteine Challenge und eine MD5-Antwort. Entfällt.Beide beweisen, wer der Peer ist. Keines verschlüsseltein Byte von dem, was folgt.3aIPCPDie IPv4-Adressean jedem Ende.3bIPV6CPDie 64-Bit-Interface-Identifier.Diese zwei sind unabhängig. IPv6 kann hochkommen, während IPv4 noch streitet, undein Fehler im einen reißt das andere nicht mit. Die Schnittstelle trägt Verkehr füreine Familie, sobald deren Steuerprotokoll fertig ist.
LCP klärt den Link selbst — wie groß ein Frame sein darf, welche Steuerzeichen escapt werden müssen, und eine magische Zahl, die eine zurückgeschleifte Leitung erkennt. Authentifizierung ist optional und hier übersprungen. Dann ein Kontrollprotokoll pro Netzwerkschicht: IPCP für IPv4, IPV6CP für IPv6. Sie sind unabhängig, sodass ein Link IPv6 tragen kann, während IPv4 noch streitet, und ein Fehler in einem nimmt das andere nicht mit runter.

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.

Der Frame, das Flag-Byte, und was Escapen kostetEin Frame, und die zwei Bytes, die darin nie vorkommen dürfen0x7EFlag0xFFAdresse0x03ControlProtokoll1 oder 2 BytesNutzlast — dein IP-Paketbis zur vereinbarten Maximum Receive UnitFCS16-Bit-CRC0x7EFlagESCAPEN, DIE EINZIGE REGEL, DIE ES GIBTJedes Byte, das für eine Grenze gehalten werden könnte, wird durch 0x7D ersetzt, gefolgt vom ursprünglichen Byte XOR 0x20.Nutzlast 0x7E übertragen als 0x7D 0x5ENutzlast 0x7D übertragen als 0x7D 0x5DDiese zwei sind Pflicht. Alles andere entscheidet die Async-Control-Character-Map — 32 Bit, eines pro Steuerzeichen.Map auf null — der Default von pppd, und richtig auf einem SocketZwei von 256 Bytes escapt. Overhead, den du nie messen wirst. Ein Socket ist ein sauberer 8-Bit-Pfad und braucht nichts weiter.Alle 32 gesetzt — die konservative Modem-EinstellungBei komprimierten oder verschlüsselten Nutzlasten liegt etwa jedes achte Byte unter 0x20, verdoppelt sich also. Rund 12 % der Leitung, für nichts.
Der Frame ist Flag, Adresse, Kontrolle, Protokoll, Nutzlast, Frame-Prüfsequenz, Flag. Jedes Byte darin, das mit einer Grenze verwechselt werden könnte, wird durch 0x7D ersetzt, gefolgt vom Originalbyte XOR 0x20. Die ACCM entscheidet, wie viele andere Bytes dieselbe Behandlung bekommen: null davon auf einem sauberen 8-Bit-Pfad, zweiunddreißig davon, wenn eines der Enden nach dem konservativen Default fragt, der ein Modem annimmt, das Steuerzeichen frisst.

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

Ein verlorenes Paket, zwei Träger, zwei sehr verschiedene AusgängeEin Paket geht verloren. Was jeder Träger als Nächstes tut.TCP-TRÄGER — der Tunnel verbirgt den Verlust1Der Träger verliert ein Segment.2Der Träger überträgt es neu. Die Bytes kommen spät,nicht gar nicht — und das ist das ganze Problem.3Das getunnelte TCP sieht den Träger nicht. Es liestdie Verzögerung als Überlastung, verdoppelt den Timerund überträgt Daten neu, die darunter schon fliegen.4Nun schuldet der Träger das Original und ein Duplikat,über eine Leitung, die gerade Paketverlust bewies.Die Warteschlange wächst schneller, als beide sie leeren.Der Durchsatz bricht lange vor der Leitung zusammen.UDP-TRÄGER — der Verlust erreicht die Schicht, der er gehört1Der Träger verwirft das Paket und sagt nichts.2Der PPP-Frame darin scheitert an seiner Prüfsummeund wird verworfen. Das nächste Flag-Byte synchronisiert neu.3Das getunnelte TCP sieht einen echten Verlust, weil ihmeinmal die Wahrheit über den Pfad gesagt wurde.4Es halbiert sein Fenster und überträgt einmal neu.Die Überlastkontrolle macht genau das, wofür sieentworfen wurde. Ein verlorenes Paket kostet ein Paket.
Das Träger-TCP garantiert die Zustellung, also wird ein verlorenes Segment neu übertragen und die Bytes kommen spät statt gar nicht an. Das getunnelte TCP darin sieht nur die Verzögerung, entscheidet, das Netz sei überlastet, macht einen Rückzieher und überträgt dieselben Daten neu — die der Träger nun ebenfalls zustellen muss. Der Timer jeder Schicht versucht ein Problem zu beheben, das die andere Schicht bereits besitzt, und die Warteschlange wächst schneller, als eine von beiden sie leeren kann. Ein UDP-Träger verwirft das Paket, das innere TCP sieht einen echten Verlust, und seine Überlastkontrolle tut die Aufgabe, für die sie gebaut wurde.

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.

Ein verlorenes Datagramm kostet einen Frame, das nächste Flag-Byte holt den Link zurückDie Grenzen passen nicht zusammen, und das ist egalPPP-FRAMES, WIE pppd SIE SCHRIEB7EFrame A + FCS7EFrame B + FCS7EFrame C + FCS7EFrame D + FCSWAS DER TRÄGER WIRKLICH SCHICKTE — Netcat liest einen Puffer, keinen FrameDatagramm 1Datagramm 2 — verlorenDatagramm 3Datagramm 4Frame B verliert seine Mitte, also scheitert die Prüfsumme und er wird verworfen. Frame C verliert seine ersten Bytes und geht denselben Weg.Zwei verlorene Frames sind zwei verlorene IP-Pakete. Die Schicht darüber überträgt sie neu, und zwar im Wissen, dass der Pfad etwas verlor —genau das Signal, das ein TCP-Träger verborgen hätte, indem er sie stattdessen spät zugestellt hätte.
Netcat liest, was auch immer im Puffer ist, und schreibt es in ein Datagramm, sodass Frame-Grenzen und Datagramm-Grenzen nichts miteinander zu tun haben. Verlier ein Datagramm und der Empfänger sieht einen Frame, dem Bytes fehlen: Die Frame-Prüfsequenz scheitert und der Frame wird verworfen, genau wie auf einer verrauschten seriellen Leitung. Das nächste 0x7E synchronisiert den Stream neu. Ein verworfener Frame kostet ein Paket, und die Schicht darüber überträgt es neu — was das Verlustsignal ist, das das innere TCP brauchte und über einen TCP-Träger nie bekam.

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
Einen toten Träger in dreißig Sekunden erkennen und neu aufbauenEin Träger kann sterben, ohne zu schließen. So merkt PPP es und erholt sich.Träger stirbt stillfernes Ende aus · NAT-Eintrag weg ·Middlebox leitet nicht mehr weiterNichts meldet eskein FIN, kein RST · ip link sagtweiter ppp0 UP · UDP hat keinen ZustandDer Detektorlcp-echo-interval 10 lcp-echo-failure 3Echo alle 10s, aus nach 3 verpassten — 30sDer Neuaufbaupersist maxfail 0 holdoff 5neu starten, nie aufgeben, 5s Pause je VersuchEin Supervisorsystemd-Unit, Restart=always,pppd führt den pty-Befehl erneut aus —frischer Netcat und Socket dazuSetz das Echo an beiden Enden. Ein Echo beweist nur den Pfad, auf dem die Antwort zurückkam.Leg `ip route` in /etc/ppp/ip-up.d/ und die IPv6-Routen in /etc/ppp/ipv6-up.d/, damit das Routing bei jeder Rückkehr des Links neu gesetzt wird —nicht einmal von Hand gesetzt und beim ersten Reconnect verloren.
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.

Schlicht, TLS über TCP, oder DTLS über UDPDieselbe Link-Hälfte an beiden Enden. Nur die Mitte ändert sich.pppd oder tap0schlichtes Netcat, UDPncat --udp --listen 6000pppd oder tap0Im Klartext auf der Leitung, und der Listener nimmt, wer zuerst am Port ist. Keine Vertraulichkeit, keine Zugangskontrolle.pppd oder tap0TLS über TCP — ncat, stunnel oder opensslncat --ssl --ssl-verify --ssl-trustfile tunnel.crt host 6000pppd oder tap0Geschützt, und das Zertifikat sagt, wer das ferne Ende ist. Aber der Träger ist wieder TCP — und es ist die einzige Form, die stream-komprimieren kann.pppd oder tap0DTLS über UDP — socat oder opensslsocat - OPENSSL-DTLS-CLIENT:host:6000,cafile=tunnel.crt,verify=1pppd oder tap0Verschlüsselt, authentifiziert und immer noch Datagramme. Ein verlorenes Paket bleibt verloren, statt dass zwei Stacks streiten. Ziel hierauf.
Durchgehend dasselbe pppd an beiden Enden — nur der pty-Befehl ändert sich. Reines Netcat gibt dir einen Link, den nichts schützt. TLS über TCP schützt die Bytes und führt das Träger-TCP-Problem wieder ein. DTLS über UDP ist die Form, die man anstreben sollte: verschlüsselt, authentifiziert und immer noch ein Datagramm-Träger, sodass ein verlorenes Paket ein verlorenes Paket bleibt, statt zu einem Neuübertragungs-Kampf zwischen zwei Stacks zu werden.

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.

Der Overhead-Stapel, und die innere MTU, die übrig bleibtRechne von der Pfad-MTU herunter und nimm, was übrig bleibtIP-Header20 (v4) / 40 (v6)UDP-Header8DTLS-Record + Tag≈ 30PPP-/Ethernet-Framingein paarinnere MTU — was der Tunnel tragen kann: ziel 1400, Untergrenze 1280Ein DTLS-Record kann nicht so fragmentiert werden, wie sich ein TLS-Record über einen TCP-Stream verteilt, also muss das alles auf einmal in die Pfad-MTU passen.Dieselbe Form wie OpenVPN seit 2001 — ein Datagramm-Träger, DTLS, eine virtuelle Schnittstelle obendrauf. Nicht exzentrisch; nur entbündelt.
Rechne von 1500 herunter: 20 Byte IPv4-Header oder 40 von IPv6, 8 von UDP, etwa 30 für den DTLS-Record und sein Tag, ein paar fürs Framing, und der Rest ist die innere MTU, die der Tunnel tragen kann. Ziel 1400 auf einem gewöhnlichen Pfad; 1280 ist die sichere Untergrenze, wenn irgendetwas in der Mitte selbst ein Tunnel ist. Diese Form — ein Datagramm-Träger, DTLS, eine virtuelle Schnittstelle obendrauf — ist nahe genug an dem, was OpenVPN seit 2001 macht. Nicht exzentrisch; nur ausgepackt.

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.

Vier pppd-Knöpfe: einer zu setzen, einer zu lassen, zwei auszuschaltenEiner zu setzen, einer zu lassen, zwei auszuschaltenSETZ IHNMTU und MRUBeide Enden, mit Luft: 1400 schlicht, 1280 unter einem Tunnel. Ein fragmentierter Frame verliert ein ganzes Paket.LASS IHNACCMStandardmäßig schon null, und das ist richtig auf einem sauberen Socket. Fass sie nur an, wenn etwas Steuerzeichen frisst.SCHALT AUSnovjVan-Jacobson-HeaderkompressionSpart einen Rundungsfehler auf schneller Leitung, kostet CPU pro Paket und bricht unter Verlust. Aus über Modemtempo.SCHALT AUSnodeflate nobsdcompDie Nutzlast ist schon TLS/DTLS — Geheimtext zu komprimieren ist pure Arbeit, und über eine Sicherheitsgrenze hinweg ein Angriff.Keiner davon macht den Tunnel schneller. PPP ist nicht der Engpass — PPPoE macht 2 Gbit auf demselben Daemon, weil sein Datenpfad imKernel bleibt. Die Kosten hier sind das pty: Jedes Byte geht in den Userspace und zurück. Das gehört zum Pseudo-Terminal, nicht zu PPP.
MTU und MRU ist der eine, auf den es ankommt: Setz beide auf beiden Enden mit Spielraum, denn ein Frame, den der Träger fragmentieren muss, verliert bei jedem Verlust ein ganzes Paket. Die ACCM ist schon null und auf einem sauberen Socket richtig. Schalte die Van-Jacobson-Header-Kompression mit 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
Ein Tap-Device ist an einem Ende eine Schnittstelle und am anderen ein DateideskriptorKein Daemon, kein Protokoll, kein Pseudo-Terminal. Ein Dateideskriptor.KERNELtap0gewöhnliche Schnittstelle,mit Adresse und Route/dev/net/tunTUNSETIFF benenntdas benannte Devicedein Prozesssocat, oder fünfzig ZeilenPython, die den fd haltenUDP-Socketein Frame pro Datagrammdas Netzwerkund das ferne Endeein read = ein FrameWas gegenüber dem PPP-Bau fehlt: keine ausgehandelte Framegröße, keine Lebendprüfung, keine ausgetauschten Adressen, keine Authentifizierung.Du stellst beide Enden von Hand ein und sie reden über nichts davon. Nichts beobachtet den Link, also sagt dir nichts, dass er gestorben ist.
Kein Daemon und kein Protokoll. Der Kernel präsentiert tap0 als gewöhnliche Schnittstelle und übergibt die andere Seite davon dem Prozess, der /dev/net/tun geöffnet hat. Ein Lesevorgang liefert genau einen Ethernet-Frame; ein Schreibvorgang injiziert genau einen. Alles, was pppd aushandelte — Adressen, Frame-Größen, Lebendigkeit — konfigurierst du jetzt von Hand an beiden Enden, und die zwei Enden besprechen nichts davon miteinander.

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:

tapcat.py — das Ganze, etwa hundert Zeilen tapcat.py · 6 kB

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.

Ein Datagramm pro Frame, oder ein Längenpräfix, das du erfinden musstEin read von einem Tap-Device ist ein Frame. Das so zu halten ist Aufgabe des Trägers.ÜBER UDP — die Grenze ist gratisDatagramm = Frame ADatagramm = Frame BDatagramm = Frame CDatagramm = Frame DEin read, ein Datagramm, ein write am fernen Ende. Nichts abzugrenzen, nichts zu puffern, nichts nach einem Verlust neu zu synchronisieren.ÜBER TCP — die Grenzen sind weg und du musst sie zurückbringenein Byte-Stream — zwei Frames können in einem read ankommen, ein Frame in dreienlenFrame AlenFrame BlenFrame ClenFrame DZwei Bytes Big-Endian-Länge vor jedem Frame, und ein Empfänger, der die Länge liest und dann genau so viele Bytes.Es funktioniert, und es kann sich nicht erholen. HDLC synchronisiert sich am nächsten 0x7E neu, weil ein Flag eindeutig ist; ein längenpräfigierter Stream, derseinen Platz verliert, liest jede Länge danach aus der Mitte eines Frames. Füg eine Marke und eine Prüfsumme dagegen hinzu und du hast HDLC nachgebaut.
Über UDP ist ein Frame ein Datagramm und die Grenze kommt gratis. Über TCP sind die Grenzen weg, also muss der Sender jedem Frame ein Längenpräfix voranstellen und der Empfänger daraus wieder zusammensetzen. PPP hat dieses Problem nicht, weil es sein eigenes Framing mitbringt — ein Flag-Byte an jedem Ende und eine Prüfsumme — was auch das ist, was es sich nach Beschädigung neu synchronisieren lässt. Ein längenpräfixierter Stream kann das nicht: Gerät ein Byte aus dem Tritt, ist jeder Frame danach falsch.

Ü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.

tuntap
Was hindurchgehtIP-PaketeEthernet-Frames
Overhead pro Paketkeiner14-Byte-Ethernet-Header
ARP, DHCP, Broadcastneinja, alles davon, über den Tunnel
Nicht-IP-Protokolleneinja
Kann einer Bridge beitretenneinja
Entsprichteinem Punkt-zu-Punkt-Link, wie ppp0einem 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.

Erst komprimieren, dann verschlüsseln — und das Fenster halten, wenn der Träger es zulässtDie Reihenfolge steht fest. Der Modus ist die Entscheidung.tap0ein read, ein Framekomprimierenzstd, Level 1verschlüsselnDTLS oder TLSTrägerUDP oder TCPNie anders-herum.FLUSH_BLOCK — das Fenster haltenGibt alles bisherige aus, behält die Historie. Jeder Frame wirdgegen jeden Frame davor kodiert. Keine zusätzliche Latenz:ein Frame rein, ein Frame raus.5.0%auf repetitivem Verkehr · 7,5 % auf Log-ZeilenBraucht jeden Frame zugestellt, in Reihenfolge.Also: TCP oder TLS über TCP. Nicht UDP, nicht DTLS.FLUSH_FRAME — bei jedem Paket wegwerfenJedes Datagramm ist ein vollständiger zstd-Frame und dekodiertfür sich, also geht Datagramm N noch, wenn 1 bis N-1 verloren sind.Der einzige Modus, den ein Datagramm-Träger nutzen kann.14.8%auf demselben Verkehr · 24,2 % auf Log-ZeilenEin trainiertes Wörterbuch holt das meiste zurück:5,3 % und 15,1 %, und es bleibt verlusttolerant.
Die Kompression geht zwischen das Tap-Device und den Träger, und vor die Verschlüsselung, weil Chiffretext sich nicht komprimieren lässt. FLUSH_BLOCK ist der Modus, auf den es ankommt: Er gibt alles Bisherige aus, sodass ein Frame rein einen Frame raus ohne zusätzliche Latenz gibt, während er die Kompressionshistorie für den nächsten Frame behält. FLUSH_FRAME wirft diese Historie bei jedem Paket weg, was ihn über einen verlustbehafteten Träger sicher macht und ihn auf der Leitung dreimal schlechter.

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 TunnelStream, FLUSH_BLOCKpro Paket, FLUSH_FRAMEpro Paket mit trainiertem Wörterbuch
Wiederholende API-Aufrufe und Telemetrie5,0 %14,8 %5,3 %
Log-Zeilen7,5 %24,2 %15,1 %
Klartext und Konfigurationsdateien37,8 %51,3 %45,3 %
Zufällige Bytes, stellvertretend für TLS100 %+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 Sockettap oder tun über einen Socket
Framingeingebaut (HDLC, synchronisiert sich nach Beschädigung neu)keins über UDP, weil keins nötig ist; erfinde es über TCP
Adress-Setupvon IPCP und IPV6CP ausgehandeltvon Hand an beiden Enden konfiguriert
LebendigkeitLCP-Echo, eingebautkeine; du fügst sie hinzu oder der Link stirbt still
AuthentifizierungPAP oder CHAP verfügbar, beide schwachüberhaupt keine
Schichtnur 33 mit tun, 2 mit tap
Bridgingneinja, mit tap
OverheadFlag, Header und FCS pro Frame, plus Escapennichts, oder 14 Byte mit tap
Kompressiondeflate und BSD, standardmäßig an, von 1996keine, oder zstd pro Frame, das du hinzufügst und kontrollierst
Root nötigja, durchgehendum das Device zu erstellen; nicht um es zu benutzen
Zeilen an beweglichen Teilenein Daemon, dreißig Jahre altein 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. pppd ist 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.

Ein Relay sind zwei Sockets, verbunden durch eine Pipe — und der Endpunkt wandertEine Verbindung rein, eine raus, eine Pipe dazwischen. Das ist ein Proxy.Clientöffnet eine TLS-SitzungRELAY — das Ziel, das deine Grenze protokolliertncat -l 7000beendet den Clientncat farendbeginnt einen neuen Hopfernes Endedie echte Gegenseitestdoutbackpipe (FIFO) trägt die Antworten zurückClient → RelayRelay → fernes EndeDie Verbindung des Clients endet am Relay. Die Pakete nicht.Deine Firewall hat eine Verbindung zu dieser Maschine protokolliert. Wohin sie weiterleitet, wird in der Maschine entschieden, und drei davon verkettet legendas echte ferne Ende drei Pipes weit weg — jedes Hop-Log zeigt nur eine saubere lokale Verbindung zum nächsten, und nichts dahinter.
Ein Relay sind zwei Sockets und eine Pipe. Der Listener beendet die Verbindung des Clients. Ein zweites netcat startet eine frische Verbindung weiter, und die FIFO trägt die Rückrichtung zwischen ihnen. Die TLS-Sitzung des Clients endet hier, am Relay, und ein neuer Sprung beginnt — sodass das Ziel, das deine Grenze protokollierte, diese Kiste ist, und die Pakete weiterreisen, wohin auch immer sie weiterleitet. Kette drei und das ferne Ende ist drei Pipes entfernt, wobei die Firewall jedes Sprungs nur eine saubere lokale Verbindung zur nächsten sieht.

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.

Verkettete Relays über kompromittierte Hosts waschen den EndpunktJeder Hop ist die Maschine eines anderen, und jeder Eigentümer sieht nur MitteAngreiferseine einzige MaschineRelay 1Netz einer anderen FirmaRelay 2ein gekaperter VPSRelay 3ein HeimrouterZielwohin es gingsieht: 1←→2sieht: 2←→3sieht: 3←→ZielKein Hop sieht über seine eigenen zwei Nachbarn hinaus. Kein Ursprung, kein Ziel, nur Mitte.Der Verkehr wird durch eine Reihe fremder Systeme gewaschen, jedes fährt dasselbe Zwei-Sockets-und-eine-Pipe-Relay unter derKontrolle des Angreifers. Deshalb führt der Ausgang eines kompromittierten Hosts so oft zum nächsten Opfer, nicht zum Angreifer — zwanzig Jahre C2.
Jeder Sprung ist ein kompromittierter Host — die Kiste einer anderen Firma, ein gekaperter VPS, ein Heimrouter — der dasselbe Zwei-Sockets-und-eine-Pipe-Relay unter der Steuerung des Angreifers fährt. Kein Sprung kann über seine eigenen zwei Nachbarn hinaussehen: Der Besitzer von Relay 2 sieht eine Verbindung von Relay 1 und eine zu Relay 3, und sonst nichts. Der Verkehr wird durch eine Kette fremder Systeme gewaschen, weshalb das Ausgehende eines kompromittierten Hosts so oft zu einem weiteren Opfer führt statt zum Angreifer.

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.

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.


  1. RFC 1661 — The Point-to-Point Protocol (PPP), 1994. Definiert den Link, LCP und die Familie der Netzwerk-Kontrollprotokolle, die darauf sitzen. ↩︎

  2. RFC 2637 — Point-to-Point Tunneling Protocol (PPTP), das PPP in GRE trägt. ↩︎

  3. RFC 3931 — Layer Two Tunneling Protocol version 3, der Standards-Track-Nachfahre des Protokolls, das PPP in UDP trägt. ↩︎

  4. 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 aus man pppd auf ppp 2.5.1 gelesen. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  5. 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).“ ↩︎ ↩︎

  6. 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. ↩︎

  7. RFC 5072 — IP Version 6 over PPP. IPV6CP und die 64-Bit-Interface-Identifier, unabhängig von IPCP. ↩︎

  8. RFC 1994 — PPP Challenge Handshake Authentication Protocol (CHAP). Beweist die Identität des Peers; verschlüsselt nichts. ↩︎

  9. 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. ↩︎

  10. Ncat Users’ Guide — connecting through a proxy — --proxy und --proxy-type für HTTP CONNECT und SOCKS 4/5, sodass der TLS-Träger am Proxy endet, nicht am fernen Ende des Tunnels. ↩︎

  11. Ncat Users’ Guide — die Optionen --ssl, --ssl-verify und --ssl-trustfile sowie die Listen- und UDP-Modi. Hier getestete Version: Ncat 7.92. ↩︎

  12. Universal TUN/TAP device driver — die eigene Dokumentation des Kernels für /dev/net/tun, TUNSETIFF und den Unterschied zwischen tun und tap. ↩︎

  13. PEP 784 — Adding Zstandard to the standard library, weshalb compression.zstd unter Python 3.14 kein Paket braucht. Hier gegen Python 3.14.7 und zstd 1.5.7 gemessen. ↩︎

  14. RFC 7457 — Summarizing Known Attacks on TLS and DTLS, das CRIME und die allgemeine Form eines Kompression-vor-Verschlüsselung-Längenlecks abdeckt. ↩︎

  15. 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. ↩︎