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.
Privacy levels
Section titled “Privacy levels”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.
Choosing a level
Section titled “Choosing a level”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.
How slot selection works
Section titled “How slot selection works”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 incincrements a kernel counter per new connection. Established flows take a fast-path conntrack lookup and skip re-evaluation.mod 100reduces 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.
Rotation
Section titled “Rotation”The endpoint order in the vmap is reshuffled on the rotation interval:
- Fisher-Yates with
crypto/randthroughout (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.
Cover traffic
Section titled “Cover traffic”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/urandomso 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.
Packet padding
Section titled “Packet padding”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.
Per-policy isolation
Section titled “Per-policy isolation”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.
Anti-DPI transport wrapping
Section titled “Anti-DPI transport wrapping”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.
API surface
Section titled “API surface”| 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.
Next steps
Section titled “Next steps”Once your first pool is splitting:
- 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.
- Watch
splitting/cover/statsif you’re at L3+. Cover traffic should drop when real load rises — if it doesn’t, the EMA never updated; check tunnel-RX counters. - 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.
- Enable an anti-DPI transport for the policy if you’re in a censored network — QUIC/MASQUE makes the pool look like ordinary HTTPS.
Related
Section titled “Related”- VPN tunnels — the pool members the engine splits across.
- Routing policies — each policy carries its own privacy level and isolated
numgencounter. - Use case: anti-censorship family router — pool at level 4 for adults; level 2 for kids’ non-censored traffic.