Status: experimental. Linux only. Off unless multiport.ports is set.
A nebula tunnel is one UDP 4-tuple. Everything between the two hosts that makes a decision per flow makes it once, for the whole tunnel:
Running routines: N does not help. Those N sockets share one port through
SO_REUSEPORT, and the group is keyed on the exact (addr, port) pair, so the
kernel's reuseport hash is the only thing spreading the work -- every hash
outside this host still sees a single flow.
Multiport gives one tunnel several underlay 4-tuples, so all of those per-flow decisions get made several times, independently. A tunnel with N lanes is N flows to everything counting flows: N ECMP hashes, N receive queues, N of the cloud provider's per-flow buckets.
There is a second bottleneck, and it is inside this host rather than out on the
network: in FIPS 140 mode the AES-GCM implementation refuses to seal twice under
the same nonce, which forces every encrypt on a session to be serialized behind
one mutex. routines: N does not help there either. Because each lane is a
separate session, multiport splits that mutex as well -- see
The FIPS 140 encrypt lock.
A lane is not a second tunnel. It is an extra session on the same
HostInfo.
Noise leaves both sides with A.eKey == B.dKey and A.dKey == B.eKey. Expanding
both keys through HKDF-SHA256 with the same per-lane label preserves that
equality, so both sides land on a matched key pair having exchanged nothing:
lane s send key = HKDF(base eKey, info: "nebula multiport lane v1 <s>")
lane s recv key = HKDF(base dKey, info: "nebula multiport lane v1 <s>")
(connection_state.go:deriveLaneKey. The base key is already unique per tunnel
and per direction, so the lane index is the only thing that needs to vary and no
salt is required.)
Consequences worth stating plainly:
maxMessageCounter therefore reports the max across the base and all
lane counters, so rehandshake and exhaustion thresholds see the real data
volume rather than the base session's small share.Which lane a packet belongs to travels in the nebula header, in the low 8 bits
of what used to be Reserved (see header/header.go). It is part of the AEAD's
associated data, so a lane index cannot be altered in flight -- a packet
decrypts on the lane it claims or not at all.
Lane 0 is the base tunnel itself: HostInfo.ConnectionState, the base port,
the peer's real remote address. It is not a special case reserved for control
traffic; it carries its share of data flows like any other lane.
routines is per port. Each of multiport.ports consecutive ports gets its
own full group of routines sockets sharing it through SO_REUSEPORT, so total
sockets = total routines = total tun queues = routines * multiport.ports.
routines: 2, multiport.ports: 3, listen.port: 4242
port 4242 port 4243 port 4244
(lane 0/base) (lane 1) (lane 2)
+-------------+ +-------------+ +-------------+
writers[] | 0 | 1 | | 2 | 3 | | 4 | 5 |
+-------------+ +-------------+ +-------------+
routine 0 1 0 1 0 1
Sockets are laid out port-major: writers[s*routinesPerPort + r] is the
r'th socket on port listen.port+s. So:
laneSock(q, s) = s*routinesPerPort + q%routinesPerPort // inside.go
egressSock(q) = laneSock(q, 0) // base traffic
Two properties fall out of this, and the rest of the design depends on both:
listenIn blocks in recvmmsg
and everything downstream of it -- the batcher, the conntrack cache, the
txQueue -- is single-owner and lock-free. More sockets than routines would
need epoll or locks.(queue, lane) with no
borrowing and no shared state, and each port's traffic spreads across its
whole group rather than funnelling into one socket.The second property is why routines is per port rather than a total to divide
up. With one socket per port, each port would be served by a single core -- worst
of all the base port, which carries every handshake, every lighthouse and punch
packet, every peer without multiport, and every tunnel whose lanes are down.
Multiport capability rides the existing handshake payload as two new protobuf
fields (handshake/payload.go): InitiatorLanes field 9 and ResponderLanes
field 10, each a LaneDetails{PortCount, BasePort, TxLanes}.
PortCount / BasePort -- the contiguous port range the sender bound, so the
peer knows where to aim its lanes.TxLanes -- how many lanes the sender may send on, so the peer knows how many
lane sessions it must be prepared to receive on.Both are nil when multiport is off, which keeps the encoded payload
byte-identical to a vanilla one. A peer that has never heard of lanes skips
unknown fields as protobuf requires, and gets a plain tunnel.
From the result, newLaneSet computes:
sessions = min(max(myLanes, peerTxLanes), 256) // we must be able to RECEIVE all of theirs
txLanes = min(myLanes, peerPortCount, sessions) // we may only SEND on ports they bound
Sizing RX by the peer's count and TX by our own is what lets asymmetric hosts work: a 4-port laptop talking to a 32-port server sends on 4 lanes and receives on 32.
Lane s targets peerBasePort + ((s + portOffset) % peerPortCount).
portOffset is a per-pair FNV hash of the sorted vpn-address pair. Without it,
every small peer would aim its few lanes at a big peer's first few ports and
concentrate that peer's receive work on a couple of sockets.
The rotation has to cancel, though, or the two directions of one flow would take
unrelated 4-tuples and neither side's traffic would arrive through the conntrack
or NAT entry the other's probe opened. So both sides hash the same sorted pair
and the higher-addressed side negates the result. When the port counts match,
the two rotations cancel exactly: our lane s's 4-tuple is the reverse of the
peer's lane s. laneBias does the matching rotation on the flow hash for the
same reason.
That pairing only exists when our lane indices map one-to-one onto the peer's
ports, which is exactly the txLanes == peerPortCount test newLaneSet applies
before setting laneBias. If we send on 4 lanes and the peer bound 8 ports, four
of its ports have no lane of ours pointing at them, and no choice of rotation can
make our lane s and its lane s be each other's reverse. So in that case
laneBias stays 0 and each side hashes the flow to a lane on its own. The flow
still works and is still spread; the two directions simply take two unrelated
4-tuples instead of one 4-tuple and its exact reverse, and each direction depends
on its own lane's probe having opened its own conntrack or NAT entry.
Receiving on a lane needs no permission: the keys are derivable the moment the base handshake completes. Sending on one needs proof the new 4-tuple actually works, because nothing else would notice a middlebox quietly dropping it. So:
lane down --probe (Test/LaneProbe on lane s, from port base+s to peer's lane port)-->
<--ack (Test/LaneProbeAck, on the BASE tunnel)--
lane up
s would make the
result depend on a second path that can be broken independently.txAddr[s] is a single atomic.Pointer[netip.AddrPort] that is both the TX
gate and the destination, so a data-plane routine that loads non-nil has
everything it needs in one atomic read and there is no window where one is set
and the other is not.
Probing is driven by the connection manager's per-tunnel traffic tick
(maintainLanes -> probeLanes), which skips a tunnel that is idle in both
directions -- a lane is only worth having while there is traffic to put on it.
Probes and their acks are not counted as tunnel traffic, so a lane's own
keepalive cannot keep an otherwise idle tunnel from being dropped by
tunnels.drop_inactive. Timers:
| timer | value |
|---|---|
| probe timeout | 2s (shorter than the 5s tick on purpose) |
| keepalive | 30s |
| retry backoff | 5s, doubling to 60s |
| max failure count | 8 |
Traffic on a lane is not evidence the lane works -- that is the whole reason lanes need probing -- so a lane that silently breaks is only caught by the keepalive.
Every lane starts out demanded, so the first traffic tick probes all of them at once. This matters more than it looks: see "flow pinning" below. A lane that is down also raises demand from the TX path each time a flow wants it, so a lane that keeps failing is retried only while something still wants it, and an idle tunnel costs nothing.
A lane aims only at its own port. There is no fallback to the peer's base port when the lane port doesn't answer: a lane sharing the base port's destination would gain only a source port of its own while costing the peer the receive spread that is the entire point. A lane that can't reach its port stays down and its flows ride the base tunnel.
This is the part that took the most iterations to get right, so it's worth spelling out why it is shaped the way it is.
On Linux, a multiqueue tun device does not simply hash a flow to a queue. It
also learns: tun_flow_update records "this flow was last seen on queue q"
from the packets we write in, and tun_automq_select_queue prefers what it
learned over the hash for as long as the flow stays busy.
We write an inbound packet to the tun queue with the same index as the UDP
routine that received it (batchers[rxc.q]). So the queue a flow's outbound
packets arrive on is decided by the socket its inbound packets landed on, at
the far end, one RTT ago.
The obvious design -- routine q sends on lane q -- deadlocks against that
feedback loop:
So the lane comes from the flow's own 5-tuple, not from the routine index:
s = (laneFlowHash(fwPacket) + laneBias) % txLanes // lanes.go:txLaneForFlow
This makes lane spread completely independent of how the kernel steers tun queues. It also gives the properties you actually want from a flow's point of view:
laneFlowHash orders the two
endpoints before hashing, so it returns the same value from either end, and
laneBias lines the two sides' choices up. The two directions are exact
reverse 4-tuples, which is what NAT and stateful firewalls need.newLaneSet demanding every lane up front is the other half of this. Lanes have
to be up before the flows are: a flow that starts while the lanes are still
down gets its queue pinned by step 2 above and can stay there for its whole
life. Eager demand costs one probe per lane on any tunnel that has traffic, and
nothing at all on one that doesn't.
outbound inbound (at the peer)
-------- ---------------------
inside flow
| kernel tun hash, or the queue it
| learned from our last write
v
tun queue q -> routine q
|
| s = laneFlowHash(flow) % txLanes lane s arrives on port base+s
v |
lane s session | SO_REUSEPORT hash of the
| | 4-tuple picks one socket
| writers[laneSock(q,s)] v
v routine q' (owner of that socket)
port base+s -------------------------> |
v
tun queue q' (teaches the kernel
flow -> q')
Note that RX is entirely socket-agnostic: the lane comes from the header byte,
not from the port the packet arrived on, and roaming is skipped for lane != 0
(a lane's source address is a per-lane 4-tuple, not the tunnel's remote -- letting
it roam the hostinfo would point every non-lane packet at a lane port). So a
lane packet may legitimately arrive on any socket, which is what makes the
reuseport spread within a port safe.
Several routines can write to one socket, since the routines whose lane
arithmetic lands on the same index share it. Linux's batchWriter serializes
sendmmsg with a mutex. Per-flow wire order still holds regardless: a flow is
hashed onto one lane and read by one routine, so nothing else is writing that
flow.
Each routine holds one txQueue with one shared arena (~1.16 MB) and builds a
SendBatch per lane lazily, on the first packet that picks it, so a routine that
never sends on a lane never pays for one.
In FIPS 140 mode -- a boringcrypto build, or GODEBUG=fips140=on -- nebula
uses noiseutil.CipherAESGCMFIPS140 instead of the plain AES-GCM cipher. That
cipher is the TLS 1.3 GCM (GCMWithXORCounterNonce), which panics if it is
asked to seal with a counter that is not strictly greater than the last one. That
check is the point: it is the nonce-reuse protection FIPS 140 requires, and
noiseutil's startup self-test refuses to run if the check has gone missing.
The check means encrypts on one session cannot overlap, so
noiseutil.EncryptLockNeeded is true and every send path takes that session's
ConnectionState.writeLock:
if noiseutil.EncryptLockNeeded {
ci.writeLock.Lock()
}
c := ci.messageCounter.Add(1)
out = header.EncodeLane(scratch, ..., c, lane)
out, encErr := ci.eKey.EncryptDanger(out, out, seg, c, nb)
if noiseutil.EncryptLockNeeded {
ci.writeLock.Unlock()
}
The lock has to cover the seal and not just the counter increment. Reserving counters atomically is easy; the requirement is that the seals arrive at the AEAD in counter order, and only holding the lock across both gives that.
So under FIPS 140 the encrypt cost of a tunnel is pinned to one core. routines:
N gives N readers, but every one of them that has a packet for the same peer
queues on the same mutex, and with TSO/USO the lock is taken and released once
per segment -- up to ~45 times for a single superpacket
(sendInsideEncrypt). Receive is not affected: extractFIPSAEAD deliberately
pulls the inner FIPS AEAD out of the crypto/tls wrapper because that inner
implementation is safe to Open concurrently, and decryptLock is only ever
held around the replay-window check and update.
Lanes split the lock because a lane is a separate ConnectionState: its own
writeLock, its own message counter, its own nonce sequence. A peer we send to
on txLanes lanes has txLanes independent encrypt locks, so encrypt for that
one tunnel can run on that many cores at once.
The flow hash is what makes this work rather than merely legal. A flow maps to one lane, so all of a flow's packets take one lock in counter order -- exactly what the FIPS AEAD demands -- while different flows to the same peer land on different locks. Contention is divided, not eliminated: any routine may send on any lane, so two routines whose flows hash to the same lane still serialize against each other.
Two caveats. This is only a FIPS 140 benefit -- in a normal build
EncryptLockNeeded is false, encrypt is lock-free, and lanes buy path and queue
spread only. And it is not unilateral: txLanes is bounded by the peer's port
count, so a peer that binds one port leaves us back on one encrypt lock however
many ports we bound ourselves.
Multiport degrades rather than failing, at every level. This is deliberate: managed deployments can't be hard-errored on config they don't control.
Any of these turns multiport off, logs why, and leaves a node that binds one
port with routines SO_REUSEPORT sockets -- bit-for-bit the pre-multiport
configuration:
| condition | log |
|---|---|
multiport.enabled: false |
- |
multiport.ports unset, 0, or 1 |
multiport disabled: set multiport.ports > 1 ... |
listen.port + ports - 1 > 65535 |
multiport disabled: would bind ports beyond 65535 |
| platform can't run multiple UDP readers | multiport disabled: this platform does not support multiple udp readers |
| that capability couldn't be probed | multiport disabled: could not probe udp reader support |
multiport.ports is also clamped to 256 (the lane header limit) and to
maxRoutines / routines, with a warning, rather than being rejected.
With a dynamic listen.port: 0, the first socket binds dynamically and the
range is claimed above it; a partially-occupied range re-rolls with a fresh
dynamic port up to 6 times.
laneSock collapses to the identity when multiport is off, so the send path is
unchanged: a routine writes to its own socket and no lane batches are built at
all.
newLaneSet returns nil, and the tunnel is an ordinary one, when:
sessions < 2, i.e. neither side offers a lane.txLanes can also land at 1 -- we bound ports but the peer bound only one -- in
which case the set exists for RX but we never send on a lane.
txLaneForFlow returns a nil session and the packet rides the base tunnel,
decided per packet with no state to unwind:
probeLanes drops them
all when there is no direct path and rebuilds when one returns;Handshakes, lighthouse traffic, punching, relay carriers, close packets, rejects,
and lane probe acks all use the base session and a socket on the base port
(egressSock). Base traffic must keep the base source port or a vanilla peer
would see the tunnel's address move and roam-thrash.
recv_error is the one deliberate exception: it replies from the socket the
offending packet arrived on, so the reply to lane traffic travels the reverse of
the lane's own 4-tuple, the only one the peer's NAT and stateful firewalls are
known to pass. The receiving side's spoof guard accepts a recv_error from the
base remote or from the target of any of its up lanes (isLaneRemote).
recv_error would tear down a perfectly good base tunnel on the strength of
one odd packet.installSession) -- the keys are identical, so it is the
same window in every respect that matters.routines: 8 # PER PORT under multiport; total workers = routines * ports
multiport:
enabled: true # default true, but inert without ports
ports: 4 # consecutive ports from listen.port; must be > 1, no default
lanes: 0 # 0 = one per bound port; lower to send on a subset
multiport.ports has no default on purpose: under these semantics a default
would silently multiply the worker count. Nothing here is reloadable.
Both sides need a port range, and the range
[listen.port, listen.port+ports-1] must be open in both directions. Opening
only the base port is the common failure and gives you exactly one working lane
-- the one whose rotation happens to land on the base port.
nebula-ssh> print-tunnel -vpn-addr <peer>
lanes[] gives per-lane up, remote and messageCounter, which is the fastest
way to tell "no lanes negotiated" (key absent) from "lanes up but traffic on one"
(counters).
Metrics, registered only when multiport is running so they don't sit at zero on nodes without it:
multiport.lanes.up -- lanes currently carrying trafficmultiport.lanes.tunnels -- tunnels with any lanesBoth are counted by walking the hostmap rather than kept at promotion/demotion, because a counter would drift upward forever: a tunnel torn down with its lanes up never demotes them.
Logs worth grepping: multiport enabled and multiport routines at startup, the
lanes attr on handshake completion (tx, sessions, peerBasePort,
peerPorts, portOffset), and Multiport lane up / Multiport lane demoted,
both of which name the udpAddr involved.
readOutsidePackets-level test for the RX lane drop paths.routines * ports > 256 fails at startup from the kernel's tun queue limit
rather than being clamped with a warning.