Skip to content

Traffic splitting

The splitting engine answers one question, on every new connection: which of my tunnels does this packet leave through?

It does so in the kernel, with nftables numgen, so there is no userspace round-trip per packet. The selection algorithm, the rotation cadence, the bandwidth shaping and the cover traffic are all controlled by a single dial: the privacy level.

Six levels, parameterised by P ∈ [0, 1]. The slot-selection algorithm and protection layers stack as you go up:

Level Name Selection Jitter Padding Cover HTB shape Rotation Overhead
0 Performance Lowest-latency endpoint 0 ms 0 B 0 none none ~0%
1 Light Weighted round-robin 10 ms 0 B 0 none 30 min ~0%
2 Balanced Weighted round-robin 50 ms 0 B 0 none 10 min ~5%
3 Private Crypto-random 200 ms 512 B 1 pps 20 Mbit 5 min ~30%
4 Strong Crypto-random 500 ms 1400 B 5 pps 10 Mbit 2 min ~60%
5 Maximum Crypto-random 1000 ms 1400 B 10 pps 5 Mbit 1 min ~100%

The selection algorithm changes once at L3 (round-robin → crypto-random). The other dials — jitter, padding, cover, HTB rate, rotation cadence — scale continuously.

A level is selected per policy, not globally — your “kids” policy might run at level 2 while your “operator” policy runs at level 5.

Higher level = more privacy + more overhead. Pick per policy, not per box.

If your goal is… Use level Why
Lowest latency, single best tunnel 0 No split, no padding, no rotation. Pure performance.
Mild jurisdictional diversity, no cost 1 Per-session rotation; you appear from N exit countries at session granularity.
The default for everyday devices 2 Per-connection round-robin breaks linkability across simultaneous flows. ~5%.
Strong privacy without crushing throughput 3 Crypto-random selection + 512 B padding + 1 pps cover. Caps at 20 Mbit/s per tunnel.
Anti-correlation against an active observer 4 + larger padding (1400 B), 5 pps cover, 2-min rotation. Caps at 10 Mbit/s.
Maximum, you don’t care about bandwidth 5 + faster rotation (1 min), 10 pps cover. Caps at 5 Mbit/s; timing/length fingerprints are gone.

A few practical rules:

  • The level only matters during rotation events, not after. Setting level 5 on a long-lived SSH session adds cover traffic but doesn’t break the session.
  • You can mix levels in one policy by binding the same tunnel to two profiles at different levels and routing different match sets through them.
  • Don’t run level 5 on a Pi 4 for more than one or two devices. The HTB shaping and cover-traffic generator are CPU-bound.
  • Levels 0 and 1 don’t need a pool — they can be applied to a single-tunnel policy. The kill-switch and rotation logic still work.

Each policy is compiled into a sub-chain of the split-mark table:

chain split-mark-p7 {
numgen inc mod 100 vmap {
0-49 : mark set 0x701, # tunnel A, weight 50
50-79 : mark set 0x702, # tunnel B, weight 30
80-99 : mark set 0x703, # tunnel C, weight 20
}
}
  • numgen inc increments a kernel counter per new connection. Established flows take a fast-path conntrack lookup and skip re-evaluation.
  • mod 100 reduces it to a slot in the weight space.
  • The vmap turns the slot into an fwmark, which then drives ip rule → routing table → tunnel interface.

This means an even distribution with per-connection affinity: bursty traffic from one app doesn’t all clump on one tunnel.

The endpoint order in the vmap is reshuffled on the rotation interval:

  • Fisher-Yates with crypto/rand throughout (no PRNG fast-path — every shuffle is cryptographically random)
  • Old conntrack entries keep their fwmark (no broken connections), new ones get the new order

This breaks long-term correlation: even an observer who watches you for an hour can’t pin a specific tunnel to a specific destination.

At levels 3+ the daemon generates Poisson-distributed dummy traffic on tunnels with low real-utilisation:

  • Exponential inter-arrival times — memoryless, so packet rate alone doesn’t reveal real load.
  • EMA-tracked adaptive rate — cover rate scales down when real traffic is high (no point padding a busy tunnel) and up when real traffic is low.
  • Payload entropy matched — cover packets are filled from /dev/urandom so they’re indistinguishable from encrypted real traffic on the wire.

Cover traffic is shaped onto its own HTB class so it never starves real flows.

At level 3 padding is 512 bytes; at levels 4 and 5 it’s 1400 bytes (near the MSS after clamp). Outbound packets that would have been smaller are padded up to this size. The aim is the same as Mullvad’s DAITA: neutralise packet-length fingerprinting so a 40-byte heartbeat and a 1300-byte HTTP request look the same to a passive observer.

Each routing policy gets its own sub-chain and its own numgen counter. Two policies splitting across the same three tunnels do not share state — one busy policy can’t bias the other’s distribution.

For deployments in DPI-heavy networks where raw WireGuard UDP is identifiable, the splitting engine can wrap egress in a pluggable transport:

Transport What it does
quic Wraps WireGuard UDP in HTTP/3 CONNECT-UDP (MASQUE / RFC 9298) via a local relay → QUIC stream → MASQUE proxy → WireGuard peer. To a censor, this looks like ordinary HTTPS / QUIC traffic. Requires a MASQUE proxy address (quic_proxy_addr).
obfs4 Lyrebird obfs4 wrapper. Requires a bridge address, cert, and IAT mode.

Configure via PUT /api/v1/splitting/transport:

{
"quic_enabled": true,
"quic_proxy_addr": "masque.example.invalid:443",
"obfs4_enabled": false
}

Status is read back via GET /api/v1/splitting/transport — including the local MASQUE / obfs4 listener addresses the daemon binds for its own use.

Method Path Purpose
GET /api/v1/splitting/status Overall engine state per policy
GET/POST/DELETE /api/v1/splitting/endpoints Endpoint pool CRUD
GET /api/v1/splitting/cover/stats Cover-traffic counters
GET /api/v1/splitting/dns/status DNS-split state
GET /api/v1/splitting/transport Active transport details
POST /api/v1/splitting/activate Activate splitting for a policy

Privacy-level / weight changes go through the policy (or route profile) — splitting is a property of the routing policy, not a separate object. See Routing policies.

Once your first pool is splitting:

  1. Pick the level per policy, not per box. Run “kids” at L2 and “operator” at L5 from the same pool — they share tunnels but pay different overhead. Re-read the level table and tune per workload.
  2. Watch splitting/cover/stats if you’re at L3+. Cover traffic should drop when real load rises — if it doesn’t, the EMA never updated; check tunnel-RX counters.
  3. Stress-test rotation. Set a 5-minute rotation, open a long SSH session, confirm it survives. Rotation reshuffles new connections only; existing conntrack entries stick.
  4. Enable an anti-DPI transport for the policy if you’re in a censored network — QUIC/MASQUE makes the pool look like ordinary HTTPS.