mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 13:46:52 +01:00
multiport: make a lane a session on the tunnel, not a tunnel
Lanes used to be separate HostInfos, each with its own handshake, its own half-established states, its own lifetime and its own slot bookkeeping. That bought nothing: a lane is the same tunnel over a different underlay 5-tuple. Derive lane sessions instead. Noise leaves us with A.eKey == B.dKey, so both sides HKDF-expand the same two keys with the same per-lane label and land on a matched pair without exchanging anything. Lanes now cost no handshake, have no half-established state, and die exactly when their base tunnel does. Which lane a packet belongs to rides the low byte of the nebula header's Reserved field, inside the AEAD's associated data. Receiving on a lane needs no permission, since the session exists the moment the base handshake completes. Sending on one needs proof the new 5-tuple works, so a lane stays down until a probe on it is acked and falls back to the base tunnel the moment it stops being acked. Probing is demand-driven off the connection manager's per-tunnel traffic tick: a peer we exchange a trickle with never costs more than its base tunnel, however many lanes are configured. The ack rides the base session on purpose, so a broken reverse lane can't fail a working one. Because the data now rides lane counters, the rehandshake, exhaustion and swap-primary checks take the max counter across the base session and its lanes; otherwise the base counter would sit near zero while a lane ran its keys past the nonce ceiling. Removes OutboundLaneTimer, EnsureLanes, startLaneHandshake, completeLane, completeLaneResponder, makeLaneTrafficDecision and the lane fields on HostInfo. Handshake payload field 3 (the per-lane handshake index) is permanently reserved; peers advertise a TxLanes count instead.
This commit is contained in:
1 parent
8b914e67f4
commit
fb20de39b2
19 files changed
+1223
-1273
No files matched your search
+6
-271
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -280,208 +279,10 @@ type HostInfo struct {
|
||||
// This should only be used by the ConnectionManagers ticker routine.
|
||||
lastUsed time.Time
|
||||
|
||||
// sockIdx is the index into Interface.writers of the socket every packet
|
||||
// on this tunnel egresses from (and, for lanes, arrives on). 0 for base
|
||||
// and vanilla tunnels — the zero value preserves stock behavior.
|
||||
sockIdx int
|
||||
|
||||
// laneIndex is the owner's lane number for a lane tunnel; 0 for base.
|
||||
laneIndex uint16
|
||||
|
||||
// laneOwned is true when we initiated this lane (it carries our TX data).
|
||||
laneOwned bool
|
||||
|
||||
// parent points at the base tunnel a lane hangs off of; nil for base and
|
||||
// vanilla tunnels. Set before the lane is registered in hostmap.Indexes.
|
||||
parent *HostInfo
|
||||
|
||||
// lanes is allocated on a base tunnel when multiport is enabled and the
|
||||
// peer advertised lane support; nil otherwise.
|
||||
lanes *laneState
|
||||
}
|
||||
|
||||
// isLane reports whether this HostInfo is a lane tunnel rather than a base
|
||||
// (or vanilla) tunnel.
|
||||
func (i *HostInfo) isLane() bool {
|
||||
return i.parent != nil
|
||||
}
|
||||
|
||||
// laneState hangs off a base HostInfo and tracks the multiport lane tunnels
|
||||
// associated with it. txLanes is read lock-free on the TX hot path; the Mutex
|
||||
// guards everything else.
|
||||
type laneState struct {
|
||||
sync.Mutex
|
||||
|
||||
// peerPortCount/peerBasePort are the peer's advert from the base
|
||||
// handshake; portOffset is the per-pair rotation from lanePortOffset.
|
||||
// Lane i targets peerBasePort + ((i + portOffset) % peerPortCount).
|
||||
peerPortCount uint16
|
||||
peerBasePort uint16
|
||||
portOffset uint16
|
||||
|
||||
// txLanes[i] is our established, initiator-owned lane for routine i, or
|
||||
// nil. Index 0 is always nil — the base tunnel is lane 0. A pointer is
|
||||
// only Stored once the lane's ConnectionState is fully populated, so a
|
||||
// data-plane routine that Loads non-nil always sees a usable tunnel.
|
||||
txLanes []atomic.Pointer[HostInfo]
|
||||
|
||||
// txDemand[i] is set by the data plane when a routine riding slot i has
|
||||
// traffic for this peer and no established lane, and consumed when
|
||||
// EnsureLanes claims the slot. Lanes exist only where traffic asked for
|
||||
// one: a peer we exchange a trickle with never costs more than the base
|
||||
// tunnel, no matter how many routines are configured. Written without
|
||||
// the Mutex.
|
||||
txDemand []atomic.Bool
|
||||
|
||||
// Under Mutex: per-slot handshake-in-flight flag, consecutive failure
|
||||
// count, and earliest next attempt, driving ensureLanes' backoff.
|
||||
txPending []bool
|
||||
txFails []uint8
|
||||
txRetryAt []time.Time
|
||||
|
||||
// Under Mutex: responder-side records of peer-owned lanes, capped by
|
||||
// same-laneIndex replacement.
|
||||
peerLanes []*HostInfo
|
||||
}
|
||||
|
||||
func newLaneState(laneCount int, peerPortCount, peerBasePort, portOffset uint16) *laneState {
|
||||
return &laneState{
|
||||
peerPortCount: peerPortCount,
|
||||
peerBasePort: peerBasePort,
|
||||
portOffset: portOffset,
|
||||
txLanes: make([]atomic.Pointer[HostInfo], laneCount),
|
||||
txDemand: make([]atomic.Bool, laneCount),
|
||||
txPending: make([]bool, laneCount),
|
||||
txFails: make([]uint8, laneCount),
|
||||
txRetryAt: make([]time.Time, laneCount),
|
||||
}
|
||||
}
|
||||
|
||||
// noteLaneDemand records that a data-plane routine has traffic for lane slot
|
||||
// i but found the slot empty. Called from the TX hot path on every packet
|
||||
// that misses, so the store is load-guarded: once the flag is up, further
|
||||
// misses are plain reads and cannot ping-pong a cache line shared with the
|
||||
// neighbouring slots' flags. Slot 0 is the base tunnel and never a lane.
|
||||
func (ls *laneState) noteLaneDemand(i int) {
|
||||
if i <= 0 || i >= len(ls.txDemand) {
|
||||
return
|
||||
}
|
||||
if !ls.txDemand[i].Load() {
|
||||
ls.txDemand[i].Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// takeLaneDemand consumes slot i's demand flag, reporting whether the data
|
||||
// plane had asked for the lane since the last time we looked.
|
||||
func (ls *laneState) takeLaneDemand(i int) bool {
|
||||
if i <= 0 || i >= len(ls.txDemand) {
|
||||
return false
|
||||
}
|
||||
return ls.txDemand[i].Swap(false)
|
||||
}
|
||||
|
||||
// laneTargetPort returns the peer port that owned lane i handshakes to and
|
||||
// egresses toward. The caller must ensure peerPortCount != 0.
|
||||
func (ls *laneState) laneTargetPort(i int) uint16 {
|
||||
return ls.peerBasePort + uint16((i+int(ls.portOffset))%int(ls.peerPortCount))
|
||||
}
|
||||
|
||||
// lanePortOffset returns the rotation applied to this pair's lane target
|
||||
// ports, in [0, peerPortCount). Without it every low-routine peer would aim
|
||||
// its few lanes at a big peer's first few ports, concentrating the big
|
||||
// peer's receive work on a couple of sockets; the hash spreads pairs across
|
||||
// the whole advertised range.
|
||||
//
|
||||
// Both sides hash the same sorted vpn-address pair and the higher address
|
||||
// negates the result, so when port counts match the two sides' rotations
|
||||
// cancel: our lane i's 4-tuple is still the reverse of a peer-owned lane's,
|
||||
// and each outbound lane handshake opens the conntrack entry its partner
|
||||
// arrives through. (The one lane a nonzero rotation lands on the peer's base
|
||||
// port has no partner lane; behind a port-restricted NAT it may not form and
|
||||
// its routine rides the base tunnel — the standard lane fallback.)
|
||||
func lanePortOffset(myAddr, peerAddr netip.Addr, peerPortCount uint16) uint16 {
|
||||
if peerPortCount == 0 {
|
||||
return 0
|
||||
}
|
||||
lo, hi := myAddr, peerAddr
|
||||
if hi.Less(lo) {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
h := fnv.New32a()
|
||||
b := lo.As16()
|
||||
h.Write(b[:])
|
||||
b = hi.As16()
|
||||
h.Write(b[:])
|
||||
o := uint16(h.Sum32() % uint32(peerPortCount))
|
||||
if myAddr == hi {
|
||||
o = (peerPortCount - o) % peerPortCount
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
const (
|
||||
laneRetryBase = 5 * time.Second
|
||||
laneRetryMax = 60 * time.Second
|
||||
)
|
||||
|
||||
// noteLaneFailure marks lane slot i as empty and pushes the next attempt out
|
||||
// with exponential backoff. Called when an owned lane dies or its handshake
|
||||
// times out.
|
||||
func (ls *laneState) noteLaneFailure(i int) {
|
||||
if i < 0 || i >= len(ls.txPending) {
|
||||
return
|
||||
}
|
||||
ls.Lock()
|
||||
ls.txPending[i] = false
|
||||
if ls.txFails[i] < 200 { // just avoid wrapping; the delay caps far earlier
|
||||
ls.txFails[i]++
|
||||
}
|
||||
d := laneRetryBase << min(ls.txFails[i], 4)
|
||||
if d > laneRetryMax {
|
||||
d = laneRetryMax
|
||||
}
|
||||
ls.txRetryAt[i] = time.Now().Add(d)
|
||||
ls.Unlock()
|
||||
}
|
||||
|
||||
// noteOwnedLaneDeath detaches an established owned lane from its slot
|
||||
// (identity-checked, so a raced re-establishment is never clobbered) and
|
||||
// applies failure backoff.
|
||||
func (ls *laneState) noteOwnedLaneDeath(lane *HostInfo) {
|
||||
i := int(lane.laneIndex)
|
||||
if i >= len(ls.txLanes) {
|
||||
return
|
||||
}
|
||||
ls.txLanes[i].CompareAndSwap(lane, nil)
|
||||
ls.noteLaneFailure(i)
|
||||
}
|
||||
|
||||
// removePeerLane drops a responder-side lane record by identity.
|
||||
func (ls *laneState) removePeerLane(lane *HostInfo) {
|
||||
ls.Lock()
|
||||
for n, h := range ls.peerLanes {
|
||||
if h == lane {
|
||||
ls.peerLanes = append(ls.peerLanes[:n], ls.peerLanes[n+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
ls.Unlock()
|
||||
}
|
||||
|
||||
// snapshotLanes returns every lane hostinfo currently attached, used by the
|
||||
// base-delete cascade. Taken under the lock and returned as a copy so the
|
||||
// caller can delete without holding it.
|
||||
func (ls *laneState) snapshotLanes() []*HostInfo {
|
||||
ls.Lock()
|
||||
defer ls.Unlock()
|
||||
out := make([]*HostInfo, 0, len(ls.txLanes)+len(ls.peerLanes))
|
||||
for n := range ls.txLanes {
|
||||
if h := ls.txLanes[n].Load(); h != nil {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
out = append(out, ls.peerLanes...)
|
||||
return out
|
||||
// lanes holds this tunnel's multiport lane sessions. Allocated when the
|
||||
// handshake completes if both sides advertised multiport, nil otherwise.
|
||||
// Immutable once the hostinfo is published to the data plane.
|
||||
lanes *laneSet
|
||||
}
|
||||
|
||||
type ViaSender struct {
|
||||
@@ -655,12 +456,6 @@ func (hm *HostMap) MakePrimary(hostinfo *HostInfo) {
|
||||
// unlockedMakePrimary reports whether hostinfo is (now) the primary for each of its addresses,
|
||||
// false only when it is no longer in the hostmap at all.
|
||||
func (hm *HostMap) unlockedMakePrimary(hostinfo *HostInfo) bool {
|
||||
// A lane must never become a Hosts primary: it would start carrying all
|
||||
// traffic for the peer and become a relay candidate.
|
||||
if hostinfo.isLane() {
|
||||
return false
|
||||
}
|
||||
|
||||
// A hostinfo that is no longer in the hostmap must not be re-inserted here. Callers can race
|
||||
// tunnel teardown, deciding to promote under the read lock and only taking the write lock
|
||||
// after a delete fully unlinked the hostinfo (connection manager swapPrimary, AddRelay). Every
|
||||
@@ -689,18 +484,8 @@ func (hm *HostMap) unlockedMakePrimary(hostinfo *HostInfo) bool {
|
||||
// any tunnel to the peer), which the caller uses to decide whether to clear learned lighthouse
|
||||
// state and disestablish relays.
|
||||
func (hm *HostMap) unlockedDeleteHostInfo(hostinfo *HostInfo) bool {
|
||||
if hostinfo.isLane() {
|
||||
return hm.unlockedDeleteLane(hostinfo)
|
||||
}
|
||||
|
||||
// A dying base takes its lanes with it. The peer converges symmetrically
|
||||
// when it processes the base's CloseTunnel, so lanes need no signaling of
|
||||
// their own. Depth-1 recursion: lanes have no children.
|
||||
if hostinfo.lanes != nil {
|
||||
for _, lane := range hostinfo.lanes.snapshotLanes() {
|
||||
hm.unlockedDeleteLane(lane)
|
||||
}
|
||||
}
|
||||
// Lane sessions hang off this hostinfo, so deleting it takes them with it
|
||||
// and there is nothing extra to unwind here.
|
||||
|
||||
// Remove this hostinfo from each of its address lists. The lists are independent, so a
|
||||
// sibling is never promoted to an address it does not own and no other list is touched.
|
||||
@@ -778,35 +563,6 @@ func (hm *HostMap) QueryIndexCached(index uint32, cache map[uint32]*HostInfo) *H
|
||||
return out
|
||||
}
|
||||
|
||||
// unlockedDeleteLane removes a lane tunnel from the index maps and detaches it
|
||||
// from its base. Lanes never live in Hosts and their death never means "no
|
||||
// tunnel to the peer", so the return is always false (the lighthouse cache and
|
||||
// relays stay untouched). Idempotent: every step is identity-checked.
|
||||
func (hm *HostMap) unlockedDeleteLane(lane *HostInfo) bool {
|
||||
if ls := lane.parent.lanes; ls != nil {
|
||||
if lane.laneOwned {
|
||||
ls.noteOwnedLaneDeath(lane)
|
||||
} else {
|
||||
ls.removePeerLane(lane)
|
||||
}
|
||||
}
|
||||
|
||||
if hostinfo2, ok := hm.RemoteIndexes[lane.remoteIndexId]; ok && hostinfo2 == lane {
|
||||
delete(hm.RemoteIndexes, lane.remoteIndexId)
|
||||
}
|
||||
if hostinfo2, ok := hm.Indexes[lane.localIndexId]; ok && hostinfo2 == lane {
|
||||
delete(hm.Indexes, lane.localIndexId)
|
||||
}
|
||||
|
||||
if hm.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hm.l.Debug("Hostmap lane deleted",
|
||||
"hostMap", m{"vpnAddrs": lane.vpnAddrs, "laneIndex": lane.laneIndex,
|
||||
"indexNumber": lane.localIndexId, "remoteIndexNumber": lane.remoteIndexId},
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (hm *HostMap) QueryIndex(index uint32) *HostInfo {
|
||||
hm.RLock()
|
||||
if h, ok := hm.Indexes[index]; ok {
|
||||
@@ -935,27 +691,6 @@ func (hm *HostMap) unlockedAddHostInfo(hostinfo *HostInfo, f *Interface) {
|
||||
}
|
||||
}
|
||||
|
||||
// unlockedAddLane registers a lane tunnel in the index maps (RX demux and
|
||||
// recv_error need it there) without touching Hosts: lanes are never primary,
|
||||
// never dns-visible, and never subject to the MaxHostInfosPerVpnIp eviction.
|
||||
// The connection manager still tracks it for keepalive/death.
|
||||
func (hm *HostMap) unlockedAddLane(lane *HostInfo, f *Interface) {
|
||||
hm.Indexes[lane.localIndexId] = lane
|
||||
hm.RemoteIndexes[lane.remoteIndexId] = lane
|
||||
|
||||
lane.markOutOnly()
|
||||
if f.connectionManager != nil { // f.connectionManager is only nil in some unit tests
|
||||
f.connectionManager.trafficTimer.Add(lane.localIndexId, f.connectionManager.checkInterval)
|
||||
}
|
||||
|
||||
if hm.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||
hm.l.Debug("Hostmap lane added",
|
||||
"hostMap", m{"vpnAddrs": lane.vpnAddrs, "laneIndex": lane.laneIndex,
|
||||
"indexNumber": lane.localIndexId, "remoteIndexNumber": lane.remoteIndexId},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HostMap) unlockedInnerAddHostInfo(vpnAddr netip.Addr, hostinfo *HostInfo, f *Interface) {
|
||||
existing, ok := hm.Hosts[vpnAddr]
|
||||
if !ok {
|
||||
|
||||
Reference in new issue
Block a user