crazy multiport stuff - #1813
Draft
JackDoan wants to merge 46 commits into
Draft
Conversation
Multi-disciplinary correctness review of the batched tun / GSO-GRO / sendmmsg rework. Each fix has a regression test; the merged tree builds on linux/darwin/openbsd/windows/freebsd/netbsd, vets clean, passes the unit and e2e suites, and is -race clean. Critical: - C1 zero-length inner UDP datagram no longer panics the process (remote DoS): the UDP coalescer routes payLen==0 to passthrough instead of seeding a GSO slot, and WriteGSO skips empty payload iovecs as defense in depth. - C2 segmenter no longer corrupts inner headers when gsoSize < headerLen: the L3+L4 header is snapshotted once and each segment stamped from the copy, replacing the destructive overlapping in-place slide (SegmentTCP + SegmentUDP). High: - H1 applyOuterECN updates the IPv4 header checksum (RFC 1624 incremental) when folding outer CE into the inner ToS, so passthrough packets are no longer dropped by the peer stack. - H2 the GRO reject path caps the borrowed RX segment ([:n:n]) so a reject can no longer overrun into the next coalesced segment's Nebula header. Note: oversized ICMPv6 rejects that need >16B beyond the segment are now refused rather than sent under GRO (safe; see TOFIX.md for the scratch-buffer follow-up). - H3 WriteBatch falls back to per-packet WriteTo for a chunk when writeSockaddr fails, so one bad-family destination costs only its own packet, not the batch. - H4 UserDevice.Readers returns N distinct queue wrappers with private buffers (sharing the pipes) so concurrent readers no longer race/overwrite borrowed packet bytes. - H5 Poll.Close / Offload.Close no longer null t.fd (matching master's tunFile.Close), removing the data race with a concurrent readOne load. Medium/Low: - M1 the UDP GSO 127-segment gate moved from kernel >=5.5 to >=6.9 (the real UDP_MAX_SEGMENTS 64->128 threshold), avoiding EINVAL + per-packet fallback on 5.5-6.8 kernels. - M2 NewMultiQueueReader replays the offload mask newTun actually negotiated instead of the TSO-only mask, so adding a queue no longer disables USO device-wide; the advertised USO capability derives from the same mask. - M3 the shutdown eventfd is closed in pollQueueSet.Close / offloadQueueSet.Close (double-close guarded), fixing the per-lifecycle fd leak. - M4 dual-stack ECN selects the cmsg by address family, not socket family: RX parseRecvCmsg reads both IP_TOS and IPV6_TCLASS; TX writeEntryCmsg stamps IP_TOS for v4/v4-mapped dests and IPV6_TCLASS for v6 (on-host verified). - L1 newPoll no longer closes the fd on failure (matching newOffload), removing the double-close on QueueSet.Add error.
SendBatch.Reserve duplicated Arena's grow-on-demand logic byte for byte. Use an Arena for the slot backing so the borrow/grow/recycle semantics live in one place.
TUN_F_TSO_ECN is negotiated, so once ECN feedback flows the kernel hands us TSO superpackets typed TCPV4|GSO_ECN (CWR set). protoFromGSOType treated the qualifier bit as an unknown type and the read path dropped every such superpacket - a latent bug that only fires when a congested hop CE-marks the flow, exactly when drops hurt most. The segmenter already handles CWR (first segment only); just mask the bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JackDoan
force-pushed
the
better-tun-interface-ordering
branch
from
July 24, 2026 21:46
59ecea9 to
9688d32
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I had clod make a multi-tunnel version of multiport just to play around. It benchmarks very well for me!
TLDR: each routine now also gets its own full underlay flow on a different port. That way, flows get steered by 5-tuple automatically. There's coordination in the handshake manager for first handshaking on the "base" port advertised by the lighthouse, and then adding more "lanes" between the hosts with additional handshakes.
Here's what clod thinks you should know:
Reviewer Guide: Multiport Lanes
The problem
A Nebula tunnel is one UDP "flow": one source IP/port talking to one
destination IP/port. Every router, NIC, and load balancer on the path uses
that 4-tuple to pick a path and a receive queue. So no matter how many TCP
streams you push through the tunnel, they all squeeze through one path and
one receive queue on the far side. On our bench, 4 parallel iperf streams
scored exactly the same as 1 stream.
The idea
Give each pair of hosts several UDP flows instead of one. When multiport is
on, a host with
routines: 4binds 4 consecutive UDP ports (listen.portthrough
listen.port+3) instead of sharing one port with SO_REUSEPORT.After the normal tunnel ("base tunnel") comes up, each side opens one extra
tunnel per routine, called a lane. Lane i handshakes from local socket i
to the peer's port
base+i. Traffic read from tun queue i uses lane i; thekernel already pins each flow to one tun queue, so a flow stays on one lane
and packets never reorder.
Each lane is a complete, normal Noise tunnel — its own keys, its
own nonce counter, its own replay window. That is the key design choice.
The alternative (upstream PR #768) spreads one tunnel's packets across many
source ports with a raw socket. But then all those paths share one replay
window: if one path is slower than another, the slow path's packets fall
outside the window and get dropped as replays. Separate tunnels also mean:
never by source address, so lanes "just work" on receive.
normal connection per lane. No roaming hacks needed.
packets arrive only on socket i), so no lock contention.
Costs: a few extra handshakes and keepalives per peer, and more hostmap
bookkeeping (the risky part — see "What to review closely").
Compatibility
Negotiation uses handshake payload fields 6 and 7, which were
reservedupstream. Vanilla peers skip unknown fields (there's a test proving the
encoding is byte-identical when multiport is off), so a vanilla peer just
gets one normal tunnel. All control traffic — handshakes, lighthouse,
punching, relays — stays on the base tunnel and base port. If lanes can't
form (blocked ports, NAT), traffic falls back to the base tunnel.
multiport.enableddefaults on and quietly turns itself off when thesetup can't support it (1 routine, non-Linux, no multi-reader UDP). It
can't hard-error because dnclient owns the config. A dynamic
listen.port: 0works too: socket 0 binds a random port, then we claim the next N-1ports, retrying with a fresh random port if the range is taken.
Map of the changes
handshake/payload.go,machine.goLaneDetails{PortCount, BasePort, LaneIndex}in fields 6/7.NewMachinetakes it;Resultreturns the peer's.main.gohostmap.golaneStateon the base HostInfo:txLanes[i](atomic, read lock-free on TX), pending/backoff bookkeeping, peer-lane list. Lanes live inIndexes/RemoteIndexesbut neverHosts. Deleting a base cascades to its lanes; deleting a lane clears only its slot.handshake_manager.goStartLaneHandshake/handleOutboundLane(single pinned target, sent from the lane's socket, no lighthouse);completeLaneResponder(attach by certified vpn address, per-lane replay dedup);EnsureLanes(fill empty slots with 5s→60s backoff). Handshake replies now egress the socket the request arrived on.connection_manager.goensureLanesruns on the existing per-tunnel tick.inside.go,interface.goHostInfo.sockIdxdecides which socket every packet leaves from (0 = base = old behavior). Per-routinetxQueue{lane, base}: lane data on socket i, base+relay data on socket 0, flushed base-first. The hot-path lane check is one atomic load.outside.goViaSender.SockIdxcarries the arrival socket; recv_error replies use it (the peer's anti-spoof check compares source addresses).Also fixed a latent upstream bug: the pending-handshake table deleted
entries by address without checking they belonged to the hostinfo being
deleted, which could evict an unrelated in-flight handshake.
What to review closely
txLanes[i]is onlyStored afterthe lane's ConnectionState is fully built, and cleared with a CAS. The
TX path (
sendInsideMessage) loads it with no lock. Check the ordering.laneStatelock, orlaneStatealone. The base-delete cascade snapshots lanes under the laneState lock,
then deletes; verify no path inverts this.
sockIdxeverywhere. Any packet on a lane that leaves from thewrong socket makes the peer see a roam or reject a reply.
sendNoMetricsderives it from the hostinfo; grep for any send path that doesn't.
no-ops.
unlockedDeleteLane,noteOwnedLaneDeath, the vpnIps fix.laneState; the oldgeneration's lanes die with the old base. Parent pointers keep the
cascades from crossing generations.
Evidence
lanes_test.go,handshake/machine_lanes_test.go, payloadround-trip + vanilla byte-compat tests. Full suite green.
deaths, zero churn; vanilla interop clean.
REV Gbps both ways). 4 streams: 7.5 → ~20 Gbps FWD (+160%), 11 → 22
Gbps REV (+100%). With multiport off, 4 streams score the same as 1 —
the exact bottleneck this removes. tcpdump confirmed traffic on 3 lane
ports + base. Works over the IPv6 underlay.
Known gaps
simply never form and everything rides the base tunnel.
multiport.lanes(running fewer lanes than routines) is implemented butlightly tested.