Multiplexed Multi-Protocol Gatewaying: Brücken von TCP, UDP und QUIC über einen einzigen Ingress-Port
Erfahren Sie, wie multiplexed multi-protocol gatewaying TCP, UDP und QUIC über einen einzigen Ingress-Port ohne Port-Exhaustion verbindet.

Quick answer
Multiplexed Multi-Protocol Gatewaying: Brücken von TCP, UDP: quick comparison answer
Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.
Which tunnel tool is best for public webhook testing?
Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.
When should I choose a private network tool instead?
Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.
Cloud-Backends, Spielserver und Edge-Netzwerke möchten alle weniger exponierte Ports. Weniger Ports bedeuten einfachere Firewall-Regeln, weniger Load Balancer und weniger Sicherheitslücken in strengen Unternehmens- oder Carrier-Netzwerken. Ein einzelner öffentlicher Port wie 443 oder 8443 kann gRPC und HTTP/2 über TCP, HTTP/3 über QUIC sowie ein benutzerdefiniertes Echtzeit-UDP-Protokoll fronten.
Die Idee ist einfach zu formulieren und leicht, sich darin zu vertun. Dieser Leitfaden erklärt, was das Betriebssystem kostenlos bereitstellt, wie man TCP- und UDP-Verkehr sicher klassifiziert, wo eBPF und XDP helfen, wie QUIC-Verbindungs-IDs das Routing beeinflussen und was wirklich zu Port-Exhaustion führt. Am Ende steht ein kleines, getestetes Go-Gateway, auf dem Sie aufbauen können.
1. Was “ein einzelner Port” wirklich bedeutet
Eine Portnummer ist kein Namensraum. TCP-Port 443 und UDP-Port 443 sind getrennt, sodass ein Host auf beiden gleichzeitig lauschen kann. HTTP/3 funktioniert bereits so: Browser verbinden sich zuerst über TCP, dann erfahren sie vom Alt-Svc: h3=":443"-Antwortheader oder einem HTTPS-DNS-Eintrag (RFC 9460) vom HTTP/3-Endpunkt. Da dieser Endpunkt auf UDP 443 liegt, müssen Clients möglicherweise auf TCP zurückfallen, wenn das Netzwerk UDP 443 filtert.
„Ein Port“ bedeutet also eine Portnummer, mit drei Problemen, die darüber hinaus gelöst werden müssen:
- Transport-Split. TCP und UDP werden vom Kernel an unterschiedliche Sockets geliefert.
- TCP-Sub-Protokolle. TLS (und welches Anwendungsprotokoll es trägt), Klartext-HTTP/1.1, Klartext-HTTP/2 und benutzerdefinierte Byte-Streams kommen alle auf denselben Listener.
- UDP-Sub-Protokolle. QUIC und Ihr eigenes Datagram-Protokoll teilen sich einen UDP-Socket, und UDP bietet keinen Handshake, um sie zu unterscheiden.
┌───────────────────────────────────────────┐ │ Öffentliches Ingress, Port 443 │ └───────────┬─────────────────────┬─────────┘ │ TCP │ UDP ┌─────────▼─────────┐ ┌───────▼────────────────┐ │ Peek: TLS? PRI? │ │ Erster-Byte-Klassifikation │ │ HTTP-Verb? ALPN? │ │ (RFC 9443 Bereiche) │ └───┬─────┬─────┬───┘ └──────┬──────────┬──────┘ ▼ ▼ ▼ ▼ ▼ gRPC HTTP/1 benutzerdef. QUIC / benutzerdef. (h2) TCP HTTP/3 UDP (Spiel)
Wo das 4-Tupel nicht mehr ausreicht
Wenn eine Socket-Adresse mehrere Protokolle empfängt, liefert die Kernel-Lookup (Protokoll, Quelladresse und -port, Zieladresse und -port) nur das richtige Socket. Alles danach ist Aufgabe Ihres Klassifikators.
| Transport | Was Sie inspizieren können | Erkennungsmerkmal | Zustand |
|---|---|---|---|
| TCP (TLS, HTTP/2, gRPC) | Erste Bytes des Streams | 0x16 = TLS-Handshake-Record; ALPN im ClientHello |
Kernel-Verbindungsstatus |
| TCP (Klartext) | Erste Bytes des Streams | PRI * HTTP/2.0… (HTTP/2-Präambel) oder HTTP-Methode |
Kernel-Verbindungsstatus |
| UDP (QUIC) | Erstes Byte jedes Datagramms | Hoher Bit gesetzt = langer Header; niedrige Bits nach RFC 9443 | QUIC-Verbindungs-IDs, gehalten von Endpunkten |
| UDP (benutzerdefiniert) | Was Ihr Protokoll definiert | Ihre eigene Erst-Byte-Konvention | Ihre Entscheidung |
2. Die Socket-Schicht: Sie brauchen SO_REUSEPORT nicht
Eine gängige Behauptung ist, dass SO_REUSEPORT es ermöglicht, dass ein TCP- und ein UDP-Socket einen Port teilen. Das stimmt nicht. Linux verfolgt TCP- und UDP-Bindings in separaten Tabellen, sodass ein TCP-Listener und ein UDP-Socket 0.0.0.0:443 ohne spezielle Optionen binden können.
SO_REUSEPORT löst ein anderes Problem: Es erlaubt mehreren Sockets desselben Protokolls, dieselbe Adresse und Port zu binden, wobei der Kernel eingehende Verbindungen oder Datagramme unter ihnen aufteilt. Standardmäßig wird auf den 4-Tupel gehasht, und alle Sockets in der Gruppe müssen zum selben effektiven Benutzer gehören. So skalieren Sie einen Listener auf viele Threads oder Prozesse.
In Go ist die Standardbibliothek ausreichend:
ln, _ := net.Listen("tcp", "0.0.0.0:443") // TCP-Socket
pc, _ := net.ListenPacket("udp", "0.0.0.0:443") // UDP-Socket, gleicher Port
Verwenden Sie SO_REUSEPORT, wenn Sie pro-Core-Listener oder Zero-Downtime-Neustarts benötigen. Abschnitt 5 zeigt, wie ein eBPF-Programm das Standard-Hashing überschreiben kann.
3. Klassifikation von UDP: QUIC versus eigenes Protokoll
Das ist der schwierige Teil. Ein UDP-Datagramm kommt ohne Handshake an, also ist das erste Byte alles, was Sie haben.
Was QUIC garantiert (und was nicht)
RFC 8999 definiert nur die Eigenschaften, die bei jeder QUIC-Version gleich bleiben:
- Das bedeutendste Bit des ersten Bytes ist die Header-Form.
1bedeutet langer Header,0bedeutet kurzer Header. - Lange Header tragen ein 32-Bit-Version-Feld (
0x00000000ist für Version Negotiation reserviert), gefolgt von Längen-präfixierten Destination- und Source-Connection-IDs. - Kurze Header tragen nur die Destination Connection ID, ohne Längenfeld, sodass ein Gateway wissen muss, wie lang die Connection-IDs sein sollen.
Das Fixed Bit (0x40) ist kein invariantes Merkmal. RFC 8999 listet auf, dass “QUIC-Pakete immer das zweite Bit des ersten Bytes (0x40) gesetzt haben” – eine Annahme, die für eine bestimmte Version falsch sein kann. In QUIC Version 1 ist das Bit gesetzt, aber RFC 9287 erlaubt Endpunkten, das grease_quic_bit-Transport-Parameter zu verhandeln und es zu randomisieren.
Ebenso erfordert QUIC Version 1 eine Mindestgröße für Client-Initial-Datagramme (1200 Bytes, RFC 9000 Abschnitt 14.1), was ebenfalls eine Version-1-Eigenschaft ist und kein invariantes Merkmal. Ein Paket mit kurzem Header hat keine sinnvolle “mindestens 3 Bytes”-Regel, auf der man einen Klassifikator aufbauen könnte.
Das Standard-Multiplexing-Schema: RFC 9443
RFC 9443 (das RFC 7983 aktualisiert) beschreibt, wie man STUN, ZRTP, DTLS, TURN-Kanäle, RTP/RTCP und QUIC auf einem UDP-Socket anhand des ersten Bytes demultiplexen kann:
| Erstes Byte | Protokoll |
|---|---|
| 0–3 | STUN |
| 4–15 | Drop |
| 16–19 | ZRTP |
| 20–63 | DTLS |
| 64–79 | TURN-Kanal, wenn vom bekannten TURN-Server, sonst QUIC |
| 80–127 | QUIC (kurzer Header, festes Bit gesetzt) |
| 128–191 | RTP / RTCP |
| 192–255 | QUIC (langer Header, festes Bit gesetzt) |
Zwei Konsequenzen sind für ein Gateway wichtig:
- Nicht klassifizieren mit “langer Header ODER festem Bit gesetzt.” Diese Prüfung, die in Skizzen üblich ist, trifft ungefähr drei Viertel aller möglichen ersten Bytes. Jeder benutzerdefinierte Protokoll, dessen erstes Byte in
0x40–0xFFliegt, würde falsch geroutet. RFC 9443 warnt selbst, dass eine fehlerhafte Klassifikation “Pakete möglicherweise falsch klassifizieren könnte.” - Endpunkte hinter einem Multiplexer dürfen
grease_quic_bitnicht senden, weil das Greasing das erste-Byte-Schema (RFC 9443, Abschnitt 1) bricht.
Das benutzerdefinierte Protokoll so gestalten, dass es koexistieren kann
Wenn Sie das Spielprotokoll kontrollieren, wählen Sie ein erstes Byte (z. B. eine Versions- oder Nachrichtenart-Feld), das in einem Bereich liegt, den QUIC nicht belegt, z. B. 0–63, oder 128–191, wenn Sie kein RTP transportieren. Wenn Sie das Protokoll nicht ändern können, müssen Sie akzeptieren, dass die erste-Byte-Klassifikation eine Heuristik ist, eine stärkere Prüfung im Long-Header-Pfad hinzufügen (das Version-Feld ist 4 Bytes bei Offset 1, gültige Werte sind 0x00000001 für QUIC v1 und 0x6b3343cf für QUIC v2, RFC 9369), und alles Ambivalente vorsichtig behandeln.
Ein korrigiertes Entscheidungsverfahren
Datagramm empfangen
│
▼
Byte0 in 64..127 oder 192..255 ? ──nein──► benutzerdefiniertes UDP-Protokoll
│ja
▼
Langer Header (Byte0 & 0x80)?
├─ ja ► Version prüfen (v1, v2, oder 0 für Verhandlung) ► QUIC
└─ nein ► kurzer Header: DCID im Connection-Table nachschlagen
├─ Treffer ► dasselbe Backend wie die Verbindung
└─ kein Treffer ► verwerfen oder an QUIC-Backend senden (kann zustandslosen Reset auslösen)
Der kurze-Header-Zweig ist der kritische. Ein Gateway, das nur kurze-Header-Pakete routen kann, wenn es entweder Verbindungen nach Connection-IDs (zustandsbehaftet) merkt oder die IDs die Routing-Entscheidung kodieren (siehe Abschnitt 5). Ohne eine dieser Methoden kann nur nach Client-Adresse geroutet werden, was bei Adresswechsel problematisch ist.
4. Klassifikation von TCP: ALPN, Präfixe und verschlüsseltes Client Hello
Für TCP schaut das Gateway auf die ersten Bytes des Streams, ohne sie zu konsumieren (recv(..., MSG_PEEK) in C, bufio.Reader.Peek in Go), und leitet dann alles weiter, inklusive der geppeekten Bytes.
| Erste Bytes | Bedeutung | Route |
|---|---|---|
0x16 + TLS-Record-Header |
TLS ClientHello | ALPN (siehe unten) parsen |
PRI (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) |
Klartext-HTTP/2, “Prior Knowledge” (h2c), von gRPC ohne TLS genutzt | HTTP/2 / gRPC Backend |
GET , POST, HEAD, … |
Klartext-HTTP/1.1 | HTTP-Backend |
| alles andere | Benutzerdefiniertes Protokoll | Fallback-Backend |
ALPN-Routing. Bei TLS 1.3 ist der ClientHello unverschlüsselt und enthält die ALPN-Erweiterung (Typ 16), die die vom Client angebotenen Anwendungsprotokolle auflistet (h2, http/1.1, oder benutzerdefinierte Tokens wie game-v1). Das Gateway liest die TLS-Record-Länge aus dem 5-Byte-Header, schaut sich den ganzen Record an, durchläuft die Erweiterungen und entscheidet anhand von ALPN, ohne TLS zu beenden. Die Go-Implementierung in Abschnitt 7 macht genau das und wurde gegen echte crypto/tls ClientHellos getestet.
Der ECH-Hinweis. RFC 9849 standardisiert den verschlüsselten ClientHello. Mit ECH verschlüsselt der Client die sensiblen Inhalte des ClientHello, was SNI schützt und auch ALPN, im inneren ClientHello. Das äußere ClientHello trägt harmlose Werte. Ein Gateway, das nur durchreicht, sieht also nur, was der Client im äußeren Hello offenlegt. Mit zunehmender ECH-Verbreitung wird das Routing anhand von ALPN weniger zuverlässig, und die Optionen sind, TLS am Gateway zu terminieren oder anhand des äußeren Namens zu routen. Planen Sie das, anstatt zu glauben, ALPN sei immer sichtbar.
5. Kernel-Ebene Demultiplexing: eBPF, XDP und Socket-Steering
Peek in Benutzerspace kostet einen Systemaufruf pro Entscheidung und gibt dem Kernel keinen Hinweis, wohin es liefern soll. Zwei eBPF-Mechanismen lösen dieses Problem, und sie adressieren unterschiedliche Aufgaben.
Option A: SK_REUSEPORT-Programme (Routing zum richtigen Socket)
Ein BPF_PROG_TYPE_SK_REUSEPORT-Programm ersetzt die Standard-4-Tupel-Hashfunktion beim Auswählen eines Sockets aus einer SO_REUSEPORT-Gruppe. Für UDP beschreibt der Kernel-Patch, der es eingeführt hat, den Anwendungsfall direkt: Das Programm kann in die Datagrammpayload schauen, um Anwendungs-Connection-Informationen zu ermitteln, und dann das Socket aus einer BPF-Map wählen. Das hält Pakete im normalen Kernel-Stack und ermöglicht, eine QUIC-Verbindung anhand der Connection-ID einem bestimmten Worker zuzuordnen, auch wenn sich die Client-Adresse ändert. Anbindung erfolgt mit setsockopt und SO_ATTACH_REUSEPORT_EBPF (benötigt CAP_SYS_ADMIN).
Option B: XDP (vor der Netzwerkschicht)
XDP läuft auf Treiber-Ebene, sobald die NIC ein Paket liefert, noch vor der Zuweisung des üblichen Socket-Puffers durch den Kernel. Meta’s Open-Source-Layer-4-Load-Balancer Katran basiert darauf und beschreibt, dass es “direkt nach Empfang durch die Netzwerkschnittstelle … vor dem Kernel” läuft, im Treibermodus, mit einem langsameren Fallback-Modus. XDP kann Pakete verwerfen, weiterleiten, an eine andere Schnittstelle umleiten oder in einen AF_XDP-Socket umleiten.
AF_XDP ist für das Design wichtig, weil das Umleiten in dieses den Kernel-Netzwerkstack umgeht: Das Paket landet in einem Benutzerspeicherbereich, und Sie sind für alles verantwortlich, was der Stack sonst tun würde (UDP-Checksums, ARP, ICMP, Socket-Demultiplexing). Die Kernel-Dokumentation beschreibt auch mehrere Verhaltensweisen, die Skizzen oft falsch implementieren:
- Ein
XSKMAPist nach Index gekennzeichnet, und eine Weiterleitung funktioniert nur, wenn das Socket an diesem Index an die gleiche Schnittstelle und Empfangsqueue gebunden ist, auf der das Paket angekommen ist. Andernfalls wird das Paket verworfen. - Wenn die Map an diesem Index leer ist, wird das Paket ebenfalls verworfen.
- Das sichere Muster ist, zuerst die Queue-Index zu ermitteln und
XDP_PASSzu verwenden, wenn kein AF_XDP-Socket gebunden ist. - Wenn Sie nur bestimmten Traffic in den Benutzerspeicher leiten wollen, steuern Sie ihn mit NIC-Flow-Regeln (
ethtool -N … action <queue>).
Hier ein korrigiertes XDP-Programm, das nur QUIC-Bereich-Datagramme auf UDP-Port 443 an einen AF_XDP-Socket sendet und alles andere (TCP, benutzerdefiniertes UDP, ARP, ICMP) durch den Kernel-Stack weiterleitet. Es kompiliert mit clang -target bpf; ich habe es noch nicht auf einer NIC getestet.
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/in.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#define INGRESS_PORT 443
struct {
__uint(type, BPF_MAP_TYPE_XSKMAP);
__uint(max_entries, 64);
__type(key, __u32);
__type(value, __u32);
} xsks_map SEC(".maps");
SEC("xdp")
int xdp_quic_steer(struct xdp_md *ctx)
{
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != bpf_htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end || iph->protocol != IPPROTO_UDP)
return XDP_PASS;
struct udphdr *udph = (void *)iph + iph->ihl * 4;
if ((void *)(udph + 1) > data_end || udph->dest != bpf_htons(INGRESS_PORT))
return XDP_PASS;
unsigned char *payload = (void *)(udph + 1);
if ((void *)(payload + 1) > data_end)
return XDP_PASS;
/* RFC 9443 Abschnitt 3: erstes Byte 64..127 oder 192..255 ist QUIC
* (64..79 ist nur bei Traffic von einem TURN-Server ambivalent; nicht behandelt). */
unsigned char b0 = *payload;
if (!((b0 >= 64 && b0 <= 127) || b0 >= 192))
return XDP_PASS; /* benutzerdefiniertes UDP: im Kernel-Stack lassen */
__u32 q = ctx->rx_queue_index;
if (bpf_map_lookup_elem(&xsks_map, &q))
return bpf_redirect_map(&xsks_map, q, 0);
return XDP_PASS; /* kein AF_XDP-Socket auf dieser Queue */
}
char _license[] SEC("license") = "GPL";
Das Programm verarbeitet IPv4 ohne VLAN-Tags oder IP-Fragmentierung. Für Produktion benötigen Sie IPv6- und VLAN-Unterstützung sowie eine Entscheidung zu Fragmenten.
Routing von QUIC anhand der Connection-ID über Hosts
Im großen Maßstab stellt sich die Frage, wie man kurze Header-Pakete ohne pro-Flow-Status routet. Zwei echte Ansätze existieren:
- Server-ID-Routing. Meta beschrieb 2021 auf der Linux Plumbers Conference ein zustandsloses Schema, bei dem eine Server-ID in den Identifikatoren codiert ist, sodass der XDP-Load-Balancer sowohl TCP als auch QUIC ohne gemeinsamen Zustand routen kann.
- QUIC-LB. Das Draft “QUIC-LB: Generating Routable QUIC Connection IDs” der QUIC-Arbeitsgruppe beschreibt eine standardisierte Methode, Routing-Informationen in server-gewählten Connection-IDs zu kodieren. Beachten Sie den Status: Es ist ein abgelaufenes Internet-Draft (letzte Revision 21, August 2025), kein RFC. Es ist eine Design-Referenz, kein Standard.
6. Mehrere Protokolle durch eine Edge-Verbindung tunneln
Ein Edge-Tunnel (eine persistente ausgehende Verbindung von einem privaten Netzwerk zu einem öffentlichen Edge) kann alle drei Protokollklassen über eine einzelne Sitzung tragen, sodass auf der privaten Seite keine eingehenden Ports erforderlich sind.
ÖFFENTLICHER EDGE (Port 443)
TCP-Streams ──┐
QUIC-Datagramme ┼──► Klassifikator ──► Tunnel-Multiplexer ══ eine äußere Sitzung ══►
UDP-Datagramme ┘ │
▼
PRIVATE SEITE: Demux nach Kanal ► gRPC :50051
► HTTP/3 :9001
► Spiel :7777
Verwenden Sie das richtige Primitive für jeden Traffic-Typ
- TCP und andere Byte-Streams lassen sich natürlich auf Streams abbilden (ein multiplexierter Stream pro akzeptierter Verbindung). Beschreiben Sie sie nicht als “Segmente”; der Tunnel trägt einen Byte-Stream, keine TCP-Segmente.
- UDP und QUIC sind Datagramme. Sie in einem zuverlässigen, geordneten Stream zu schicken, führt zu Head-of-Line-Blocking und Verzögerungen bei Retransmissionen, die Echtzeitverkehr vermeiden soll. Stattdessen transportieren Sie sie in unzuverlässigen Datagrammen. Bestehende Standards decken das ab: QUIC DATAGRAM-Frames (RFC 9221), HTTP-Datagramme (RFC 9297) und CONNECT-UDP (RFC 9298), das UDP über HTTP/2 oder HTTP/3 proxyt und QUIC-Datagramme bei Betrieb über HTTP/3 nutzt. Wenn Sie eines dieser verwenden können, profitieren Sie von interoperabler Rahmenbildung und Proxy-Unterstützung.
- WireGuard ist ein Layer-3-Tunnel, der IP-Pakete über UDP trägt, also als Transport für Rohpakete geeignet ist, nicht als “Stream”-Substrat.
Ein beispielhafter benutzerdefinierter Frame
Wenn Sie Ihr eigenes Framing bauen, halten Sie es klein und explizit. Dieses Layout ist ein Beispiel, kein Standard:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Magic (0xA5) | Protocol Type | Channel ID |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Session Token |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Payload Length | Payload Data ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Feld | Größe | Zweck |
|---|---|---|
| Magic | 8 Bit | Rahmen-Grenz-Sanity-Check (0xA5) |
| Protocol Type | 8 Bit | 0x01 Stream-Daten, 0x02 UDP-Datagram, 0x03 QUIC-Datagram |
| Channel ID | 16 Bit | Welcher Backend-Service oder Mandant |
| Session Token | 32 Bit | Client-spezifischer Schlüssel für Statussuche |
| Payload Length | 16 Bit | Bis zu 65.535 Bytes, ausreichend für das größte UDP-Payload |
Ein 32-Bit-Token ist ein Lookup-Schlüssel, kein Credential. Alles, was einen Tunnel-Client authentifiziert, muss aus einem echten Handshake stammen (TLS, signiertes Token oder WireGuard-Schlüssel), niemals aus einer erratbaren Zählung.
7. Port-Exhaustion, Sitzungsstatus und was wirklich begrenzt
“Port-Exhaustion” wird für vieles verantwortlich gemacht. Hier ist, was tatsächlich gilt.
Das ephemeral port limit ist pro Ziel. Unter Linux liegt der Standard-Ausgangsbereich bei 32768–60999, etwa 28.232 Ports. Eine Verbindung wird durch das 4-Tupel identifiziert, also kann die gleiche Quell-IP und -Port für Verbindungen zu verschiedenen Zielen wiederverwendet werden. Das praktische Limit scheint erreicht, wenn ein Gateway viele Verbindungen von einer Quell-IP zu einer einzigen Backend-IP und -Port öffnet: Das liegt in der Nähe der Range-Größe.
Grobe Gegenmaßnahmen:
- Multiplexen statt eines Sockets pro Client. Mehrere Client-Streams über wenige langlebige Backend-Verbindungen (HTTP/2 oder eigenes Framing). Cloudflares Beitrag zu ephemeral ports zeigt dasselbe: langlebige, multiplexte Verbindungen sind besser als Verbindungs-Churn.
- Auf mehr 4-Tupel verteilen. Mehrere Backend-Adressen oder Ports, oder mehrere Quell-IPs.
- Range erweitern oder partitionieren. Range erhöhen mit
net.ipv4.ip_local_port_range; neuere Kernel bieten auch eineIP_LOCAL_PORT_RANGE-Option pro Socket, um den Bereich aufzuteilen.
Sitzungstabellen. Für verbindungslose Traffic braucht das Gateway eine Tabelle, die einen Client (oder eine Connection-ID) auf seinen Upstream-Pfad abbildet, mit einem Idle-Timeout, damit Einträge verfallen. Begrenzen Sie sie und räumen Sie regelmäßig auf; eine unbeschränkte Tabelle ist ein Risiko für Denial-of-Service.
TUN-Geräte. Das Einspeisen roher IP-Pakete über eine TUN-Schnittstelle (/dev/net/tun) leitet auf Layer 3 weiter, sodass Ihr Prozess keine pro-Client-ephemeren Ports benötigt. Das ist nicht dasselbe wie “OS-Port-Limits umgehen”: Wenn der Kernel NAT oder Masquerading durchführt, um den Backend zu erreichen, belegt NAT weiterhin Quell-Ports. Verwenden Sie TUN, wenn Sie End-zu-End-Routing ohne NAT realisieren können.
QUIC-Verbindungsmigration. QUIC trennt eine Verbindung vom IP und Port des Clients mittels Connection-IDs (RFC 9000, Abschnitt 5.1). Zwei Details beeinflussen das Verhalten eines Gateways:
- Ein migrierender Client wechselt zu einer neuen Connection-ID, nicht der gleiche auf beiden Pfaden. RFC 9000 verlangt, dass Connection-IDs nicht durch Beobachter verknüpfbar sind, und empfiehlt, dass Endpunkte jede ID auf ein Adresspaar beschränken (Abschnitte 5.1 und 9.5). Ein Gateway, das “diese CID auf dieses Backend abbildet”, verliert die Verbindung, wenn der Client das Netzwerk wechselt.
- Ein Durchleitungs-Gateway kann die neuen IDs nicht lesen, weil
NEW_CONNECTION_ID-Frames innerhalb verschlüsselter Pakete reisen. Die Lösung ist, dass der Server routbare IDs ausgibt (Server-ID-Routing oder QUIC-LB, Abschnitt 5), oder das Gateway QUIC selbst beendet. RFC 9000 Abschnitt 5.2.3 beschreibt den Fallback für einfache adressbasierte Load-Balancer: Server können dasdisable_active_migration-Transport-Parameter senden oderpreferred_addressverwenden, um Clients auf eine dedizierte Adresse zu verschieben.
Beachten Sie auch, dass null-länge Connection-IDs hinter einer gemeinsamen Adresse fragil sind: RFC 9000 verbietet, eine IP und Port für mehrere gleichzeitige Zero-Length-CID-Verbindungen zu verwenden, es sei denn, Migration und NAT-Rebinding sind ausgeschlossen.
8. Ein getesteter Referenz-Gateway in Go
Das folgende Programm implementiert das oben beschriebene Design nur mit der Standardbibliothek:
- Es lauscht auf einem Port für TCP und UDP.
- Für TCP peekt es die ersten Bytes, parst den TLS ClientHello, liest ALPN (
h2oderhttp/1.1), erkennt die Klartext-HTTP/2-Präambel und HTTP-Methoden, und leitet alles andere an ein Fallback-Backend weiter. Geppeekte Bytes werden an das Backend wiedergegeben. - Für UDP klassifiziert es jedes Datagramm anhand der RFC 9443-Bereiche und hält für jeden Client und Backend ein Upstream-Socket mit Idle-Timeout, um Antworten zurückzuleiten.
Ich habe es kompiliert, go vet ausgeführt und gegen lokale Echo-Backends getestet. Die Tests decken Klartext-Routing (PRI, GET, unbekannte Bytes), UDP-Routing über die Range-Grenzen (0xC0, 0x45 bis QUIC; 0x05, 0x3F, 0x90 zum benutzerdefinierten Backend) und den ALPN-Parser gegen echte crypto/tls ClientHellos (h2, http/1.1, ein unbekanntes Protokoll und kein ALPN) ab.
// Single-port TCP+UDP-Gateway: TCP geroutet nach TLS ALPN / HTTP-Präambel,
// UDP nach RFC 9443 erster-Byte-Bereichen. Nur Standardbibliothek.
package main
import (
"bufio"
"context"
"io"
"log"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Config struct {
Listen string
H2Backend string // gRPC / HTTP/2
HTTPBackend string // HTTP/1.1
TCPFallback string // alles andere (benutzerdef. TCP-Protokolle)
QUICBackend string // UDP, QUIC / HTTP/3
GameBackend string // UDP, benutzerdef. Protokoll
UDPIdle time.Duration
}
func main() {
cfg := Config{
Listen: "0.0.0.0:8443", H2Backend: "127.0.0.1:50051", HTTPBackend: "127.0.0.1:8080",
TCPFallback: "127.0.0.1:9000", QUICBackend: "127.0.0.1:9001", GameBackend: "127.0.0.1:7777",
UDPIdle: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go serveTCP(ctx, cfg)
go serveUDP(ctx, cfg)
<-ctx.Done()
}
// ---------- TCP ----------
func serveTCP(ctx context.Context, cfg Config) {
ln, err := net.Listen("tcp", cfg.Listen)
if err != nil {
log.Fatalf("tcp listen: %v", err)
}
go func() { <-ctx.Done(); ln.Close() }()
for {
c, err := ln.Accept()
if err != nil {
return
}
go handleTCP(c, cfg)
}
}
func handleTCP(c net.Conn, cfg Config) {
defer c.Close()
br := bufio.NewReaderSize(c, 16*1024+5)
c.SetReadDeadline(time.Now().Add(5 * time.Second)) // Sniff-Deadline
backend := cfg.TCPFallback
if hdr, err := br.Peek(5); err == nil {
switch {
case hdr[0] == 0x16: // TLS-Handshake-Record
recLen := int(hdr[3])<<8 | int(hdr[4])
if rec, err := br.Peek(5 + recLen); err == nil {
switch alpnPick(rec[5:]) {
case "h2":
backend = cfg.H2Backend
case "http/1.1":
backend = cfg.HTTPBackend
}
}
case string(hdr[:4]) == "PRI ": // Klartext-HTTP/2-Präambel
backend = cfg.H2Backend
case isHTTPVerb(hdr):
backend = cfg.HTTPBackend
}
}
c.SetReadDeadline(time.Time{})
up, err := net.DialTimeout("tcp", backend, 3*time.Second)
if err != nil {
log.Printf("dial %s: %v", backend, err)
return
}
defer up.Close()
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); io.Copy(up, br); closeWrite(up) }() // Snippet: Bytes an Backend
go func() { defer wg.Done(); io.Copy(c, up); closeWrite(c) }()
wg.Wait()
}
func closeWrite(c net.Conn) {
if cw, ok := c.(interface{ CloseWrite() error }); ok {
cw.CloseWrite()
}
}
func isHTTPVerb(b []byte) bool {
for _, v := range []string{"GET ", "POST", "HEAD", "PUT ", "DELE", "OPTI", "PATC"} {
if string(b[:4]) == v {
return true
}
}
return false
}
// alpnPick gibt das erste ALPN-Protokoll zurück, das der Client bevorzugt, oder "" wenn der ClientHello kein ALPN enthält (oder nicht geparst werden kann).
func alpnPick(rec []byte) string {
p := rec
adv := func(n int) []byte {
if len(p) < n {
p = nil
return nil
}
out := p[:n]
p = p[n:]
return out
}
if h := adv(4); h == nil || h[0] != 0x01 { // Handshake-Typ ClientHello + 3-Byte-Länge
return ""
}
adv(2 + 32) // legacy_version + random
if l := adv(1); l != nil {
adv(int(l[0])) // Session-ID
}
if l := adv(2); l != nil {
adv(int(l[0])<<8 | int(l[1])) // Cipher Suites
}
if l := adv(1); l != nil {
adv(int(l[0])) // Kompressionsmethoden
}
l := adv(2)
if l == nil {
return ""
}
exts := adv(int(l[0])<<8 | int(l[1]))
for len(exts) >= 4 {
typ := int(exts[0])<<8 | int(exts[1])
n := int(exts[2])<<8 | int(exts[3])
if len(exts) < 4+n {
return ""
}
body := exts[4 : 4+n]
exts = exts[4+n:]
if typ != 16 || len(body) < 2 { // application_layer_protocol_negotiation
continue
}
list := body[2:]
for len(list) > 0 {
k := int(list[0])
if len(list) < 1+k {
return ""
}
switch s := string(list[1 : 1+k]); s {
case "h2", "http/1.1":
return s
}
list = list[1+k:]
}
}
return ""
}
// ---------- UDP ----------
// classifyUDP folgt RFC 9443 Abschnitt 3: erstes Byte 80..127 oder 192..255 ist QUIC;
// 64..79 ist QUIC, außer es kommt von einem TURN-Server (nicht behandelt).
// Halten Sie das erste Byte Ihres eigenen Protokolls in 0..63 oder 128..191.
func classifyUDP(first byte) (isQUIC bool) {
return (first >= 64 && first <= 127) || first >= 192
}
type udpSession struct {
up *net.UDPConn
seen time.Time
}
func serveUDP(ctx context.Context, cfg Config) {
pc, err := net.ListenPacket("udp", cfg.Listen)
if err != nil {
log.Fatalf("udp listen: %v", err)
}
conn := pc.(*net.UDPConn)
go func() { <-ctx.Done(); conn.Close() }()
quicAddr, _ := net.ResolveUDPAddr("udp", cfg.QUICBackend)
gameAddr, _ := net.ResolveUDPAddr("udp", cfg.GameBackend)
var mu sync.Mutex
sessions := map[string]*udpSession{} // Schlüssel: Clientadresse + Backend
// Idle-Reaper
go func() {
t := time.NewTicker(cfg.UDPIdle / 4)
defer t.Stop()
for range t.C {
mu.Lock()
for k, s := range sessions {
if time.Since(s.seen) > cfg.UDPIdle {
s.up.Close()
delete(sessions, k)
}
}
mu.Unlock()
}
}()
buf := make([]byte, 65535)
for {
n, client, err := conn.ReadFromUDP(buf)
if err != nil {
return
}
if n == 0 {
continue
}
target := gameAddr
if classifyUDP(buf[0]) {
target = quicAddr
}
key := client.String() + ">" + target.String()
mu.Lock()
s := sessions[key]
if s == nil {
up, err := net.DialUDP("udp", nil, target)
if err != nil {
mu.Unlock()
continue
}
s = &udpSession{up: up}
sessions[key] = s
// Backend -e4udfer Client
go func(up *net.UDPConn, client *net.UDPAddr) {
b := make([]byte, 65535)
for {
m, err := up.Read(b)
if err != nil {
return
}
conn.WriteToUDP(b[:m], client)
}
}(up, client)
}
s.seen = time.Now()
mu.Unlock()
s.up.Write(buf[:n])
}
}
Was dieses Gateway absichtlich nicht tut
Es ist eine Grundlage, kein fertiges Produkt. Bevor Sie es ins Internet stellen, planen Sie Folgendes:
- Client-Address-Preservation. Backends sehen die Adresse des Gateways. Verwenden Sie das PROXY-Protokoll für TCP oder übertragen Sie die Client-Adresse im Tunnel-Frame.
- QUIC-Connection-ID-Bewusstsein. Sitzungen sind nach Client-Adresse keying, was bei Netzwerkwechseln des Clients eine neue Upstream-Socket bedeutet. Kombinieren Sie es mit serverseitig ausgegebenen routbaren Connection-IDs (Abschnitt 5), wenn Migration überleben soll.
- Ein Upstream-Socket pro UDP-Client. Das ist einfach und korrekt, aber jeder Socket verwendet einen Ephemeral-Port, der die Grenzen in Abschnitt 7 übernimmt. Begrenzen Sie die Sitzungszahl und erwägen Sie die multiplexed-Designs.
- Missbrauchs- und Schutzmaßnahmen. Es gibt keine Ratenbegrenzungen, Verbindungsbegrenzungen oder Schutz vor Verstärkung. QUIC-Server müssen einschränken, was sie an eine unvalidierte Adresse senden (RFC 9000, Abschnitt 8), und ein Gateway, das Antworten an gefälschte Quellen weiterleitet, kann missbraucht werden.
- Slowloris-ähnliches Sniffing. Die 5-Sekunden-Deadline begrenzt Idle-Verbindungen, aber Sie sollten auch gleichzeitige unklassifizierte Verbindungen begrenzen.
- Fehlerhaftes Verhalten bei unbekannten oder fehlerhaften Bytes. Derzeit fallen sie durch an das Fallback-Backend. Entscheiden Sie, ob Sie sie verwerfen oder anders behandeln.
9. Nutzen messen, statt nur anzunehmen
Das Konsolidieren von Ports ist vor allem ein operativer Gewinn: Weniger Firewall-Regeln, weniger Listener, ein Ort zur Durchsetzung von Richtlinien. Leistungsgewinne sind spezifisch für die Arbeitslast, messen Sie also vorher. Veröffentlicht oder erfunden Vorher-Nachher-Zahlen für “Single-Port versus Multi-Port” übertragen sich nicht zwischen Umgebungen, und die wichtigsten Mechanismen, die genannt werden, brauchen Sorgfalt:
- Handshake-Latenz. Ein einzelner Port verkürzt keinen TLS- oder QUIC-Handshake, und ALPN ist im ClientHello enthalten, was keine Round-Trip-Zeit kostet. QUIC 0-RTT gilt nur für wiederaufgenommene Verbindungen, und RFC 9000 warnt, dass es keinen Schutz gegen Replay-Angriffe bietet, also ist es kein allgemeiner Latenzgewinn und nicht für nicht-idempotente Anfragen geeignet.
- Kosten pro Paket. Klassifikation im Benutzerspeicher kostet einen Systemaufruf pro Datagramm. Kernel-seitige Klassifikation (
SK_REUSEPORToder XDP) entfernt diese Kosten für den Traffic, den sie verarbeitet. Batch-Lesen (recvmmsg, UDP GRO und GSO bei unterstützten Kernel) verringert es weiter ohne eBPF. XDP mit AF_XDP tauscht Kernel-Stack-Features gegen Geschwindigkeit, lohnt sich bei hohen Paketraten, nicht bei typischen Spielserver-Lasten. - Was messen? Verbindungsaufbauzeit bei p50/p99, Pakete pro Sekunde pro Kern bei festem Verlustziel, CPU pro Gbit/s, verwendete Sockets und ephemeral ports (
ss -s), und Drops bei jedem Schritt (NIC, Socket-Puffer, Session-Tabellen). Testen Sie mit Ihrer echten Client-Mischung, inklusive QUIC-Clients, die migrieren, und Clients hinter Carrier-NAT.
10. Implementierungs-Checkliste
- Sockets. TCP und UDP an denselben Port binden, mit normalen Listeners;
SO_REUSEPORTnur für pro-Core-Scaling eines Protokolls. - UDP-Klassifikation. Verwenden Sie die RFC 9443 erster-Byte-Bereiche, niemals “langer Header oder festes Bit”. Halten Sie das erste Byte Ihres eigenen Protokolls außerhalb der QUIC-Bereiche, und aktivieren Sie
grease_quic_bitnicht bei Endpunkten hinter einem erster-Byte-Multiplexer. - QUIC-Routing. Entscheiden Sie im Voraus, wie kurze Header ihre Backends finden: serverseitig routbare Connection-IDs (Server-ID-Routing, QUIC-LB), eine Connection-Tabelle oder QUIC am Gateway beenden. Ohne eine dieser Methoden bricht Migration.
- TCP-Klassifikation. Peek, nicht konsumieren; ALPN aus dem ClientHello parsen; den
PRI-Präfix und HTTP-Methoden behandeln; einen Plan für ECH haben. - Kernel-Beschleunigung. Zuerst
SK_REUSEPORTeBPF verwenden (es hält den normalen Stack), XDP und AF_XDP nur bei Bedarf für Geschwindigkeit und wenn Sie den User-Space-Stack kontrollieren. - Tunnels. Streams als Streams, Datagramme als Datagramme (RFC 9221, RFC 9297, RFC 9298), und Tunnel-Clients richtig authentifizieren.
- Port-Budgets. Ephemeral Ports pro Backend 4-Tupel zählen, nicht pro Host, und vor der Erweiterung der Range multiplexen.
- Absicherung. Begrenzen Sie jede Tabelle, setzen Sie Timeouts für jede Sitzung, raten Sie unvalidierte UDP-Quellen, und entscheiden Sie, was mit nicht klassifizierbarem Traffic passiert.
Referenzen
- RFC 8999: Version-Independent Properties of QUIC
- RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport
- RFC 9443: Multiplexing Scheme Updates for QUIC
- RFC 9849: TLS Encrypted Client Hello
- RFC 9298: Proxying UDP in HTTP
- AF_XDP, Die Linux Kernel Dokumentation
- BPF_PROG_TYPE_SK_REUSEPORT, eBPF-Dokumentation
- QUIC-LB: Generating Routable QUIC Connection IDs (Internet-Draft)
- Katran, Meta’s XDP-basierten Layer-4-Load-Balancer
- Von XDP zu Socket, Linux Plumbers Conference 2021
- Cloudflare: Wie man auf ephemere Ports nicht mehr läuft
- Peakhour: Wie Browser entscheiden, HTTP/3 zu verwenden
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.