Repository navigation
protocol
Last updated: 2026-10-01 - Content re-verified against v1.6.14 (TCP framing on the client->proxy leg, session_token stamping, QUIC control plane on UDP 4433, data on UDP 4434 - all confirmed in the source). Originally written for v1.6.5.
The LightSpeed Tunnel Protocol is a lightweight UDP encapsulation protocol designed to route game traffic through proxy nodes with minimal overhead. It is:
- Unencrypted - game traffic remains inspectable (anti-cheat friendly)
- IP-preserving - original source/destination IPs carried in header
- Low overhead - 20 bytes added per packet (v1), 24 bytes with FEC (v2)
- Sequence-numbered - supports dedup for multipath routing
- FEC-capable - optional Forward Error Correction for packet loss recovery (v2)
- TCP-transportable - the same packet can travel the client→proxy leg over TCP (length-prefixed framing) for UDP-restricted networks
| Version | Header Size | Features | Status |
|---|---|---|---|
| v1 | 20 bytes | Plain tunneling, keepalive, handshake, FIN | ✅ Production |
| v2 | 20 + 4 = 24 bytes | v1 + FEC header extension | ✅ Production |
The version field (4 bits) in byte 0 determines which header format is used.
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Ver | Flags | Session Token | Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Timestamp (μs) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Original Source IP |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Original Dest IP |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Orig Source Port | Orig Dest Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Field | Bits | Bytes | Description |
|---|---|---|---|
| Version | 4 | 0[7:4] | Protocol version (1 = plain, 2 = FEC) |
| Flags | 4 | 0[3:0] | Packet type flags (see below) |
| Session Token | 8 | 1 | Session token byte (used for auth) |
| Sequence | 16 | 2-3 | Monotonically increasing packet sequence number |
| Timestamp | 32 | 4-7 | Microsecond timestamp for latency measurement |
| Orig Src IP | 32 | 8-11 | Original source IPv4 address |
| Orig Dst IP | 32 | 12-15 | Original destination IPv4 address |
| Orig Src Port | 16 | 16-17 | Original UDP source port |
| Orig Dst Port | 16 | 18-19 | Original UDP destination port |
Total v1 header: 20 bytes. Payload follows immediately after.
| Bit | Name | Description |
|---|---|---|
| 0 | KEEPALIVE |
Keepalive probe (no payload) |
| 1 | HANDSHAKE |
Handshake packet |
| 2 | FIN |
Graceful tunnel close |
| 3 | FRAGMENT |
Packet is a fragment (reserved for future) |
Multiple flags can be set simultaneously.
When Version = 2, the v1 header is followed by a 4-byte FEC extension:
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| [--- 20 bytes v1 header as above ---] |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| FEC Group ID | Packet Index | Group Size |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Field | Bits | Bytes | Description |
|---|---|---|---|
| FEC Group ID | 16 | 20-21 | Identifies which FEC group this packet belongs to |
| Packet Index | 8 | 22 | Index within the group (0..K-1 = data, K = parity) |
| Group Size | 8 | 23 | Total data packets in group (K) |
Total v2 header: 24 bytes. Payload follows immediately after.
The FEC scheme uses simple XOR parity across a group of K data packets:
Group of K=4 data packets (variable length: 64, 128, 256, 64 bytes):
max_len = 256 (max of all payload lengths in the block)
P1 = [data1...] ‖ 0-pad to max_len
P2 = [data2...] ‖ 0-pad to max_len
P3 = [data3...] ‖ 0-pad to max_len
P4 = [data4...] ‖ 0-pad to max_len
Parity content (max_len bytes):
XOR = P1 ⊕ P2 ⊕ P3 ⊕ P4 (byte-wise XOR, only first max_len bytes)
lengths_xor (2 bytes, big-endian):
LX = len(P1) ⊕ len(P2) ⊕ len(P3) ⊕ len(P4) (cumulative XOR of lengths as u16)
Parity packet payload (v0.4.0-dev+):
[XOR (max_len bytes)][LX (2 bytes BE)] ← compact format, total = max_len + 2
Recovery (if P2 lost):
xor_buf = XOR ⊕ P1 ⊕ P3 ⊕ P4 (XOR in all received, missing region is P2)
orig_len = LX ⊕ len(P1) ⊕ len(P3) ⊕ len(P4) (recover P2 exact length)
P2 = xor_buf[..orig_len]
Properties:
- Recovers exactly 1 lost packet per group
- Overhead: 1 extra packet per K data packets (e.g., K=8 → 12.5% overhead)
- Parity computation: ~3μs (negligible latency impact)
- Recovery: ~3ms (vs 400ms+ for TCP-style retransmission)
- Parity packet is
max_payload_len_in_block + 2bytes - much smaller than old fixed 1400 B for typical game traffic (64-512 B), saving ~10× parity bandwidth
| Release | Parity payload format |
|---|---|
| ≤ v0.3.x | Fixed 1400 B XOR buffer (zero-padded to FEC_MAX_PAYLOAD) |
| ≥ v0.4.0-dev | Compact: [XOR content (max_len B)][lengths_xor (2 B BE)]
|
Proxy and client must be on the same release for FEC recovery to work.
Mixed v0.3.x ↔ v0.4.0-dev deployments: parity packets are silently ignored (no crash,
but no recovery). Perform a coordinated rolling upgrade of all proxy nodes and clients.
Both FecEncoder and FecDecoder track:
-
packets_encoded/packets_decoded- total packets processed -
parity_generated/packets_recovered- FEC operations performed -
packets_lost- packets that could not be recovered (>1 loss per group)
- Flags:
0x00 - Carries game UDP payload
- Proxy strips header and forwards payload to
Orig Dst IP:Orig Dst Port
- Flags:
KEEPALIVE (0x01) - No payload
- Sent by client every 5 seconds (configurable)
- Proxy responds with matching keepalive (echoes sequence number)
- Used for latency measurement and liveness detection
- Flags:
HANDSHAKE (0x02) - Sent when establishing a new tunnel session
- Proxy records client address and begins accepting data packets
- Session token in Reserved byte authenticates the client
- Flags:
FIN (0x04) - Graceful tunnel close
- Proxy cleans up session state
- No payload
Client Proxy
| |
|--- HANDSHAKE (seq=0) ------->| Client sends handshake with session token
| | Proxy records client addr + token
|<-- HANDSHAKE ACK (seq=0) ----| Proxy acknowledges (make_response)
| |
|--- DATA (seq=1) ------------>| Start sending game packets
|<-- DATA (seq=1) -------------| Proxy relays responses
| |
|--- KEEPALIVE (seq=N) ------->| Periodic keepalive
|<-- KEEPALIVE (seq=N) --------| Proxy echoes
| |
|--- FIN (seq=M) ------------->| Client closes tunnel
|<-- FIN ACK (seq=M) ----------| Proxy confirms
| |
-
Client sends: Keepalive every
keepalive_ms(default: 5000ms) -
Proxy responds: Echo with same sequence number (via
make_response()) -
RTT measurement:
RTT = recv_time - header.timestamp -
Liveness: If
max_keepalive_misses(default: 3) consecutive misses → trigger failover -
Proxy cleanup: If no activity for
session_timeout(default: 300s) → remove session
The client wraps each game datagram in the 24-byte LightSpeed header (plus a 4-byte FEC header and a 2-byte parity trailer when FEC is on), then the kernel prepends the outer IPv4 (20) and UDP (8) headers. The client-to-relay leg is clamped to a conservative path MTU of 1492 bytes (PPPoE):
Conservative path MTU: 1492 bytes
Outer IP header: 20 bytes
Outer UDP header: 8 bytes
LightSpeed header: 24 bytes
Plain payload budget: 1440 bytes
FEC payload budget: 1434 bytes (also reserves the 4-byte FEC header
and the 2-byte parity trailer)
A payload within budget is sent with the socket's don't-fragment bit set, so it is never fragmented. A payload over budget is not dropped (dropping a world-state packet stutters the game): it is forwarded with don't-fragment cleared for that datagram, so the local kernel may fragment it, and the client counts it. A lost fragment loses the whole datagram, which is why the clamp exists; forwarding the oversized payload is a deliberate tradeoff of that fragmentation risk against certain loss. This is a fixed clamp over the client-to-relay hop.
With path_mtu_discovery = true (or --path-mtu-discovery) the client enables
bounded DPLPMTUD (RFC 8899) over the already-encrypted QUIC control plane. The
control connection probes the path up to a 1500-byte IPv4 Ethernet hard cap and
reports the largest UDP payload the path supports; the data plane samples that
result (at most once every two seconds) and raises the effective path MTU from
the 1492-byte clamp toward the cap. The 1500-byte hard cap is 1472 bytes of UDP
payload, which also stays inside the relay's 2048-byte receive buffer.
Discovery can only move the effective MTU between the clamp and the cap. Any
failed or ambiguous probe, a missing control connection, or a path that shrinks
falls back to the conservative clamp, so a path that never answers can never
stall or break a session. Discovery is off by default; with it off the fixed
clamp above is the whole policy. The effective MTU in force is logged
(client-to-relay path MTU updated) and exposed as the effective_path_mtu
stat.
With dscp = true (or --dscp) the client marks tunnel UDP sockets with IPv4
DSCP EF (46), the standard low-latency class, on Linux and macOS. Only the
player's own router or ISP can act on it; every later hop may ignore or
reclassify it, and the proxy-to-game-server hop is not marked. Windows is not
marked because doing so needs the QWAVE/QoS2 API rather than a plain IP_TOS
call. Marking is off by default.
The control plane uses binary-encoded messages over QUIC (feature-gated behind quic):
| Type ID | Name | Direction | Description |
|---|---|---|---|
| 0x01 | Ping |
Client → Proxy | Latency probe |
| 0x02 | Pong |
Proxy → Client | Latency response (echoes timestamp) |
| 0x03 | Register |
Client → Proxy | Register session (game_id, client_version) |
| 0x04 | RegisterAck |
Proxy → Client | Confirm registration (session_id, session_token, node_id, region) |
| 0x05 | Disconnect |
Either | Graceful disconnect (with reason code) |
| 0x06 | ServerInfo |
Proxy → Client | Proxy metadata (node_id, region, load, version) |
Messages are length-prefixed with a 2-byte big-endian length field:
[length: u16][type: u8][payload...]
| ID | Game |
|---|---|
| 1 | Fortnite |
| 2 | CS2 |
| 3 | Dota 2 |
| 4 | Rust (Facepunch) |
| 5 | Valorant |
| 6 | Apex Legends |
| Code | Reason |
|---|---|
| 0 | Normal |
| 1 | Timeout |
| 2 | Auth Failed |
| 3 | Rate Limited |
| 4 | Server Shutdown |
The client↔proxy leg of the data plane can run over TCP instead of UDP (--tcp). Each
tunnel packet is wrapped in a length-prefixed frame:
[length: u32 big-endian][tunnel packet bytes...]
-
lengthis capped atMAX_FRAME_SIZE(header + FEC header + 2048 bytes = 2072). A zero or oversized length is rejected before any allocation, preventing an attacker-suppliedu32::MAXfrom triggering a huge buffer. - The frame payload is the exact bytes that would travel as one UDP datagram, so the header format, FEC, and security pipeline are unchanged.
- The proxy accepts both UDP and TCP on its data port; the same auth, rate-limit, abuse, destination validation, and FEC apply to TCP traffic.
- No encryption by design - game traffic is inspectable (anti-cheat compatible)
- IP preservation - game servers see real user IP (not proxy IP)
-
Session tokens - per-client
session_tokenbyte in header, stamped after QUIC registration (v1.1.0+) - Rate limiting - per-client PPS and BPS limits enforced by proxy
- Anti-amplification - proxy tracks inbound/outbound byte ratio
- Anti-reflection - proxy limits unique destinations per client per time window
- Destination validation - proxy blocks private IPs, localhost, multicast, link-local
-
No open relay - only authenticated sessions can relay traffic (
require_authon by default)
Offset Hex ASCII
0000 10 00 00 2A 00 0F 42 40 C0 A8 01 64 68 1A 01 32 ...*..B@...dh..2
0010 30 39 1E 61 00 07 02 08 + FEC ext (4B)
0018 [game payload...]
Decoded:
Version: 1
Flags: 0x00 (data)
Reserved: 0x00
Sequence: 42
Timestamp: 1,000,000 μs
Src IP: 192.168.1.100
Dst IP: 104.26.1.50
Src Port: 12345
Dst Port: 7777
Offset Hex
0000 11 00 00 05 00 0F 42 40 00 00 00 00 00 00 00 00
0010 00 00 00 00 00 07 08 08 + FEC ext (4B)
0018 [XOR parity payload...]
Decoded:
Version: 1
Flags: 0x01 (KEEPALIVE)
Sequence: 5
Timestamp: 1,000,000 μs
[all address fields zero]
[no payload]
Offset Hex
0000 20 00 00 2C 00 0F 42 40 C0 A8 01 64 68 1A 01 32 v2 header (20B)
0010 30 39 1E 61 00 07 02 08 + FEC ext (4B)
0018 [game payload...]
Decoded:
Version: 2 (FEC enabled)
Flags: 0x00 (data)
Sequence: 44
FEC Group ID: 7
Packet Index: 2 (3rd data packet, zero-indexed)
Group Size: 8
Offset Hex
0000 20 00 00 30 00 0F 42 40 00 00 00 00 00 00 00 00 v2 header (20B)
0010 00 00 00 00 00 07 08 08 + FEC ext (4B)
0018 [XOR parity payload...]
Decoded:
Version: 2 (FEC enabled)
Flags: 0x00
Sequence: 48
FEC Group ID: 7
Packet Index: 8 (= group_size, so this is parity)
Group Size: 8
-
TunnelHeader::new()- create data packet header -
TunnelHeader::keepalive()- create keepalive header -
TunnelHeader::with_session_token()- builder for auth token -
TunnelHeader::make_response()- swap src/dst for proxy reply -
TunnelHeader::encode_to_array()→[u8; 20]- zero-alloc stack encode (v0.4.0-dev+, hot path) -
TunnelHeader::encode()→Bytes- serialize to wire format (delegates toencode_to_array) -
TunnelHeader::encode_with_payload(&[u8])→Bytes- header + payload in one allocation -
TunnelHeader::decode(&[u8])→Result<TunnelHeader>- parse from wire format -
TunnelHeader::decode_with_payload(&[u8])→Result<(TunnelHeader, &[u8])>- parse header + slice payload
-
FecEncoder::new(k_size: u8)- create encoder with K data packets per block -
FecEncoder::add_packet(&[u8])→Option<Bytes>- XOR into parity; returns compact parity when block full -
FecEncoder::flush()→Option<(block_id, actual_k, Bytes)>- force-emit partial block parity -
FecEncoder::block_id()→u16- current block ID -
FecEncoder::current_index()→u8- packets accumulated in current block -
FecHeader::data(block_id, index, k_size)- create FEC extension header for data packet -
FecHeader::parity(block_id, k_size)- create FEC extension header for parity packet -
FecHeader::encode(&mut BytesMut)- append 4-byte FEC header to buffer -
FecHeader::decode(&mut &[u8])→Option<FecHeader>- parse 4-byte FEC header -
FecDecoder::new()- create decoder (64-slot ring buffer, 500ms block expiry) -
FecDecoder::receive_data(&FecHeader, Bytes)→Bytes- track data packet; return payload -
FecDecoder::receive_parity(&FecHeader, Bytes)→Option<(u8, Bytes)>- store parity; attempt recovery -
FecDecoder::try_recover_block(block_id)→Option<(u8, Bytes)>- explicit recovery attempt -
FecDecoder::gc()- discard blocks older thanmax_age_ms -
FecDecoder::stats()→FecStats-active_blocks,recovered_count,recovery_failures
-
ControlMessage::read_from(stream)- async QUIC message read -
ControlMessage::write_to(stream)- async QUIC message write -
ControlMessage::encode()/decode()- binary serialization
All encode/decode covered by unit tests (header: 8 tests, FEC: 10 tests + 6 proptests, control: 6 tests).