How to Tunnel Datagrams with MASQUE CONNECT-UDP Over HTTP/3
Tunneling UDP over legacy TCP introduces head-of-line blocking. When a packet drops on a lossy path, TCP stalls all multiplexed streams until that segment retransmits. RFC 9298 defines CONNECT-UDP, a mechanism that forwards UDP datagrams across HTTP/3. By running over QUIC DATAGRAM frames instead of streams, CONNECT-UDP transmits packets without retransmission coupling. This guide demonstrates how to configure and run an RFC 9298 tunnel over an HTTP/3 proxy.
Step 1 – Verify QUIC Datagram Negotiation on the Transport Layer
Before an HTTP/3 proxy can forward UDP traffic, both endpoints must negotiate RFC 9221 QUIC datagram support during the TLS 1.3 handshake. QUIC datagrams drop flow control and automatic retransmission, fitting real-time traffic like WireGuard, DNS, and WebRTC.
During connection establishment, the server advertises its maximum datagram size in the TLS EncryptedExtensions frame. Specifically, the server must provide a non-zero value for max_datagram_frame_size (parameter ID 0x20). If omitted, the proxy rejects datagram frames, forcing traffic onto stream capsules. Verify edge datagram support using tshark:
tshark -i eth0 -f "udp port 443" -Y "quic.transport_param.max_datagram_frame_size" -V
If the parameter shows a non-zero size, the QUIC transport permits direct datagram encapsulation. If empty, enable datagram flags in your QUIC server stack.
Step 2 – Configure the HTTP/3 Edge Proxy for CONNECT-UDP
The edge proxy requires an HTTP/3 listener configured for the extended CONNECT method and capsule protocol handling. Gateways like Envoy implement this via UDP proxy filters, while Go or Rust edge proxies implement connect-udp through libraries like quiche.
The proxy binds to UDP 443, loads a TLS 1.3 certificate, and maps incoming paths to target endpoints using URI templates. In standard MASQUE setups, paths follow RFC 9298: /.well-known/masque/udp/{target_host}/{target_port}/.
static_resources:
listeners:
- name: masque_h3
address:
socket_address: { address: 0.0.0.0, port_value: 443 }
filter_chains:
- transport_socket:
name: envoy.transport_sockets.quic
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: HTTP3
route_config:
name: masque_route
virtual_hosts:
- name: masque_backend
domains: ["*"]
routes:
- match: { prefix: "/.well-known/masque/udp/" }
route: { cluster: udp_egress }
When this route matches, the proxy parses the destination IP and port, validates firewall access rules, and allocates a local socket to relay payloads.
Step 3 – Dispatch the Extended CONNECT Request Header
Unlike standard HTTP requests, MASQUE relies on extended CONNECT semantics defined in RFC 8441. The client establishes the tunnel by sending a HEADERS frame containing pseudo-headers that declare the protocol and target endpoint.
The client request specifies :method = CONNECT, sets :protocol to connect-udp, and declares capsule support via capsule-protocol: ?1. The :scheme must be https, and :authority matches the proxy host.
:method = CONNECT
:protocol = connect-udp
:scheme = https
:authority = proxy.example.com
:path = /.well-known/masque/udp/192.0.2.1/51820/
capsule-protocol: ?1
RFC 9298 states: Once the CONNECT-UDP request succeeds, HTTP datagrams transmitted on the QUIC connection bypass stream flow control, ensuring real-time UDP packets remain unblocked by transport stalls.
Receiving HTTP 200 confirms the association. The control stream stays open for error signaling while data moves over QUIC datagrams.
Step 4 – Encode and Transmit UDP Packets in Datagram Frames
With the session established, the client encapsulates UDP packets into HTTP/3 Datagram frames. Under RFC 9298, every datagram sent over QUIC carries an integer prefix called the Context ID.
For direct UDP forwarding where a stream maps to one flow, the Context ID is zero (0x00). When reading packets from a local virtual interface like a Linux TUN device, prepend 0x00 before transmitting via the QUIC engine:
def frame_datagram(payload: bytes) -> bytes:
return b"\x00" + payload
def unframe_datagram(datagram: bytes) -> bytes:
if datagram[0] != 0:
raise ValueError("Invalid context ID")
return datagram[1:]
On reception, the proxy strips the zero byte and sends the raw payload to the target host. Return packets follow the reverse path: the proxy prepends 0x00 and pushes a QUIC DATAGRAM frame back to the client.
Step 5 – Clamp Tunnel MTU and Manage Path Blackholes
Encapsulating UDP inside QUIC datagrams introduces header overhead that reduces the path maximum transmission unit (MTU). A standard Ethernet MTU of 1500 bytes cannot fit an unfragmented 1500-byte inner packet once outer headers attach.
Outer overhead includes the IP header (20 to 40 bytes), the UDP header (8 bytes), the QUIC short header (up to 25 bytes), the TLS AEAD tag (16 bytes), and the Context ID byte. This consumes 70 to 90 bytes. Exceeding this budget causes intermediate routers to drop packets silently.
ip link set dev tun0 mtu 1350 up
sysctl -w net.ipv4.ip_no_pmtu_disc=0
Setting MTU to 1350 leaves headroom for encapsulation, preventing silent loss and fragmentation across Internet paths.
Next steps
With the tunnel running, add access controls to restrict target destination ranges. Enforce mutual TLS or bearer tokens during the CONNECT handshake to authenticate clients. Finally, track QUIC datagram drop counters and round-trip times in Prometheus to catch route congestion before packet loss affects applications.