Quellcode durchsuchen

crazy multiport stuff

(cherry picked from commit 0488793a626fa2681dc17d7f3bc44f56d8d98c7f)
JackDoan vor 3 Monaten
Ursprung
Commit
0ae7ba073c

+ 89 - 1
connection_manager.go

@@ -197,6 +197,28 @@ func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte
 	}
 
 	cm.resetRelayTrafficCheck(hostinfo)
+	cm.ensureLanes(localIndex, decision, hostinfo)
+}
+
+// ensureLanes piggybacks lane re-establishment on the per-tunnel traffic
+// tick: any live base with lane state gets its empty slots retried (subject to
+// the per-slot backoff). makeTrafficDecision returns a nil hostinfo on some
+// keep-alive paths, so re-resolve the index in that case — an idle base must
+// still restart lanes that died while it was quiet.
+func (cm *connectionManager) ensureLanes(localIndex uint32, decision trafficDecision, hostinfo *HostInfo) {
+	if decision == deleteTunnel || decision == closeTunnel {
+		return
+	}
+	if hostinfo == nil {
+		hostinfo = cm.hostMap.QueryIndex(localIndex)
+		if hostinfo == nil {
+			return
+		}
+	}
+	if hostinfo.isLane() || hostinfo.lanes == nil {
+		return
+	}
+	cm.intf.handshakeManager.EnsureLanes(hostinfo)
 }
 
 func (cm *connectionManager) resetRelayTrafficCheck(hostinfo *HostInfo) {
@@ -329,12 +351,19 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
 		return closeTunnel, hostinfo, nil
 	}
 
+	// Checked ahead of the lane branch: an exhausted counter is fatal for lanes too, and makeLaneTrafficDecision
+	// only reasons about liveness.
 	if hostinfo.ConnectionState != nil && hostinfo.ConnectionState.messageCounter.Load() >= RejectAfterMessages {
 		// Send path can't encrypt a CloseTunnel notify, so just delete locally; the peer recovers via recv_error.
 		hostinfo.logger(cm.l).Error("Dropping tunnel, message counter is exhausted")
 		return deleteTunnel, hostinfo, nil
 	}
 
+	if hostinfo.isLane() {
+		return cm.makeLaneTrafficDecision(hostinfo, now)
+	}
+
+
 	primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]]
 	mainHostInfo := true
 	if primary != nil && primary != hostinfo {
@@ -431,13 +460,72 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
 	return decision, hostinfo, nil
 }
 
+// makeLaneTrafficDecision is the lane-tunnel subset of makeTrafficDecision:
+// no primary/swap/rehandshake logic (lanes are never primary), no punches
+// (lane keepalives egress the lane's own socket and are its NAT keepalive),
+// just alive / test / dead. A dead lane's DeleteHostInfo clears its base slot
+// with backoff, and ensureLanes re-establishes it.
+func (cm *connectionManager) makeLaneTrafficDecision(hostinfo *HostInfo, now time.Time) (trafficDecision, *HostInfo, *HostInfo) {
+	inTraffic, _ := cm.getAndResetTrafficCheck(hostinfo, now)
+
+	if inTraffic {
+		if cm.l.Enabled(context.Background(), slog.LevelDebug) {
+			hostinfo.logger(cm.l).Debug("Tunnel status",
+				"tunnelCheck", m{"state": "alive", "method": "passive"},
+				"laneIndex", hostinfo.laneIndex,
+			)
+		}
+		hostinfo.setPendingDeletion(false)
+		cm.trafficTimer.Add(hostinfo.localIndexId, cm.checkInterval)
+		return doNothing, hostinfo, nil
+	}
+
+	if hostinfo.isPendingDeletion() {
+		hostinfo.logger(cm.l).Info("Tunnel status",
+			"tunnelCheck", m{"state": "dead", "method": "active"},
+			"laneIndex", hostinfo.laneIndex,
+		)
+		return deleteTunnel, hostinfo, nil
+	}
+
+	// Idle lanes are actively kept alive (unlike idle base tunnels, which
+	// just get punches): a lane is datapath infrastructure and its keepalive
+	// doubles as the per-path death detector.
+	decision := doNothing
+	if hostinfo.ConnectionState != nil {
+		decision = sendTestPacket
+	}
+	hostinfo.setPendingDeletion(true)
+	cm.trafficTimer.Add(hostinfo.localIndexId, cm.pendingDeletionInterval)
+	return decision, hostinfo, nil
+}
+
 func (cm *connectionManager) isInactive(hostinfo *HostInfo, now time.Time) (time.Duration, bool) {
 	if cm.dropInactive.Load() == false {
 		// We aren't configured to drop inactive tunnels
 		return 0, false
 	}
 
-	inactiveDuration := now.Sub(hostinfo.lastUsed)
+	// With multiport the data rides the lanes and the base may look idle;
+	// a base is only inactive if its whole lane family is. lastUsed is only
+	// written by this ticker goroutine, so these reads are safe.
+	lastUsed := hostinfo.lastUsed
+	if ls := hostinfo.lanes; ls != nil {
+		for i := range ls.txLanes {
+			if lane := ls.txLanes[i].Load(); lane != nil && lane.lastUsed.After(lastUsed) {
+				lastUsed = lane.lastUsed
+			}
+		}
+		ls.Lock()
+		for _, lane := range ls.peerLanes {
+			if lane.lastUsed.After(lastUsed) {
+				lastUsed = lane.lastUsed
+			}
+		}
+		ls.Unlock()
+	}
+
+	inactiveDuration := now.Sub(lastUsed)
 	if inactiveDuration < cm.getInactivityTimeout() {
 		// It's not considered inactive
 		return inactiveDuration, false

+ 2 - 0
connection_state_test.go

@@ -58,6 +58,7 @@ func runTestHandshake(t *testing.T) (initR, respR *handshake.Result) {
 		cert.Version2, initCreds, verifier,
 		func() (uint32, error) { return 1000, nil },
 		true, header.HandshakeIXPSK0,
+		nil,
 	)
 	require.NoError(t, err)
 
@@ -65,6 +66,7 @@ func runTestHandshake(t *testing.T) (initR, respR *handshake.Result) {
 		cert.Version2, respCreds, verifier,
 		func() (uint32, error) { return 2000, nil },
 		false, header.HandshakeIXPSK0,
+		nil,
 	)
 	require.NoError(t, err)
 

+ 18 - 2
control.go

@@ -67,6 +67,9 @@ type ControlHostInfo struct {
 	CurrentRemote          netip.AddrPort   `json:"currentRemote"`
 	CurrentRelaysToMe      []netip.Addr     `json:"currentRelaysToMe"`
 	CurrentRelaysThroughMe []netip.Addr     `json:"currentRelaysThroughMe"`
+	IsLane                 bool             `json:"isLane,omitempty"`
+	LaneIndex              uint16           `json:"laneIndex,omitempty"`
+	SockIdx                int              `json:"sockIdx,omitempty"`
 }
 
 // Start actually runs nebula, this is a nonblocking call.
@@ -202,10 +205,15 @@ func (c *Control) RebindUDPServer() {
 		return
 	}
 
+	// Every socket needs rebinding, not just the base: with multiport each one is bound to its own lane port, and
+	// even without it the surplus SO_REUSEPORT sockets stay pinned to the interface we came up on otherwise.
+	//
 	// A failure here means we are likely still pinned to the interface we came up on, so the rest of this is
 	// unlikely to help. Say so instead of silently carrying on as if we rebound.
-	if err := c.f.outside.Rebind(); err != nil {
-		c.l.Error("Failed to rebind udp socket", "error", err)
+	for i, w := range c.f.writers {
+		if err := w.Rebind(); err != nil {
+			c.l.Error("Failed to rebind udp socket", "error", err, "writer", i)
+		}
 	}
 
 	// Trigger a lighthouse update, useful for mobile clients that should have an update interval of 0
@@ -357,6 +365,11 @@ func (c *Control) CloseAllTunnels(excludeLighthouses bool) (closed int) {
 	// Grab the hostMap lock to access the Hosts map
 	c.f.hostMap.Lock()
 	for _, relayHost := range c.f.hostMap.Indexes {
+		// Lanes ride along with their base tunnel's shutdown cascade; closing
+		// them individually would race the cascade's identity-checked deletes.
+		if relayHost.isLane() {
+			continue
+		}
 		if _, ok := relayingHosts[relayHost.vpnAddrs[0]]; !ok {
 			hostInfos = append(hostInfos, relayHost)
 		}
@@ -385,6 +398,9 @@ func copyHostInfo(h *HostInfo, preferredRanges []netip.Prefix) ControlHostInfo {
 		CurrentRelaysToMe:      h.relayState.CopyRelayIps(),
 		CurrentRelaysThroughMe: h.relayState.CopyRelayForIps(),
 		CurrentRemote:          h.GetRemote(),
+		IsLane:                 h.isLane(),
+		LaneIndex:              h.laneIndex,
+		SockIdx:                h.sockIdx,
 	}
 
 	for i, a := range h.vpnAddrs {

+ 1 - 1
control_test.go

@@ -105,7 +105,7 @@ func TestControl_GetHostInfoByVpnIp(t *testing.T) {
 	}
 
 	// Make sure we don't have any unexpected fields
-	assertFields(t, []string{"VpnAddrs", "LocalIndex", "RemoteIndex", "RemoteAddrs", "Cert", "MessageCounter", "CurrentRemote", "CurrentRelaysToMe", "CurrentRelaysThroughMe"}, thi)
+	assertFields(t, []string{"VpnAddrs", "LocalIndex", "RemoteIndex", "RemoteAddrs", "Cert", "MessageCounter", "CurrentRemote", "CurrentRelaysToMe", "CurrentRelaysThroughMe", "IsLane", "LaneIndex", "SockIdx"}, thi)
 	assert.Equal(t, &expectedInfo, thi)
 	test.AssertDeepCopyEqual(t, &expectedInfo, thi)
 

+ 30 - 0
examples/config.yml

@@ -182,6 +182,36 @@ listen:
 # This option is only supported on Linux.
 #routines: 1
 
+# EXPERIMENTAL: multiport lanes give each pair of hosts multiple underlay UDP
+# flows so overlay traffic is no longer bottlenecked by a single 5-tuple
+# (one ECMP path, one NIC RSS queue, one per-flow policer). Socket i binds
+# listen.port+i instead of sharing one port via SO_REUSEPORT, and one extra
+# tunnel ("lane") per routine is negotiated with capable peers: lane i
+# handshakes from local port listen.port+i to the peer's advertised
+# base+(i mod peer_ports). Each lane is a full Noise session with its own
+# keys, nonce counter and replay window, so flows taking different paths
+# never fight over shared replay state.
+#
+# Peers negotiate lanes in the handshake; vanilla peers get a single normal
+# tunnel. All control traffic (handshakes, lighthouse, punching, relays) and
+# the data fallback stay on the base tunnel/port. Lanes are established after
+# the base tunnel comes up, are kept alive with their own keepalives, and
+# traffic falls back to the base tunnel while a lane is down.
+#
+# Requirements: routines > 1, Linux, and the port range
+# [listen.port, listen.port+routines-1] reachable through firewalls on both
+# sides (peers behind NAT fall back to the base tunnel). With listen.port 0
+# the base port is dynamic and the next routines-1 ports above it are
+# claimed. Enabled by default when the requirements hold; degrades to a
+# single port otherwise. Not reloadable.
+#multiport:
+  # Bind `routines` consecutive UDP ports and negotiate lanes with peers.
+  #enabled: true
+  # How many lanes to run, counting the base tunnel as lane 0. 0 (default)
+  # means one per routine. Lowering this bounds how many extra tunnels each
+  # peer pair maintains; routines without a lane use the base tunnel.
+  #lanes: 0
+
 punchy:
   # Continues to punch inbound/outbound at a regular interval to avoid expiration of firewall nat mappings
   # This setting is reloadable.

+ 14 - 2
handshake/handshake.proto

@@ -23,7 +23,19 @@ message NebulaHandshakeDetails {
   // hand-written parser silently skips it on read.
   uint64 Cookie = 4 [deprecated = true];
   uint64 Time = 5;
+  // Multiport lane negotiation. Absent on hosts without multiport enabled;
+  // vanilla nebula treats 6 and 7 as unknown fields and skips them.
+  LaneDetails InitiatorLanes = 6;
+  LaneDetails ResponderLanes = 7;
   uint32 CertVersion = 8;
-  // reserved for WIP multiport
-  reserved 6, 7;
+}
+
+// LaneDetails advertises a host's multiport lane capability. On a base
+// handshake LaneIndex is 0 and PortCount/BasePort describe the sender's
+// consecutively bound UDP ports. On a lane handshake the initiator sets
+// LaneIndex to its (nonzero) lane number.
+message LaneDetails {
+  uint32 PortCount = 1;
+  uint32 BasePort = 2;
+  uint32 LaneIndex = 3;
 }

+ 1 - 0
handshake/helpers_test.go

@@ -71,6 +71,7 @@ func newTestMachine(
 		cs.version, cs.getCredential,
 		verifier, func() (uint32, error) { return localIndex, nil },
 		initiator, header.HandshakeIXPSK0,
+		nil,
 	)
 	require.NoError(t, err)
 	return m

+ 33 - 1
handshake/machine.go

@@ -39,6 +39,13 @@ type Result struct {
 	HandshakeTime uint64
 	MessageIndex  uint64 // number of messages exchanged during the handshake
 	Initiator     bool
+
+	// Multiport lane negotiation, from the peer's LaneDetails. All zero when
+	// the peer did not advertise (vanilla peer or multiport disabled).
+	// PeerLaneIndex is nonzero only on the responder side of a lane handshake.
+	PeerPortCount uint32
+	PeerBasePort  uint32
+	PeerLaneIndex uint32
 }
 
 // Machine drives a Noise handshake through N messages. It handles Noise
@@ -61,6 +68,7 @@ type Machine struct {
 	verifier       CertVerifier
 	result         *Result
 	msgs           []msgFlags
+	lanes          *LaneDetails // our multiport advert; nil emits a vanilla payload
 	myVersion      cert.Version
 	subtype        header.MessageSubType
 	indexAllocated bool
@@ -73,6 +81,8 @@ type Machine struct {
 // the noise pattern and the per-message content layout. The credential for
 // `version` is fetched via getCred and used to seed the noise.HandshakeState.
 // IndexAllocator is called lazily when the first outgoing payload is built.
+// lanes, when non-nil, is emitted as this side's multiport advert on every
+// payload-bearing message; nil produces byte-identical vanilla payloads.
 func NewMachine(
 	version cert.Version,
 	getCred GetCredentialFunc,
@@ -80,6 +90,7 @@ func NewMachine(
 	allocIndex IndexAllocator,
 	initiator bool,
 	subtype header.MessageSubType,
+	lanes *LaneDetails,
 ) (*Machine, error) {
 	info, err := subtypeInfoFor(subtype)
 	if err != nil {
@@ -103,6 +114,7 @@ func NewMachine(
 		getCred:    getCred,
 		allocIndex: allocIndex,
 		verifier:   verifier,
+		lanes:      lanes,
 		myVersion:  version,
 		result: &Result{
 			Initiator: initiator,
@@ -298,7 +310,8 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
 	}
 
 	// Assert the payload contains exactly what we expect
-	hasPayloadData := payload.InitiatorIndex != 0 || payload.ResponderIndex != 0 || payload.Time != 0
+	hasPayloadData := payload.InitiatorIndex != 0 || payload.ResponderIndex != 0 || payload.Time != 0 ||
+		payload.InitiatorLanes != nil || payload.ResponderLanes != nil
 	if hasPayloadData != flags.expectsPayload {
 		m.failed = true
 		return ErrUnexpectedContent
@@ -327,6 +340,23 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
 		m.result.RemoteIndex = remoteIndex
 		m.result.HandshakeTime = payload.Time
 		m.payloadSet = true
+
+		// Multiport advert from the peer's side of the exchange. Out-of-range
+		// values mean a peer we can't pair lanes with; ignore the advert
+		// rather than failing the handshake — the tunnel itself is fine, it
+		// just won't get lanes. Semantic policing (index bounds vs advert,
+		// port-count caps) belongs to the handshake manager.
+		var peerLanes *LaneDetails
+		if m.result.Initiator {
+			peerLanes = payload.ResponderLanes
+		} else {
+			peerLanes = payload.InitiatorLanes
+		}
+		if peerLanes != nil && peerLanes.BasePort <= 0xffff && peerLanes.PortCount <= 0xffff {
+			m.result.PeerPortCount = peerLanes.PortCount
+			m.result.PeerBasePort = peerLanes.BasePort
+			m.result.PeerLaneIndex = peerLanes.LaneIndex
+		}
 	}
 
 	// Process certificate
@@ -397,9 +427,11 @@ func (m *Machine) marshalOutgoing(flags msgFlags) ([]byte, error) {
 
 		if m.result.Initiator {
 			p.InitiatorIndex = m.result.LocalIndex
+			p.InitiatorLanes = m.lanes
 		} else {
 			p.ResponderIndex = m.result.LocalIndex
 			p.InitiatorIndex = m.result.RemoteIndex
+			p.ResponderLanes = m.lanes
 		}
 		p.Time = uint64(time.Now().UnixNano())
 	}

+ 113 - 0
handshake/machine_lanes_test.go

@@ -0,0 +1,113 @@
+package handshake
+
+import (
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/cert"
+	ct "github.com/slackhq/nebula/cert_test"
+	"github.com/slackhq/nebula/header"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// newTestLaneMachine is newTestMachine with a lane advert attached.
+func newTestLaneMachine(
+	t *testing.T,
+	cs *testCertState,
+	verifier CertVerifier,
+	initiator bool,
+	localIndex uint32,
+	lanes *LaneDetails,
+) *Machine {
+	t.Helper()
+	m, err := NewMachine(
+		cs.version, cs.getCredential,
+		verifier, func() (uint32, error) { return localIndex, nil },
+		initiator, header.HandshakeIXPSK0,
+		lanes,
+	)
+	require.NoError(t, err)
+	return m
+}
+
+func doFullLaneHandshake(t *testing.T, initLanes, respLanes *LaneDetails) (initR, respR *Result) {
+	t.Helper()
+	ca, _, caKey, _ := ct.NewTestCaCert(
+		cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil,
+	)
+	caPool := ct.NewTestCAPool(ca)
+	v := testVerifier(caPool)
+
+	initCS := newTestCertState(t, ca, caKey, "initiator", []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")})
+	respCS := newTestCertState(t, ca, caKey, "responder", []netip.Prefix{netip.MustParsePrefix("10.0.0.2/24")})
+
+	initM := newTestLaneMachine(t, initCS, v, true, 1000, initLanes)
+	respM := newTestLaneMachine(t, respCS, v, false, 2000, respLanes)
+
+	msg1, err := initM.Initiate(nil)
+	require.NoError(t, err)
+
+	resp, respR, err := respM.ProcessPacket(nil, msg1)
+	require.NoError(t, err)
+	require.NotNil(t, respR)
+
+	_, initR, err = initM.ProcessPacket(nil, resp)
+	require.NoError(t, err)
+	require.NotNil(t, initR)
+	return initR, respR
+}
+
+func TestMachineLaneAdvertBothSides(t *testing.T) {
+	initR, respR := doFullLaneHandshake(t,
+		&LaneDetails{PortCount: 8, BasePort: 4242},
+		&LaneDetails{PortCount: 4, BasePort: 5353},
+	)
+
+	// Each side's Result carries the peer's advert.
+	assert.Equal(t, uint32(4), initR.PeerPortCount)
+	assert.Equal(t, uint32(5353), initR.PeerBasePort)
+	assert.Equal(t, uint32(0), initR.PeerLaneIndex)
+
+	assert.Equal(t, uint32(8), respR.PeerPortCount)
+	assert.Equal(t, uint32(4242), respR.PeerBasePort)
+	assert.Equal(t, uint32(0), respR.PeerLaneIndex)
+}
+
+func TestMachineLaneHandshakeCarriesLaneIndex(t *testing.T) {
+	// A lane handshake: initiator tags its lane number; responder still
+	// adverts (harmlessly).
+	initR, respR := doFullLaneHandshake(t,
+		&LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3},
+		&LaneDetails{PortCount: 4, BasePort: 5353},
+	)
+
+	assert.Equal(t, uint32(3), respR.PeerLaneIndex)
+	assert.Equal(t, uint32(0), initR.PeerLaneIndex)
+}
+
+func TestMachineLaneAdvertAsymmetric(t *testing.T) {
+	// Vanilla initiator, multiport responder and vice versa: the nil side
+	// yields all-zero peer fields on the other end.
+	initR, respR := doFullLaneHandshake(t, nil, &LaneDetails{PortCount: 4, BasePort: 5353})
+	assert.Equal(t, uint32(4), initR.PeerPortCount)
+	assert.Equal(t, uint32(0), respR.PeerPortCount)
+	assert.Equal(t, uint32(0), respR.PeerBasePort)
+
+	initR, respR = doFullLaneHandshake(t, &LaneDetails{PortCount: 8, BasePort: 4242}, nil)
+	assert.Equal(t, uint32(0), initR.PeerPortCount)
+	assert.Equal(t, uint32(8), respR.PeerPortCount)
+}
+
+func TestMachineLaneAdvertOutOfRangeIgnored(t *testing.T) {
+	// A BasePort that can't be a real UDP port is ignored, not fatal.
+	initR, respR := doFullLaneHandshake(t,
+		&LaneDetails{PortCount: 8, BasePort: 70000},
+		&LaneDetails{PortCount: 4, BasePort: 5353},
+	)
+	assert.Equal(t, uint32(0), respR.PeerPortCount)
+	assert.Equal(t, uint32(0), respR.PeerBasePort)
+	// The sane side still negotiates.
+	assert.Equal(t, uint32(4), initR.PeerPortCount)
+}

+ 2 - 0
handshake/machine_test.go

@@ -444,6 +444,7 @@ func TestMachineThreeMessagePattern(t *testing.T) {
 		initCS.getCredential, v,
 		func() (uint32, error) { return 1000, nil },
 		true, header.HandshakeXXPSK0,
+		nil,
 	)
 	require.NoError(t, err)
 
@@ -452,6 +453,7 @@ func TestMachineThreeMessagePattern(t *testing.T) {
 		respCS.getCredential, v,
 		func() (uint32, error) { return 2000, nil },
 		false, header.HandshakeXXPSK0,
+		nil,
 	)
 	require.NoError(t, err)
 

+ 110 - 0
handshake/payload.go

@@ -20,6 +20,19 @@ type Payload struct {
 	ResponderIndex uint32
 	Time           uint64
 	CertVersion    uint32
+
+	// Multiport lane negotiation; nil when the sender has multiport disabled
+	// (which keeps the encoded payload byte-identical to a vanilla one).
+	InitiatorLanes *LaneDetails
+	ResponderLanes *LaneDetails
+}
+
+// LaneDetails advertises multiport lane capability. LaneIndex is zero on base
+// handshakes and the initiator's lane number (>= 1) on lane handshakes.
+type LaneDetails struct {
+	PortCount uint32
+	BasePort  uint32
+	LaneIndex uint32
 }
 
 // Proto field numbers for NebulaHandshakeDetails
@@ -28,9 +41,18 @@ const (
 	fieldInitiatorIndex = 2 // uint32
 	fieldResponderIndex = 3 // uint32
 	fieldTime           = 5 // uint64
+	fieldInitiatorLanes = 6 // LaneDetails
+	fieldResponderLanes = 7 // LaneDetails
 	fieldCertVersion    = 8 // uint32
 )
 
+// Proto field numbers for LaneDetails
+const (
+	fieldLanePortCount = 1 // uint32
+	fieldLaneBasePort  = 2 // uint32
+	fieldLaneLaneIndex = 3 // uint32
+)
+
 // MarshalPayload encodes a handshake payload in protobuf wire format compatible
 // with NebulaHandshake{Details: NebulaHandshakeDetails{...}}.
 // Returns out (which may be nil), with the marshalled Payload appended to it.
@@ -53,6 +75,14 @@ func MarshalPayload(out []byte, p Payload) []byte {
 		details = protowire.AppendTag(details, fieldTime, protowire.VarintType)
 		details = protowire.AppendVarint(details, p.Time)
 	}
+	if p.InitiatorLanes != nil {
+		details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
+		details = protowire.AppendBytes(details, p.InitiatorLanes.marshal(nil))
+	}
+	if p.ResponderLanes != nil {
+		details = protowire.AppendTag(details, fieldResponderLanes, protowire.BytesType)
+		details = protowire.AppendBytes(details, p.ResponderLanes.marshal(nil))
+	}
 	if p.CertVersion != 0 {
 		details = protowire.AppendTag(details, fieldCertVersion, protowire.VarintType)
 		details = protowire.AppendVarint(details, uint64(p.CertVersion))
@@ -64,6 +94,20 @@ func MarshalPayload(out []byte, p Payload) []byte {
 	return out
 }
 
+// marshal appends the LaneDetails submessage fields to out. All fields are
+// emitted unconditionally: a LaneDetails is only present at all when multiport
+// is negotiating, and explicit zeros keep the parser's presence semantics
+// trivial.
+func (d *LaneDetails) marshal(out []byte) []byte {
+	out = protowire.AppendTag(out, fieldLanePortCount, protowire.VarintType)
+	out = protowire.AppendVarint(out, uint64(d.PortCount))
+	out = protowire.AppendTag(out, fieldLaneBasePort, protowire.VarintType)
+	out = protowire.AppendVarint(out, uint64(d.BasePort))
+	out = protowire.AppendTag(out, fieldLaneLaneIndex, protowire.VarintType)
+	out = protowire.AppendVarint(out, uint64(d.LaneIndex))
+	return out
+}
+
 // UnmarshalPayload decodes a protobuf-encoded NebulaHandshake message.
 func UnmarshalPayload(b []byte) (Payload, error) {
 	var p Payload
@@ -161,6 +205,72 @@ func unmarshalPayloadDetails(p *Payload, b []byte) error {
 			}
 			p.CertVersion = uint32(v)
 			b = b[n:]
+		case fieldInitiatorLanes:
+			if typ != protowire.BytesType {
+				return errInvalidHandshakeDetails
+			}
+			v, n := protowire.ConsumeBytes(b)
+			if n < 0 {
+				return errInvalidHandshakeDetails
+			}
+			p.InitiatorLanes = new(LaneDetails)
+			if err := unmarshalLaneDetails(p.InitiatorLanes, v); err != nil {
+				return err
+			}
+			b = b[n:]
+		case fieldResponderLanes:
+			if typ != protowire.BytesType {
+				return errInvalidHandshakeDetails
+			}
+			v, n := protowire.ConsumeBytes(b)
+			if n < 0 {
+				return errInvalidHandshakeDetails
+			}
+			p.ResponderLanes = new(LaneDetails)
+			if err := unmarshalLaneDetails(p.ResponderLanes, v); err != nil {
+				return err
+			}
+			b = b[n:]
+		default:
+			n := protowire.ConsumeFieldValue(num, typ, b)
+			if n < 0 {
+				return errInvalidHandshakeDetails
+			}
+			b = b[n:]
+		}
+	}
+	return nil
+}
+
+func unmarshalLaneDetails(d *LaneDetails, b []byte) error {
+	for len(b) > 0 {
+		num, typ, n := protowire.ConsumeTag(b)
+		if n < 0 {
+			return errInvalidHandshakeDetails
+		}
+		b = b[n:]
+
+		// Same contract as the details parser: known fields hard-fail on a
+		// wire-type mismatch, unknown fields are skipped, repeated singular
+		// fields follow proto3 last-wins.
+		switch num {
+		case fieldLanePortCount, fieldLaneBasePort, fieldLaneLaneIndex:
+			if typ != protowire.VarintType {
+				return errInvalidHandshakeDetails
+			}
+			v, n := protowire.ConsumeVarint(b)
+			if n < 0 || v > math.MaxUint32 {
+				return errInvalidHandshakeDetails
+			}
+			switch num {
+			case fieldLanePortCount:
+				d.PortCount = uint32(v)
+			case fieldLaneBasePort:
+				d.BasePort = uint32(v)
+			case fieldLaneLaneIndex:
+				d.LaneIndex = uint32(v)
+			}
+			b = b[n:]
 		default:
 			n := protowire.ConsumeFieldValue(num, typ, b)
 			if n < 0 {

+ 137 - 11
handshake/payload_test.go

@@ -117,23 +117,134 @@ func TestPayloadUnknownFields(t *testing.T) {
 		assert.Equal(t, uint32(88), got.ResponderIndex)
 	})
 
-	t.Run("reserved fields 6 and 7 are skipped", func(t *testing.T) {
-		// Fields 6 and 7 are reserved in the proto definition
+	t.Run("unknown field inside LaneDetails is skipped", func(t *testing.T) {
+		var lane []byte
+		lane = protowire.AppendTag(lane, fieldLanePortCount, protowire.VarintType)
+		lane = protowire.AppendVarint(lane, 4)
+		lane = protowire.AppendTag(lane, 50, protowire.VarintType) // unknown subfield
+		lane = protowire.AppendVarint(lane, 9999)
+		lane = protowire.AppendTag(lane, fieldLaneBasePort, protowire.VarintType)
+		lane = protowire.AppendVarint(lane, 4242)
+
 		var details []byte
 		details = protowire.AppendTag(details, fieldInitiatorIndex, protowire.VarintType)
 		details = protowire.AppendVarint(details, 100)
-		details = protowire.AppendTag(details, 6, protowire.VarintType)
-		details = protowire.AppendVarint(details, 1)
-		details = protowire.AppendTag(details, 7, protowire.VarintType)
-		details = protowire.AppendVarint(details, 2)
+		details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
+		details = protowire.AppendBytes(details, lane)
 
-		var data []byte
-		data = protowire.AppendTag(data, 1, protowire.BytesType)
-		data = protowire.AppendBytes(data, details)
+		got, err := UnmarshalPayload(wrapDetails(details))
+		require.NoError(t, err)
+		assert.Equal(t, uint32(100), got.InitiatorIndex)
+		require.NotNil(t, got.InitiatorLanes)
+		assert.Equal(t, uint32(4), got.InitiatorLanes.PortCount)
+		assert.Equal(t, uint32(4242), got.InitiatorLanes.BasePort)
+	})
+}
+
+func TestPayloadLaneDetails(t *testing.T) {
+	t.Run("round trip both sides", func(t *testing.T) {
+		data := MarshalPayload(nil, Payload{
+			InitiatorIndex: 12345,
+			Time:           999,
+			InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3},
+			ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353},
+		})
 
 		got, err := UnmarshalPayload(data)
 		require.NoError(t, err)
-		assert.Equal(t, uint32(100), got.InitiatorIndex)
+		require.NotNil(t, got.InitiatorLanes)
+		assert.Equal(t, LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3}, *got.InitiatorLanes)
+		require.NotNil(t, got.ResponderLanes)
+		assert.Equal(t, LaneDetails{PortCount: 4, BasePort: 5353}, *got.ResponderLanes)
+	})
+
+	t.Run("zero-valued LaneDetails survives the round trip", func(t *testing.T) {
+		// Presence is what negotiation keys on; an all-zero advert must not
+		// decay to nil.
+		data := MarshalPayload(nil, Payload{
+			InitiatorIndex: 1,
+			InitiatorLanes: &LaneDetails{},
+		})
+		got, err := UnmarshalPayload(data)
+		require.NoError(t, err)
+		require.NotNil(t, got.InitiatorLanes)
+		assert.Equal(t, LaneDetails{}, *got.InitiatorLanes)
+		assert.Nil(t, got.ResponderLanes)
+	})
+
+	t.Run("nil lanes marshal byte-identical to a vanilla payload", func(t *testing.T) {
+		p := Payload{
+			Cert:           []byte("cert"),
+			CertVersion:    2,
+			InitiatorIndex: 100,
+			Time:           999,
+		}
+		// The vanilla encoding of the same fields, built by hand in field order.
+		var details []byte
+		details = protowire.AppendTag(details, fieldCert, protowire.BytesType)
+		details = protowire.AppendBytes(details, p.Cert)
+		details = protowire.AppendTag(details, fieldInitiatorIndex, protowire.VarintType)
+		details = protowire.AppendVarint(details, uint64(p.InitiatorIndex))
+		details = protowire.AppendTag(details, fieldTime, protowire.VarintType)
+		details = protowire.AppendVarint(details, p.Time)
+		details = protowire.AppendTag(details, fieldCertVersion, protowire.VarintType)
+		details = protowire.AppendVarint(details, uint64(p.CertVersion))
+
+		assert.Equal(t, wrapDetails(details), MarshalPayload(nil, p))
+	})
+
+	t.Run("lane field with wrong wire type rejected", func(t *testing.T) {
+		for _, field := range []protowire.Number{fieldInitiatorLanes, fieldResponderLanes} {
+			var details []byte
+			details = protowire.AppendTag(details, field, protowire.VarintType)
+			details = protowire.AppendVarint(details, 1)
+			_, err := UnmarshalPayload(wrapDetails(details))
+			assert.Error(t, err)
+		}
+	})
+
+	t.Run("lane subfield with wrong wire type rejected", func(t *testing.T) {
+		var lane []byte
+		lane = protowire.AppendTag(lane, fieldLanePortCount, protowire.BytesType)
+		lane = protowire.AppendBytes(lane, []byte{1, 2, 3})
+
+		var details []byte
+		details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
+		details = protowire.AppendBytes(details, lane)
+		_, err := UnmarshalPayload(wrapDetails(details))
+		assert.Error(t, err)
+	})
+
+	t.Run("truncated LaneDetails submessage rejected", func(t *testing.T) {
+		var details []byte
+		details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
+		details = append(details, 0x0a, 0x01, 0x02) // length 10, only 2 bytes
+		_, err := UnmarshalPayload(wrapDetails(details))
+		assert.Error(t, err)
+	})
+
+	t.Run("truncated varint inside LaneDetails rejected", func(t *testing.T) {
+		var lane []byte
+		lane = protowire.AppendTag(lane, fieldLaneBasePort, protowire.VarintType)
+		lane = append(lane, 0x80) // incomplete varint
+
+		var details []byte
+		details = protowire.AppendTag(details, fieldResponderLanes, protowire.BytesType)
+		details = protowire.AppendBytes(details, lane)
+		_, err := UnmarshalPayload(wrapDetails(details))
+		assert.Error(t, err)
+	})
+
+	t.Run("lane subfield varint overflow rejected", func(t *testing.T) {
+		var lane []byte
+		lane = protowire.AppendTag(lane, fieldLaneLaneIndex, protowire.VarintType)
+		lane = protowire.AppendVarint(lane, math.MaxUint32+1)
+
+		var details []byte
+		details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
+		details = protowire.AppendBytes(details, lane)
+		_, err := UnmarshalPayload(wrapDetails(details))
+		assert.Error(t, err)
 	})
 }
 
@@ -328,6 +439,12 @@ func FuzzPayload(f *testing.F) {
 		Time:           3,
 		CertVersion:    2,
 	}))
+	f.Add(MarshalPayload(nil, Payload{
+		InitiatorIndex: 1,
+		Time:           3,
+		InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 2},
+		ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353},
+	}))
 	f.Add([]byte{})
 	f.Add([]byte{0xff})
 
@@ -357,5 +474,14 @@ func payloadsEqual(a, b Payload) bool {
 		a.InitiatorIndex == b.InitiatorIndex &&
 		a.ResponderIndex == b.ResponderIndex &&
 		a.Time == b.Time &&
-		a.CertVersion == b.CertVersion
+		a.CertVersion == b.CertVersion &&
+		laneDetailsEqual(a.InitiatorLanes, b.InitiatorLanes) &&
+		laneDetailsEqual(a.ResponderLanes, b.ResponderLanes)
+}
+
+func laneDetailsEqual(a, b *LaneDetails) bool {
+	if a == nil || b == nil {
+		return a == b
+	}
+	return *a == *b
 }

+ 438 - 10
handshake_manager.go

@@ -50,6 +50,14 @@ type HandshakeConfig struct {
 	retries       int64
 	triggerBuffer int
 
+	// Multiport lane parameters; laneCount == 0 means multiport is disabled.
+	// laneCount includes implicit lane 0 (the base tunnel), so lanes
+	// 1..laneCount-1 are initiated. lanePortCount/laneBasePort describe our
+	// own bound port range and are advertised in every handshake payload.
+	laneCount     int
+	lanePortCount uint16
+	laneBasePort  uint16
+
 	messageMetrics *MessageMetrics
 }
 
@@ -65,11 +73,15 @@ type HandshakeManager struct {
 	outside                udp.Conn
 	config                 HandshakeConfig
 	OutboundHandshakeTimer *LockingTimerWheel[netip.Addr]
-	messageMetrics         *MessageMetrics
-	metricInitiated        metrics.Counter
-	metricTimedOut         metrics.Counter
-	f                      *Interface
-	l                      *slog.Logger
+	// OutboundLaneTimer drives lane handshake retries. Lanes never enter
+	// vpnIps (they would collide with base handshakes for the same address),
+	// so their wheel is keyed by pending localIndexId instead.
+	OutboundLaneTimer *LockingTimerWheel[uint32]
+	messageMetrics    *MessageMetrics
+	metricInitiated   metrics.Counter
+	metricTimedOut    metrics.Counter
+	f                 *Interface
+	l                 *slog.Logger
 
 	// can be used to trigger outbound handshake for the given vpnIp
 	trigger chan netip.Addr
@@ -88,6 +100,11 @@ type HandshakeHostInfo struct {
 
 	hostinfo *HostInfo
 	machine  *handshake.Machine // The handshake state machine, set during stage 0 (initiator) or beginHandshake (responder multi-message)
+
+	// laneTarget is the single pinned destination for a lane handshake
+	// (base-tunnel remote IP at the peer's lane port). Lane handshakes never
+	// broadcast to the RemoteList.
+	laneTarget netip.AddrPort
 }
 
 func (hh *HandshakeHostInfo) cachePacket(l *slog.Logger, t header.MessageType, st header.MessageSubType, packet []byte, f packetCallback, m *cachedPacketMetrics) {
@@ -125,6 +142,7 @@ func NewHandshakeManager(l *slog.Logger, mainHostMap *HostMap, lightHouse *Light
 		config:                 config,
 		trigger:                make(chan netip.Addr, config.triggerBuffer),
 		OutboundHandshakeTimer: NewLockingTimerWheel[netip.Addr](config.tryInterval, hsTimeout(config.retries, config.tryInterval)),
+		OutboundLaneTimer:      NewLockingTimerWheel[uint32](config.tryInterval, hsTimeout(config.retries, config.tryInterval)),
 		messageMetrics:         config.messageMetrics,
 		metricInitiated:        metrics.GetOrRegisterCounter("handshake_manager.initiated", nil),
 		metricTimedOut:         metrics.GetOrRegisterCounter("handshake_manager.timed_out", nil),
@@ -144,6 +162,7 @@ func (hm *HandshakeManager) Run(ctx context.Context) {
 			hm.handleOutbound(vpnIP, true)
 		case now := <-clockSource.C:
 			hm.NextOutboundHandshakeTimerTick(now)
+			hm.NextOutboundLaneTimerTick(now)
 		}
 	}
 }
@@ -535,6 +554,10 @@ func (hm *HandshakeManager) DeleteHostInfo(hostinfo *HostInfo) {
 
 func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) {
 	for _, addr := range hostinfo.vpnAddrs {
+		// Only delete the pending entry if it is actually ours. Lane
+		// handshakes never live in vpnIps, and an unconditional delete here
+		// could evict a concurrently pending base handshake for the same
+		// address.
 		if cur, ok := hm.vpnIps[addr]; ok && cur.hostinfo == hostinfo {
 			delete(hm.vpnIps, addr)
 		}
@@ -672,6 +695,7 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
 		v, cs.GetCredential,
 		hm.certVerifier(), func() (uint32, error) { return hm.allocateIndex(hh) },
 		true, header.HandshakeIXPSK0,
+		hm.laneAdvert(uint32(hh.hostinfo.laneIndex)),
 	)
 	if err != nil {
 		hm.f.l.Error("Failed to create handshake machine",
@@ -695,6 +719,215 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
 	return true
 }
 
+// laneAdvert returns our multiport advert for a handshake payload, or nil
+// when multiport is disabled (which keeps the payload byte-identical to
+// vanilla). laneIndex is 0 for base handshakes and our lane number for lane
+// handshakes.
+func (hm *HandshakeManager) laneAdvert(laneIndex uint32) *handshake.LaneDetails {
+	if hm.config.laneCount == 0 {
+		return nil
+	}
+	return &handshake.LaneDetails{
+		PortCount: uint32(hm.config.lanePortCount),
+		BasePort:  uint32(hm.config.laneBasePort),
+		LaneIndex: laneIndex,
+	}
+}
+
+// maybeAllocLaneState attaches a laneState to a just-completed base tunnel
+// when both sides advertised multiport. Must run before the hostinfo becomes
+// visible in the hostmap: the data plane reads base.lanes lock-free.
+func (hm *HandshakeManager) maybeAllocLaneState(hostinfo *HostInfo, result *handshake.Result) {
+	if hm.config.laneCount == 0 || result.PeerPortCount == 0 || result.PeerLaneIndex != 0 {
+		return
+	}
+	peerPorts := result.PeerPortCount
+	if peerPorts > 256 {
+		// A certified-but-hostile peer doesn't get to size our state.
+		peerPorts = 256
+	}
+	hostinfo.lanes = newLaneState(hm.f.routines, uint16(peerPorts), uint16(result.PeerBasePort))
+}
+
+// EnsureLanes starts lane handshakes for every empty, non-pending, retry-due
+// slot of a base tunnel. Called on base handshake completion (both sides) and
+// from the connection manager's per-tunnel tick, so a dead lane re-establishes
+// within one check interval, subject to per-slot backoff.
+func (hm *HandshakeManager) EnsureLanes(base *HostInfo) {
+	ls := base.lanes
+	if ls == nil || hm.config.laneCount <= 1 {
+		return
+	}
+
+	now := time.Now()
+	var starts []int
+	ls.Lock()
+	n := min(len(ls.txLanes), hm.config.laneCount)
+	for i := 1; i < n; i++ {
+		if ls.txLanes[i].Load() != nil || ls.txPending[i] || now.Before(ls.txRetryAt[i]) {
+			continue
+		}
+		ls.txPending[i] = true
+		starts = append(starts, i)
+	}
+	ls.Unlock()
+
+	for _, i := range starts {
+		hm.startLaneHandshake(base, i)
+	}
+}
+
+// startLaneHandshake initiates lane i of base: a full Noise handshake from
+// local socket i to the peer's advertised lane port. The caller has already
+// claimed the slot (txPending[i] = true); every failure path must release it
+// via noteLaneFailure.
+func (hm *HandshakeManager) startLaneHandshake(base *HostInfo, i int) {
+	ls := base.lanes
+
+	remote := base.GetRemote()
+	if !remote.IsValid() || ls.peerPortCount == 0 {
+		// Relay-only peer (or a zero advert that should not have allocated
+		// lanes). Retried if a direct path shows up later.
+		ls.noteLaneFailure(i)
+		return
+	}
+	target := netip.AddrPortFrom(remote.Addr(), ls.peerBasePort+uint16(i%int(ls.peerPortCount)))
+
+	hostinfo := &HostInfo{
+		vpnAddrs:        slices.Clone(base.vpnAddrs),
+		HandshakePacket: make(map[uint8][]byte, 0),
+		relayState: RelayState{
+			relays:         nil,
+			relayForByAddr: map[netip.Addr]*Relay{},
+			relayForByIdx:  map[uint32]*Relay{},
+		},
+		// A private RemoteList: SetRemote must never leak lane ports into the
+		// shared lighthouse-learned cache that base handshakes broadcast to.
+		remotes:   NewRemoteList(base.vpnAddrs, nil),
+		sockIdx:   i,
+		laneIndex: uint16(i),
+		laneOwned: true,
+		parent:    base,
+	}
+
+	hh := &HandshakeHostInfo{
+		hostinfo:   hostinfo,
+		startTime:  time.Now(),
+		laneTarget: target,
+	}
+	if cs := base.ConnectionState; cs != nil && cs.myCert != nil {
+		// Pin the lane to the base tunnel's negotiated cert version rather
+		// than re-running version selection.
+		hh.initiatingVersionOverride = cs.myCert.Version()
+	}
+
+	// Build stage 0 eagerly: allocateIndex registers hh in hm.indexes so
+	// continueHandshake can find it, and gives us the key for the lane timer.
+	hh.Lock()
+	ok := hm.buildStage0Packet(hh)
+	hh.Unlock()
+	if !ok {
+		ls.noteLaneFailure(i)
+		return
+	}
+
+	hostinfo.logger(hm.l).Info("Lane handshake started",
+		"laneIndex", i,
+		"udpAddr", target,
+		"initiatorIndex", hostinfo.localIndexId,
+	)
+	hm.metricInitiated.Inc(1)
+	hm.OutboundLaneTimer.Add(hostinfo.localIndexId, hm.config.tryInterval)
+	hm.handleOutboundLane(hostinfo.localIndexId)
+}
+
+func (hm *HandshakeManager) NextOutboundLaneTimerTick(now time.Time) {
+	hm.OutboundLaneTimer.Advance(now)
+	for {
+		idx, has := hm.OutboundLaneTimer.Purge()
+		if !has {
+			break
+		}
+		hm.handleOutboundLane(idx)
+	}
+}
+
+// handleOutboundLane is handleOutbound for lane handshakes: single pinned
+// target, egress via the lane's own socket, no lighthouse and no relays.
+func (hm *HandshakeManager) handleOutboundLane(localIndex uint32) {
+	hh := hm.queryIndex(localIndex)
+	if hh == nil || !hh.hostinfo.isLane() {
+		return
+	}
+	hh.Lock()
+	defer hh.Unlock()
+
+	hostinfo := hh.hostinfo
+	if hh.counter >= hm.config.retries {
+		hostinfo.logger(hm.l).Info("Lane handshake timed out",
+			"laneIndex", hostinfo.laneIndex,
+			"udpAddr", hh.laneTarget,
+			"initiatorIndex", hostinfo.localIndexId,
+			"durationNs", time.Since(hh.startTime).Nanoseconds(),
+		)
+		hm.metricTimedOut.Inc(1)
+		hm.DeleteHostInfo(hostinfo)
+		hostinfo.parent.lanes.noteLaneFailure(int(hostinfo.laneIndex))
+		return
+	}
+	hh.counter++
+
+	stage0 := hostinfo.HandshakePacket[handshakePacketStage0]
+	hm.messageMetrics.Tx(header.Handshake, hh.machine.Subtype(), 1)
+	err := hm.f.writers[hostinfo.sockIdx].WriteTo(stage0, hh.laneTarget)
+	if err != nil {
+		hostinfo.logger(hm.l).Error("Failed to send lane handshake message",
+			"laneIndex", hostinfo.laneIndex,
+			"udpAddr", hh.laneTarget,
+			"initiatorIndex", hostinfo.localIndexId,
+			"error", err,
+		)
+	} else if hm.l.Enabled(context.Background(), slog.LevelDebug) {
+		hostinfo.logger(hm.l).Debug("Lane handshake message sent",
+			"laneIndex", hostinfo.laneIndex,
+			"udpAddr", hh.laneTarget,
+			"initiatorIndex", hostinfo.localIndexId,
+		)
+	}
+
+	hm.OutboundLaneTimer.Add(localIndex, hm.config.tryInterval*time.Duration(hh.counter))
+}
+
+// deletePendingHostInfo abandons a pending handshake. For lanes it also
+// releases the base's slot claim with failure backoff so ensureLanes can
+// retry later.
+func (hm *HandshakeManager) deletePendingHostInfo(hostinfo *HostInfo) {
+	hm.DeleteHostInfo(hostinfo)
+	if hostinfo.isLane() {
+		hostinfo.parent.lanes.noteLaneFailure(int(hostinfo.laneIndex))
+	}
+}
+
+// completeLane moves a finished lane out of the pending map and registers it
+// in the main hostmap's index maps (never Hosts). The caller publishes it to
+// the base's txLanes/peerLanes afterwards.
+func (hm *HandshakeManager) completeLane(hostinfo *HostInfo, f *Interface) {
+	hm.mainHostMap.Lock()
+	defer hm.mainHostMap.Unlock()
+	hm.Lock()
+	defer hm.Unlock()
+
+	existingRemoteIndex, found := hm.mainHostMap.RemoteIndexes[hostinfo.remoteIndexId]
+	if found && existingRemoteIndex != nil {
+		hostinfo.logger(hm.l).Info("New lane shadows existing host remoteIndex",
+			"collision", existingRemoteIndex.vpnAddrs,
+		)
+	}
+
+	hm.unlockedDeleteHostInfo(hostinfo)
+	hm.mainHostMap.unlockedAddLane(hostinfo, f)
+}
+
 // beginHandshake handles an incoming handshake packet that doesn't match any
 // existing pending handshake. It creates a new responder Machine and processes
 // the first message.
@@ -713,6 +946,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 		v, cs.GetCredential,
 		hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) },
 		false, header.HandshakeIXPSK0,
+		hm.laneAdvert(0),
 	)
 	if err != nil {
 		f.l.Error("Failed to create handshake machine", "from", via, "error", err)
@@ -749,12 +983,20 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 		return
 	}
 
+	// A nonzero lane tag makes this a lane handshake for an existing base
+	// tunnel rather than a (possibly duplicate) tunnel of its own.
+	if result.PeerLaneIndex > 0 {
+		hm.completeLaneResponder(via, packet, response, result, vpnAddrs)
+		return
+	}
+
 	connState, err := newConnectionStateFromResult(result)
 	if err != nil {
 		f.l.Error("Discarding handshake with an invalid message index", "error", err, "vpnAddrs", vpnAddrs)
 		return
 	}
 
+
 	hostinfo := &HostInfo{
 		ConnectionState:   connState,
 		localIndexId:      result.LocalIndex,
@@ -799,6 +1041,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 		hostinfo.SetRemote(via.UdpAddr)
 	}
 	hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
+	hm.maybeAllocLaneState(hostinfo, result)
 
 	existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
 	if err != nil {
@@ -808,6 +1051,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 
 	hm.sendHandshakeResponse(via, response, hostinfo, false)
 	hostinfo.remotes.RefreshFromHandshake(vpnAddrs)
+	hm.EnsureLanes(hostinfo)
 
 	// Don't wait for UpdateWorker
 	if f.lightHouse.IsAnyLighthouseAddr(vpnAddrs) {
@@ -815,6 +1059,146 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 	}
 }
 
+// completeLaneResponder finishes the responder side of a lane handshake:
+// associate with the base tunnel by the peer's certified vpn address, dedup
+// replays per lane, register the lane in the index maps, and reply from the
+// arrival socket. Handshakes with no live base are dropped — the initiator's
+// retry loop converges once the base exists.
+func (hm *HandshakeManager) completeLaneResponder(via ViaSender, packet, response []byte, result *handshake.Result, vpnAddrs []netip.Addr) {
+	f := hm.f
+
+	if via.IsRelayed {
+		hm.l.Debug("dropping relayed lane handshake", "vpnAddrs", vpnAddrs, "from", via)
+		return
+	}
+	if hm.config.laneCount == 0 {
+		hm.l.Debug("dropping lane handshake, multiport is disabled", "vpnAddrs", vpnAddrs, "from", via)
+		return
+	}
+
+	base := hm.mainHostMap.QueryVpnAddr(vpnAddrs[0])
+	if base == nil || base.ConnectionState == nil || base.lanes == nil {
+		hm.l.Debug("dropping lane handshake with no base tunnel",
+			"vpnAddrs", vpnAddrs, "from", via, "laneIndex", result.PeerLaneIndex)
+		return
+	}
+	ls := base.lanes
+
+	// The owner's lane index is bounded by its own advertised port count
+	// (lanes are one-per-routine and PortCount == routines on the owner).
+	laneIndex := result.PeerLaneIndex
+	if laneIndex >= uint32(ls.peerPortCount) || laneIndex > 256 {
+		hm.l.Debug("dropping lane handshake with out-of-range lane index",
+			"vpnAddrs", vpnAddrs, "from", via, "laneIndex", laneIndex)
+		return
+	}
+
+	// Per-lane replay dedup against the existing lane with the same index.
+	stage0 := packet[header.Len:]
+	var existing *HostInfo
+	ls.Lock()
+	for _, h := range ls.peerLanes {
+		if h.laneIndex == uint16(laneIndex) {
+			existing = h
+			break
+		}
+	}
+	ls.Unlock()
+	if existing != nil {
+		if bytes.Equal(stage0, existing.HandshakePacket[handshakePacketStage0]) {
+			// Stage-0 retransmit: the peer is committed to the original
+			// response's ephemeral keys, resend it from the arrival socket.
+			if msg := existing.HandshakePacket[handshakePacketStage2]; msg != nil {
+				hm.sendHandshakeResponse(via, msg, existing, true)
+			}
+			return
+		}
+		if existing.lastHandshakeTime >= result.HandshakeTime {
+			existing.logger(hm.l).Debug("dropping stale lane handshake",
+				"laneIndex", laneIndex, "from", via)
+			return
+		}
+		// Newer handshake wins; the initiator only re-initiates after it
+		// declared the old lane dead. Silent local teardown.
+		hm.mainHostMap.DeleteHostInfo(existing)
+	}
+
+	connState, err := newConnectionStateFromResult(result)
+	if err != nil {
+		hm.l.Error("Discarding lane handshake with an invalid message index",
+			"error", err, "vpnAddrs", vpnAddrs, "laneIndex", laneIndex)
+		return
+	}
+
+	hostinfo := &HostInfo{
+		ConnectionState:   connState,
+		localIndexId:      result.LocalIndex,
+		remoteIndexId:     result.RemoteIndex,
+		vpnAddrs:          vpnAddrs,
+		HandshakePacket:   make(map[uint8][]byte, 0),
+		lastHandshakeTime: result.HandshakeTime,
+		relayState: RelayState{
+			relays:         nil,
+			relayForByAddr: map[netip.Addr]*Relay{},
+			relayForByIdx:  map[uint32]*Relay{},
+		},
+		// Private RemoteList: lane roaming must not touch the shared
+		// lighthouse-learned cache.
+		remotes:   NewRemoteList(vpnAddrs, nil),
+		sockIdx:   via.SockIdx,
+		laneIndex: uint16(laneIndex),
+		laneOwned: false,
+		parent:    base,
+	}
+
+	// packet aliases the listener's incoming buffer, so this copy must stay.
+	hostinfo.HandshakePacket[handshakePacketStage0] = make([]byte, len(stage0))
+	copy(hostinfo.HandshakePacket[handshakePacketStage0], stage0)
+	if response != nil {
+		hostinfo.HandshakePacket[handshakePacketStage2] = response
+	}
+	hostinfo.SetRemote(via.UdpAddr)
+	hostinfo.buildNetworks(f.myVpnNetworksTable, result.RemoteCert.Certificate)
+
+	// Index-collision checks, mirroring CheckAndComplete minus the Hosts
+	// dedup (lanes are keyed by (base, laneIndex), not by address).
+	hm.mainHostMap.Lock()
+	hm.Lock()
+	if existingIndex, found := hm.mainHostMap.Indexes[hostinfo.localIndexId]; found && existingIndex != hostinfo {
+		hm.Unlock()
+		hm.mainHostMap.Unlock()
+		hostinfo.logger(hm.l).Error("Failed to add lane due to localIndex collision", "laneIndex", laneIndex)
+		return
+	}
+	if existingPendingIndex, found := hm.indexes[hostinfo.localIndexId]; found && existingPendingIndex.hostinfo != hostinfo {
+		hm.Unlock()
+		hm.mainHostMap.Unlock()
+		hostinfo.logger(hm.l).Error("Failed to add lane due to pending localIndex collision", "laneIndex", laneIndex)
+		return
+	}
+	if existingRemoteIndex, found := hm.mainHostMap.RemoteIndexes[hostinfo.remoteIndexId]; found && existingRemoteIndex != nil {
+		hostinfo.logger(hm.l).Info("New lane shadows existing host remoteIndex",
+			"collision", existingRemoteIndex.vpnAddrs,
+		)
+	}
+	hm.mainHostMap.unlockedAddLane(hostinfo, f)
+	hm.Unlock()
+	hm.mainHostMap.Unlock()
+
+	ls.Lock()
+	ls.peerLanes = append(ls.peerLanes, hostinfo)
+	ls.Unlock()
+
+	hostinfo.logger(hm.l).Info("Lane handshake received",
+		"laneIndex", laneIndex,
+		"from", via,
+		"initiatorIndex", result.RemoteIndex,
+		"responderIndex", result.LocalIndex,
+	)
+
+	hm.sendHandshakeResponse(via, response, hostinfo, false)
+}
+
 // continueHandshake feeds an incoming packet to an existing pending handshake Machine.
 func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostInfo, packet []byte) {
 	f := hm.f
@@ -835,6 +1219,14 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 	}
 
 	hostinfo := hh.hostinfo
+	if hostinfo.isLane() && via.IsRelayed {
+		// Lane handshakes are direct-only; a relayed continuation is a stray
+		// or a protocol violation. Drop without failing the machine so the
+		// direct stage-2 can still land.
+		f.l.Debug("dropping relayed lane handshake continuation",
+			"vpnAddrs", hostinfo.vpnAddrs, "from", via)
+		return
+	}
 	if !via.IsRelayed {
 		if !f.lightHouse.GetRemoteAllowList().AllowAll(hostinfo.vpnAddrs, via.UdpAddr.Addr()) {
 			f.l.Debug("lighthouse.remote_allow_list denied incoming handshake",
@@ -847,7 +1239,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 	if machine == nil {
 		f.l.Error("No handshake machine available for continuation",
 			"vpnAddrs", hostinfo.vpnAddrs, "from", via)
-		hm.DeleteHostInfo(hostinfo)
+		hm.deletePendingHostInfo(hostinfo)
 		return
 	}
 
@@ -857,7 +1249,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 		if machine.Failed() {
 			f.l.Warn("Failed to process handshake packet, abandoning",
 				"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
-			hm.DeleteHostInfo(hostinfo)
+			hm.deletePendingHostInfo(hostinfo)
 		} else {
 			f.l.Debug("Failed to process handshake packet",
 				"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
@@ -886,7 +1278,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 	if remoteCert == nil {
 		f.l.Error("Handshake completed without peer certificate",
 			"vpnAddrs", hostinfo.vpnAddrs, "from", via)
-		hm.DeleteHostInfo(hostinfo)
+		hm.deletePendingHostInfo(hostinfo)
 		return
 	}
 
@@ -920,7 +1312,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 				"issuer", remoteCert.Certificate.Issuer(),
 				"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())},
 			)
-			hm.DeleteHostInfo(hostinfo)
+			hm.deletePendingHostInfo(hostinfo)
 			return
 		}
 		vpnAddrs[i] = network.Addr()
@@ -944,6 +1336,15 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 			"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())},
 		)
 
+		if hostinfo.isLane() {
+			// No restart dance for lanes: close what the peer completed,
+			// release the slot with backoff, and let ensureLanes retry.
+			hm.deletePendingHostInfo(hostinfo)
+			hostinfo.vpnAddrs = vpnAddrs
+			f.sendCloseTunnel(hostinfo)
+			return
+		}
+
 		hm.DeleteHostInfo(hostinfo)
 		hm.StartHandshake(hostinfo.vpnAddrs[0], func(newHH *HandshakeHostInfo) {
 			newHH.hostinfo.remotes = hostinfo.remotes
@@ -978,7 +1379,31 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 	hostinfo.vpnAddrs = vpnAddrs
 	hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
 
+	if hostinfo.isLane() {
+		hm.completeLane(hostinfo, f)
+
+		// Publish to the data plane only after the lane is registered and its
+		// ConnectionState is fully populated; a routine that Loads non-nil
+		// must always see a usable tunnel.
+		ls := hostinfo.parent.lanes
+		i := int(hostinfo.laneIndex)
+		ls.Lock()
+		if i < len(ls.txPending) {
+			ls.txPending[i] = false
+			ls.txFails[i] = 0
+		}
+		ls.Unlock()
+		if i < len(ls.txLanes) {
+			ls.txLanes[i].Store(hostinfo)
+		}
+
+		f.metricHandshakes.Update(duration)
+		return
+	}
+
+	hm.maybeAllocLaneState(hostinfo, result)
 	hm.Complete(hostinfo, f)
+	hm.EnsureLanes(hostinfo)
 
 	if len(hh.packetStore) > 0 {
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
@@ -1086,7 +1511,10 @@ func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hos
 
 	if !via.IsRelayed {
 		fields := append(logFields, "from", via)
-		err := f.outside.WriteTo(msg, via.UdpAddr)
+		// Reply from the socket the handshake arrived on so the initiator sees
+		// the source port it targeted. Identical to f.outside under vanilla
+		// config (all writers share one port); required for multiport lanes.
+		err := f.writers[via.SockIdx].WriteTo(msg, via.UdpAddr)
 		if err != nil {
 			f.l.Error("Failed to send handshake message", append(fields, "error", err)...)
 		} else {

+ 203 - 0
hostmap.go

@@ -278,6 +278,135 @@ type HostInfo struct {
 	// This value will be behind against actual tunnel utilization in the hot path.
 	// 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; lane i targets peerBasePort + (i % peerPortCount).
+	peerPortCount uint16
+	peerBasePort  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]
+
+	// 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 uint16) *laneState {
+	return &laneState{
+		peerPortCount: peerPortCount,
+		peerBasePort:  peerBasePort,
+		txLanes:       make([]atomic.Pointer[HostInfo], laneCount),
+		txPending:     make([]bool, laneCount),
+		txFails:       make([]uint8, laneCount),
+		txRetryAt:     make([]time.Time, laneCount),
+	}
+}
+
+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
 }
 
 type ViaSender struct {
@@ -285,6 +414,11 @@ type ViaSender struct {
 	relayHI   *HostInfo // relayHI is the host info object of the relay
 	relay     *Relay    // relay contains the rest of the relay information, including the PeerIP of the host trying to communicate with us.
 	IsRelayed bool      // IsRelayed is true if the packet was sent through a relay
+
+	// SockIdx is the local socket (Interface.writers index) the packet
+	// arrived on. Replies that must originate from the same 4-tuple egress
+	// f.writers[SockIdx].
+	SockIdx int
 }
 
 func (v ViaSender) String() string {
@@ -446,6 +580,12 @@ 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
@@ -474,6 +614,19 @@ 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)
+		}
+	}
+
 	// 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.
 	final := true
@@ -550,6 +703,35 @@ 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 {
@@ -678,6 +860,27 @@ 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 {

+ 55 - 6
inside.go

@@ -11,12 +11,11 @@ import (
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/iputil"
 	"github.com/slackhq/nebula/noiseutil"
-	"github.com/slackhq/nebula/overlay/batch"
 	"github.com/slackhq/nebula/overlay/tio"
 	"github.com/slackhq/nebula/routing"
 )
 
-func (f *Interface) consumeInsidePacket(pkt tio.Packet, fwPacket *firewall.ParsedPacket, nb []byte, sendBatch *batch.SendBatch, rejectBuf []byte, q int, localCache firewall.ConntrackCache) {
+func (f *Interface) consumeInsidePacket(pkt tio.Packet, fwPacket *firewall.ParsedPacket, nb []byte, tx *txQueue, rejectBuf []byte, q int, localCache firewall.ConntrackCache) {
 	// borrowed: pkt.Bytes is owned by the originating tio.Queue and is
 	// only valid until the next Read on that queue. Every consumer below
 	// (parse, self-forward, handshake cache, sendInsideMessage) reads it
@@ -111,7 +110,7 @@ func (f *Interface) consumeInsidePacket(pkt tio.Packet, fwPacket *firewall.Parse
 
 	dropReason := f.firewall.Drop(fwPacket.Packet, false, hostinfo, f.pki.GetCAPool(), localCache)
 	if dropReason == nil {
-		f.sendInsideMessage(hostinfo, pkt, nb, sendBatch)
+		f.sendInsideMessage(hostinfo, pkt, nb, tx, q)
 	} else {
 		f.rejectInside(packet, rejectBuf, q)
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
@@ -154,12 +153,20 @@ func (f *Interface) sendInsideEncrypt(hostinfo *HostInfo, ci *ConnectionState, s
 // kernel-supplied superpacket bytes never get written into a separate
 // scratch arena: SegmentSuperpacket builds each segment's plaintext in
 // segScratch[:segLen] in turn, and we encrypt directly into a fresh SendBatch slot.
-func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []byte, sendBatch *batch.SendBatch) {
+//
+// hostinfo is always the base tunnel (the hostmap resolves by vpn address);
+// when routine q has an established lane to this peer, the direct path swaps
+// to the lane's session and socket below. Relay and base traffic stays on
+// tx.base (socket 0).
+func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []byte, tx *txQueue, q int) {
 	ci := hostinfo.ConnectionState
 	if ci.eKey == nil {
 		return
 	}
 
+	// Base and relay traffic stays on socket 0; the direct path may swap to tx.lane below.
+	sendBatch := tx.base
+
 	// One traffic-out mark covers every segment of the superpacket; doing it
 	// per segment in sendInsideEncrypt paid an atomic store up to ~45 extra
 	// times per TSO packet, inside writeLock under boring crypto.
@@ -222,6 +229,21 @@ func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []b
 		return
 	}
 
+	// Direct path: prefer this routine's lane tunnel when it is established.
+	// The pointer is only published once the lane's ConnectionState is fully
+	// populated, so a non-nil Load is always usable. On lane death the slot
+	// CAS-clears and traffic falls back to the base tunnel instantly.
+	if ls := hostinfo.lanes; ls != nil && q < len(ls.txLanes) {
+		if lane := ls.txLanes[q].Load(); lane != nil {
+			if lci := lane.ConnectionState; lci != nil && lci.eKey != nil {
+				hostinfo = lane
+				ci = lci
+				remote = lane.GetRemote()
+				sendBatch = tx.lane
+			}
+		}
+	}
+
 	err := tio.SegmentSuperpacket(pkt, func(seg []byte) error {
 		// header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305)
 		scratch := sendBatch.Reserve(header.Len + len(seg) + 16)
@@ -515,16 +537,33 @@ func (f *Interface) SendVia(via *HostInfo, relay *Relay, ad, nb, out []byte, noc
 		return
 	}
 
-	err = f.writers[q].WriteTo(toSend, via.GetRemote())
+	// Relay carriers are base tunnels (sockIdx 0); indexing through the carrier keeps the invariant explicit.
+	err = f.writers[f.egressSock(via, q)].WriteTo(toSend, via.GetRemote())
 	if err != nil {
 		via.logger(f.l).Info("Failed to WriteTo in sendVia", "error", err)
 	}
 }
 
+// egressSock picks the socket a tunnel packet leaves from.
+//
+// With multiport the tunnel's own sockIdx is authoritative: a lane must egress the lane's 4-tuple so the peer's
+// spoof/roam checks accept keepalives, close packets and rejects and the NAT entry stays warm, and a base or relay
+// carrier must egress socket 0 so a vanilla peer never sees per-routine source ports.
+//
+// Without multiport every socket shares one port under SO_REUSEPORT, so the source address is identical either way and
+// we keep q, the socket the triggering packet arrived on, to avoid contending on socket 0's fd.
+func (f *Interface) egressSock(hostinfo *HostInfo, q int) int {
+	if f.multiport {
+		return hostinfo.sockIdx
+	}
+	return q
+}
+
 func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType, ci *ConnectionState, hostinfo *HostInfo, remote netip.AddrPort, p, nb, out []byte, q int) {
 	if ci.eKey == nil {
 		return
 	}
+	q = f.egressSock(hostinfo, q)
 	useRelay := !remote.IsValid() && !hostinfo.GetRemote().IsValid()
 	fullOut := out
 
@@ -556,7 +595,10 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
 	// A closing tunnel is torn down right after this, so skip the connection manager entirely: no point recording
 	// traffic or asking the lighthouse for a punch. Otherwise, if we rebound since this tunnel last sent, ask the
 	// lighthouse to get the far side punching at us again.
-	if t != header.CloseTunnel && f.connectionManager.Out(hostinfo) {
+	//
+	// isLane is checked last on purpose: a lane still needs its Out() traffic recorded for the liveness decision, it
+	// just shouldn't issue the lighthouse query. The base tunnel issues the one query for the peer.
+	if t != header.CloseTunnel && f.connectionManager.Out(hostinfo) && !hostinfo.isLane() {
 		f.lightHouse.QueryServer(hostinfo.vpnAddrs[0])
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
 			f.l.Debug("Lighthouse update triggered for punch due to rebind epoch",
@@ -596,6 +638,13 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
 			)
 		}
 	} else {
+		if hostinfo.isLane() {
+			// A lane always has a valid remote (set from its own handshake);
+			// reaching here means the lane is broken, and lane ciphertext must
+			// never ride a relay (relays are base-tunnel-only).
+			hostinfo.logger(f.l).Error("Dropping lane packet with no valid remote")
+			return
+		}
 		// Try to send via a relay
 		for _, relayIP := range hostinfo.relayState.CopyRelayIps() {
 			relayHostInfo, relay, err := f.hostMap.QueryVpnAddrsRelayFor(hostinfo.vpnAddrs, relayIP)

+ 56 - 10
interface.go

@@ -43,10 +43,13 @@ type InterfaceConfig struct {
 	DropLocalBroadcast bool
 	DropMulticast      bool
 	routines           int
-	MessageMetrics     *MessageMetrics
-	version            string
-	relayManager       *relayManager
-	punchy             *Punchy
+	// Multiport means writers[i] is bound to listen.port+i (not a shared
+	// SO_REUSEPORT port) and lane tunnels are negotiated with capable peers.
+	Multiport      bool
+	MessageMetrics *MessageMetrics
+	version        string
+	relayManager   *relayManager
+	punchy         *Punchy
 
 	tryPromoteEvery uint32
 	reQueryEvery    uint32
@@ -87,6 +90,7 @@ type Interface struct {
 	dropLocalBroadcast    bool
 	dropMulticast         bool
 	routines              int
+	multiport             bool
 	disconnectInvalid     atomic.Bool
 	closed                atomic.Bool
 	// cpuAffinity, when non-empty, names the CPUs each TUN reader goroutine
@@ -217,6 +221,7 @@ func NewInterface(ctx context.Context, c *InterfaceConfig) (*Interface, error) {
 		dropLocalBroadcast:    c.DropLocalBroadcast,
 		dropMulticast:         c.DropMulticast,
 		routines:              c.routines,
+		multiport:             c.Multiport,
 		version:               c.version,
 		writers:               make([]udp.Conn, c.routines),
 		batchers:              make([]*batch.MultiCoalescer, c.routines),
@@ -276,7 +281,10 @@ func (f *Interface) activate() error {
 		"fips140Enforced", fips140.Enforced(),
 	)
 
-	if f.routines > 1 && !f.outside.SupportsMultipleReaders() {
+	// Under multiport each socket has exactly one reader on its own port, so
+	// the shared-port multi-reader capability is irrelevant (and main.go
+	// already hard-errored on unsupported platforms).
+	if f.routines > 1 && !f.multiport && !f.outside.SupportsMultipleReaders() {
 		f.routines = 1
 		f.l.Warn("multiple udp readers are not supported on this platform, falling back to a single routine")
 	}
@@ -289,6 +297,11 @@ func (f *Interface) activate() error {
 		return err
 	}
 	if len(queues) < f.routines {
+		if f.multiport {
+			// The lane sockets are already bound one-per-routine; shrinking
+			// the routine count would leave bound ports with no reader.
+			return fmt.Errorf("multiport requires %d tun queues, device provided %d", f.routines, len(queues))
+		}
 		// TODO: this clamp is only safe because it is unreachable when the
 		// udp side has multiple readers (linux Queues opens exactly n or
 		// errors; every other platform already clamped routines to 1 above).
@@ -389,7 +402,7 @@ func (f *Interface) listenOut(i int) {
 	rxc := newRxContext(f, i)
 
 	listener := func(fromUdpAddr netip.AddrPort, payload []byte) {
-		f.readOutsidePackets(ViaSender{UdpAddr: fromUdpAddr}, payload, rxc)
+		f.readOutsidePackets(ViaSender{UdpAddr: fromUdpAddr, SockIdx: i}, payload, rxc)
 	}
 
 	flusher := func() {
@@ -431,6 +444,35 @@ func (f *Interface) pinThisThread(i int) {
 	}
 }
 
+// txQueue is the per-routine TX state owned by one listenIn goroutine. lane
+// is bound to the routine's own socket and carries lane-tunnel data; base is
+// bound to socket 0 and carries base-tunnel and relay data, which must keep
+// the base source port (a vanilla peer would otherwise see per-routine source
+// ports and roam-thrash). The two alias when multiport is off or on routine 0.
+// Concurrent sendmmsg on the shared socket-0 fd is safe: a flow is pinned to
+// one routine by tun steering, so per-flow wire order still holds.
+type txQueue struct {
+	lane *batch.SendBatch
+	base *batch.SendBatch
+}
+
+func (tx *txQueue) full() bool {
+	if tx.lane.Len() >= batch.SendBatchCap {
+		return true
+	}
+	return tx.base != tx.lane && tx.base.Len() >= batch.SendBatchCap
+}
+
+// flush drains base before lane so that when a flow moves from the base
+// tunnel onto a freshly established lane mid-window, its packets still leave
+// this host in encryption order.
+func (tx *txQueue) flush(f *Interface, i int) {
+	if tx.base != tx.lane {
+		f.flushSendBatch(tx.base, 0)
+	}
+	f.flushSendBatch(tx.lane, i)
+}
+
 func (f *Interface) listenIn(queue tio.Queue, i int) {
 	// Pinning this thread (and goroutine) to a single CPU keeps every sendmmsg from this goroutine going through the
 	// same TX ring on the nic, so the wire sees per-flow order. Skip entirely when tun.pin_threads is false.
@@ -441,6 +483,10 @@ func (f *Interface) listenIn(queue tio.Queue, i int) {
 	rejectBuf := make([]byte, mtu)
 	arenaSize := batch.SendBatchCap * (udp.MTU + 32)
 	sb := batch.NewSendBatch(f.writers[i], batch.SendBatchCap, arenaSize)
+	tx := &txQueue{lane: sb, base: sb}
+	if f.multiport && i != 0 {
+		tx.base = batch.NewSendBatch(f.writers[0], batch.SendBatchCap, arenaSize)
+	}
 	fwPacket := &firewall.ParsedPacket{}
 	nb := make([]byte, 12, 12)
 
@@ -458,15 +504,15 @@ func (f *Interface) listenIn(queue tio.Queue, i int) {
 		}
 
 		for _, pkt := range pkts {
-			f.consumeInsidePacket(pkt, fwPacket, nb, sb, rejectBuf, i, conntrackCache.Get())
+			f.consumeInsidePacket(pkt, fwPacket, nb, tx, rejectBuf, i, conntrackCache.Get())
 			// Flush incrementally once a full sendmmsg batch has
 			// accumulated so the first packets of a deep read drain
 			// hit the wire while the rest are still being encrypted.
-			if sb.Len() >= batch.SendBatchCap {
-				f.flushSendBatch(sb, i)
+			if tx.full() {
+				tx.flush(f, i)
 			}
 		}
-		f.flushSendBatch(sb, i)
+		tx.flush(f, i)
 	}
 
 	f.l.Debug("overlay reader is done", "reader", i)

+ 412 - 0
lanes_test.go

@@ -0,0 +1,412 @@
+package nebula
+
+import (
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/gaissmai/bart"
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/header"
+	"github.com/slackhq/nebula/overlay/batch"
+	"github.com/slackhq/nebula/overlay/overlaytest"
+	"github.com/slackhq/nebula/overlay/tio"
+	"github.com/slackhq/nebula/test"
+	"github.com/slackhq/nebula/udp"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func newTestBaseHostInfo(vpnIp netip.Addr, localIdx, remoteIdx uint32, laneCount int) *HostInfo {
+	base := &HostInfo{
+		vpnAddrs:        []netip.Addr{vpnIp},
+		localIndexId:    localIdx,
+		remoteIndexId:   remoteIdx,
+		remotes:         NewRemoteList([]netip.Addr{vpnIp}, nil),
+		HandshakePacket: map[uint8][]byte{},
+	}
+	base.SetRemote(netip.MustParseAddrPort("192.0.2.1:4242"))
+	base.lanes = newLaneState(laneCount, uint16(laneCount), 4242)
+	return base
+}
+
+func newTestLaneHostInfo(base *HostInfo, laneIndex uint16, localIdx, remoteIdx uint32, owned bool) *HostInfo {
+	lane := &HostInfo{
+		vpnAddrs:        base.vpnAddrs,
+		localIndexId:    localIdx,
+		remoteIndexId:   remoteIdx,
+		remotes:         NewRemoteList(base.vpnAddrs, nil),
+		HandshakePacket: map[uint8][]byte{},
+		sockIdx:         int(laneIndex),
+		laneIndex:       laneIndex,
+		laneOwned:       owned,
+		parent:          base,
+	}
+	lane.SetRemote(netip.MustParseAddrPort("192.0.2.1:4243"))
+	return lane
+}
+
+func TestLaneHostmapLifecycle(t *testing.T) {
+	l := test.NewLogger()
+	hostMap := newHostMap(l)
+	ifce := &Interface{l: l} // connectionManager nil is tolerated by unlockedAddLane
+
+	vpnIp := netip.MustParseAddr("172.1.1.2")
+	base := newTestBaseHostInfo(vpnIp, 100, 200, 4)
+
+	hostMap.Lock()
+	hostMap.unlockedAddHostInfo(base, ifce)
+	hostMap.Unlock()
+
+	lane := newTestLaneHostInfo(base, 1, 101, 201, true)
+	hostMap.Lock()
+	hostMap.unlockedAddLane(lane, ifce)
+	hostMap.Unlock()
+	base.lanes.txLanes[1].Store(lane)
+
+	// The lane is reachable by index (RX demux, recv_error) but never a Hosts primary.
+	assert.Equal(t, lane, hostMap.QueryIndex(101))
+	assert.Equal(t, lane, hostMap.QueryReverseIndex(201))
+	assert.Equal(t, base, hostMap.Hosts[vpnIp])
+
+	// A lane can never be promoted to primary.
+	hostMap.Lock()
+	assert.False(t, hostMap.unlockedMakePrimary(lane))
+	hostMap.Unlock()
+	assert.Equal(t, base, hostMap.Hosts[vpnIp])
+
+	// Deleting the lane clears only its slot, applies backoff, and never
+	// reports "no more tunnels to peer" (final).
+	final := hostMap.DeleteHostInfo(lane)
+	assert.False(t, final)
+	assert.Nil(t, hostMap.QueryIndex(101))
+	assert.Equal(t, base, hostMap.Hosts[vpnIp])
+	assert.Nil(t, base.lanes.txLanes[1].Load())
+	base.lanes.Lock()
+	assert.Equal(t, uint8(1), base.lanes.txFails[1])
+	assert.False(t, base.lanes.txPending[1])
+	assert.True(t, base.lanes.txRetryAt[1].After(time.Now()))
+	base.lanes.Unlock()
+
+	// Idempotent: deleting again must not bump the backoff further.
+	hostMap.DeleteHostInfo(lane)
+	base.lanes.Lock()
+	assert.Equal(t, uint8(2), base.lanes.txFails[1]) // noteLaneFailure still runs, but slot CAS is a no-op
+	base.lanes.Unlock()
+}
+
+func TestLaneHostmapCascadeDelete(t *testing.T) {
+	l := test.NewLogger()
+	hostMap := newHostMap(l)
+	ifce := &Interface{l: l}
+
+	vpnIp := netip.MustParseAddr("172.1.1.3")
+	base := newTestBaseHostInfo(vpnIp, 300, 400, 4)
+
+	hostMap.Lock()
+	hostMap.unlockedAddHostInfo(base, ifce)
+	hostMap.Unlock()
+
+	owned := newTestLaneHostInfo(base, 1, 301, 401, true)
+	peer := newTestLaneHostInfo(base, 2, 302, 402, false)
+	hostMap.Lock()
+	hostMap.unlockedAddLane(owned, ifce)
+	hostMap.unlockedAddLane(peer, ifce)
+	hostMap.Unlock()
+	base.lanes.txLanes[1].Store(owned)
+	base.lanes.Lock()
+	base.lanes.peerLanes = append(base.lanes.peerLanes, peer)
+	base.lanes.Unlock()
+
+	// Deleting the base takes the whole lane family with it.
+	final := hostMap.DeleteHostInfo(base)
+	assert.True(t, final)
+	assert.Nil(t, hostMap.QueryIndex(300))
+	assert.Nil(t, hostMap.QueryIndex(301))
+	assert.Nil(t, hostMap.QueryIndex(302))
+	assert.Nil(t, hostMap.Hosts[vpnIp])
+}
+
+// Regression: deleting a hostinfo whose pending entry is NOT the one recorded
+// in vpnIps (e.g. a lane, whose vpnAddrs alias the base's) must not evict a
+// concurrently pending base handshake for the same address.
+func TestHandshakeManagerVpnIpsIdentityDelete(t *testing.T) {
+	l := test.NewLogger()
+	hostMap := newHostMap(l)
+	lh := newTestLighthouse()
+	hm := NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig)
+
+	vpnIp := netip.MustParseAddr("172.1.1.4")
+	pendingBase := hm.StartHandshake(vpnIp, nil)
+	require.NotNil(t, pendingBase)
+
+	other := &HostInfo{vpnAddrs: []netip.Addr{vpnIp}, localIndexId: 999}
+	hm.DeleteHostInfo(other)
+
+	// The pending base handshake must still be tracked.
+	assert.Equal(t, pendingBase, hm.QueryVpnAddr(vpnIp))
+
+	// And deleting the actual owner still works.
+	hm.DeleteHostInfo(pendingBase)
+	assert.Nil(t, hm.QueryVpnAddr(vpnIp))
+}
+
+func newLaneTestConnectionManager(hostMap *HostMap) (*connectionManager, *Interface) {
+	l := test.NewLogger()
+	lh := newTestLighthouse()
+	cs := &CertState{
+		initiatingVersion: cert.Version1,
+		privateKey:        []byte{},
+		v1Cert:            &dummyCert{version: cert.Version1},
+		v1Credential:      nil,
+	}
+	ifce := &Interface{
+		hostMap:            hostMap,
+		inside:             &overlaytest.NoopTun{},
+		outside:            &udp.NoopConn{},
+		firewall:           &Firewall{},
+		lightHouse:         lh,
+		pki:                &PKI{},
+		handshakeManager:   NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig),
+		myVpnNetworksTable: new(bart.Lite),
+		l:                  l,
+	}
+	ifce.pki.cs.Store(cs)
+
+	conf := config.NewC(test.NewLogger())
+	punchy := NewPunchyFromConfig(test.NewLogger(), conf, nil)
+	cm := newConnectionManagerFromConfig(test.NewLogger(), conf, hostMap, punchy)
+	cm.intf = ifce
+	ifce.connectionManager = cm
+	ifce.handshakeManager.f = ifce
+	return cm, ifce
+}
+
+func TestLaneTrafficDecision(t *testing.T) {
+	hostMap := newHostMap(test.NewLogger())
+	cm, ifce := newLaneTestConnectionManager(hostMap)
+
+	vpnIp := netip.MustParseAddr("172.1.1.5")
+	base := newTestBaseHostInfo(vpnIp, 500, 600, 4)
+	base.ConnectionState = &ConnectionState{}
+	hostMap.Lock()
+	hostMap.unlockedAddHostInfo(base, ifce)
+	hostMap.Unlock()
+
+	lane := newTestLaneHostInfo(base, 1, 501, 601, true)
+	lane.ConnectionState = &ConnectionState{}
+	hostMap.Lock()
+	hostMap.unlockedAddLane(lane, ifce)
+	hostMap.Unlock()
+	base.lanes.txLanes[1].Store(lane)
+
+	now := time.Now()
+
+	// A lane with inbound traffic is alive and never swaps primary or
+	// migrates relays.
+	lane.markIn()
+	decision, resolved, _ := cm.makeTrafficDecision(lane.localIndexId, now)
+	assert.Equal(t, doNothing, decision)
+	assert.Equal(t, lane, resolved)
+	assert.False(t, lane.isPendingDeletion())
+
+	// An idle lane gets an active keepalive test...
+	decision, _, _ = cm.makeTrafficDecision(lane.localIndexId, now)
+	assert.Equal(t, sendTestPacket, decision)
+	assert.True(t, lane.isPendingDeletion())
+
+	// ...and is declared dead when the test goes unanswered.
+	decision, _, _ = cm.makeTrafficDecision(lane.localIndexId, now)
+	assert.Equal(t, deleteTunnel, decision)
+}
+
+func TestBaseInactiveConsidersLanes(t *testing.T) {
+	hostMap := newHostMap(test.NewLogger())
+	cm, _ := newLaneTestConnectionManager(hostMap)
+	cm.dropInactive.Store(true)
+	cm.inactivityTimeout.Store(int64(10 * time.Minute))
+
+	now := time.Now()
+	vpnIp := netip.MustParseAddr("172.1.1.6")
+	base := newTestBaseHostInfo(vpnIp, 700, 800, 4)
+	base.lastUsed = now.Add(-time.Hour)
+
+	// Base alone: inactive.
+	_, inactive := cm.isInactive(base, now)
+	assert.True(t, inactive)
+
+	// A recently used lane keeps the base alive.
+	lane := newTestLaneHostInfo(base, 1, 701, 801, true)
+	lane.lastUsed = now.Add(-time.Minute)
+	base.lanes.txLanes[1].Store(lane)
+	_, inactive = cm.isInactive(base, now)
+	assert.False(t, inactive)
+
+	// Peer-owned lanes count too.
+	base.lanes.txLanes[1].Store(nil)
+	base.lanes.Lock()
+	base.lanes.peerLanes = append(base.lanes.peerLanes, lane)
+	base.lanes.Unlock()
+	_, inactive = cm.isInactive(base, now)
+	assert.False(t, inactive)
+}
+
+// recordingBatchWriter satisfies batch's writer interface and records what
+// was flushed to it.
+type recordingBatchWriter struct {
+	bufs [][]byte
+	dsts []netip.AddrPort
+}
+
+func (w *recordingBatchWriter) WriteBatch(bufs [][]byte, addrs []netip.AddrPort) (int, error) {
+	for i := range bufs {
+		w.bufs = append(w.bufs, append([]byte(nil), bufs[i]...))
+		w.dsts = append(w.dsts, addrs[i])
+	}
+	return len(bufs), nil
+}
+
+func TestSendInsideMessageLaneSwap(t *testing.T) {
+	hostMap := newHostMap(test.NewLogger())
+	cm, ifce := newLaneTestConnectionManager(hostMap)
+	_ = cm
+
+	vpnIp := netip.MustParseAddr("172.1.1.7")
+	base := newTestBaseHostInfo(vpnIp, 900, 1000, 4)
+	lane := newTestLaneHostInfo(base, 1, 901, 1001, true)
+
+	// Real cipher states from a real handshake so encryption works.
+	baseInit, _ := runTestHandshake(t)
+	laneInit, _ := runTestHandshake(t)
+	var err error
+	base.ConnectionState, err = newConnectionStateFromResult(baseInit)
+	require.NoError(t, err)
+	lane.ConnectionState, err = newConnectionStateFromResult(laneInit)
+	require.NoError(t, err)
+
+	baseWriter := &recordingBatchWriter{}
+	laneWriter := &recordingBatchWriter{}
+	tx := &txQueue{
+		base: batch.NewSendBatch(baseWriter, batch.SendBatchCap, 1<<16),
+		lane: batch.NewSendBatch(laneWriter, batch.SendBatchCap, 1<<16),
+	}
+
+	pkt := tio.Packet{Bytes: []byte{0x45, 0, 0, 4, 1, 2, 3, 4}}
+	nb := make([]byte, 12)
+
+	// With the lane published, routine 1's traffic uses the lane session and
+	// the lane batch.
+	base.lanes.txLanes[1].Store(lane)
+	ifce.sendInsideMessage(base, pkt, nb, tx, 1)
+	tx.flush(ifce, 1)
+	require.Len(t, laneWriter.bufs, 1)
+	require.Empty(t, baseWriter.bufs)
+	assert.Equal(t, lane.GetRemote(), laneWriter.dsts[0])
+
+	h := &header.H{}
+	require.NoError(t, h.Parse(laneWriter.bufs[0]))
+	assert.Equal(t, lane.remoteIndexId, h.RemoteIndex)
+
+	// Routine 2 has no lane: base tunnel, base batch.
+	ifce.sendInsideMessage(base, pkt, nb, tx, 2)
+	tx.flush(ifce, 1)
+	require.Len(t, baseWriter.bufs, 1)
+	assert.Equal(t, base.GetRemote(), baseWriter.dsts[0])
+	require.NoError(t, h.Parse(baseWriter.bufs[0]))
+	assert.Equal(t, base.remoteIndexId, h.RemoteIndex)
+
+	// Lane death: slot cleared, instant fallback to base.
+	base.lanes.txLanes[1].Store(nil)
+	ifce.sendInsideMessage(base, pkt, nb, tx, 1)
+	tx.flush(ifce, 1)
+	require.Len(t, baseWriter.bufs, 2)
+	require.Len(t, laneWriter.bufs, 1)
+}
+
+func TestCompleteLaneResponder(t *testing.T) {
+	hostMap := newHostMap(test.NewLogger())
+	_, ifce := newLaneTestConnectionManager(hostMap)
+	ifce.writers = []udp.Conn{&udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}}
+	ifce.messageMetrics = newMessageMetricsOnlyRecvError()
+
+	hm := ifce.handshakeManager
+	hm.config.laneCount = 4
+	hm.config.lanePortCount = 4
+	hm.config.laneBasePort = 4242
+
+	// A real handshake supplies usable keys and a peer cert.
+	_, respR := runTestHandshake(t)
+	respR.PeerLaneIndex = 2
+	respR.PeerPortCount = 4
+	respR.PeerBasePort = 5353
+
+	via := ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.9:5355"), SockIdx: 2}
+	packet := make([]byte, header.Len+8)
+	copy(packet[header.Len:], []byte("stage0!!"))
+	vpnAddrs := []netip.Addr{netip.MustParseAddr("172.1.1.9")}
+
+	// No base tunnel: the lane handshake is dropped, nothing registered.
+	hm.completeLaneResponder(via, packet, []byte("resp"), respR, vpnAddrs)
+	assert.Nil(t, hostMap.QueryIndex(respR.LocalIndex))
+
+	// With a live base the lane attaches to it.
+	base := newTestBaseHostInfo(vpnAddrs[0], 1300, 1400, 4)
+	base.ConnectionState = &ConnectionState{}
+	hostMap.Lock()
+	hostMap.unlockedAddHostInfo(base, ifce)
+	hostMap.Unlock()
+
+	hm.completeLaneResponder(via, packet, []byte("resp"), respR, vpnAddrs)
+	lane := hostMap.QueryIndex(respR.LocalIndex)
+	require.NotNil(t, lane)
+	assert.True(t, lane.isLane())
+	assert.False(t, lane.laneOwned)
+	assert.Equal(t, uint16(2), lane.laneIndex)
+	assert.Equal(t, 2, lane.sockIdx)
+	assert.Equal(t, via.UdpAddr, lane.GetRemote())
+	assert.Equal(t, base, hostMap.Hosts[vpnAddrs[0]], "lane must not displace the base as primary")
+	base.lanes.Lock()
+	assert.Len(t, base.lanes.peerLanes, 1)
+	base.lanes.Unlock()
+
+	// A byte-identical stage-0 retransmit resends the cached response and
+	// must not register a second lane.
+	hm.completeLaneResponder(via, packet, []byte("resp"), respR, vpnAddrs)
+	base.lanes.Lock()
+	assert.Len(t, base.lanes.peerLanes, 1)
+	base.lanes.Unlock()
+
+	// An out-of-range lane index is refused.
+	respR2 := *respR
+	respR2.PeerLaneIndex = 9
+	respR2.LocalIndex = respR.LocalIndex + 1
+	hm.completeLaneResponder(via, packet, []byte("resp"), &respR2, vpnAddrs)
+	assert.Nil(t, hostMap.QueryIndex(respR2.LocalIndex))
+}
+
+func TestEnsureLanesBackoffOnStage0Failure(t *testing.T) {
+	hostMap := newHostMap(test.NewLogger())
+	_, ifce := newLaneTestConnectionManager(hostMap)
+
+	// laneCount enables multiport in the manager; the dummy CertState has no
+	// credential, so stage-0 construction must fail and release the slot with
+	// backoff rather than leaving it claimed forever.
+	hm := ifce.handshakeManager
+	hm.config.laneCount = 4
+	hm.config.lanePortCount = 4
+	hm.config.laneBasePort = 4242
+
+	vpnIp := netip.MustParseAddr("172.1.1.8")
+	base := newTestBaseHostInfo(vpnIp, 1100, 1200, 4)
+
+	hm.EnsureLanes(base)
+
+	base.lanes.Lock()
+	defer base.lanes.Unlock()
+	for i := 1; i < 4; i++ {
+		assert.False(t, base.lanes.txPending[i], "slot %d still pending", i)
+		assert.Equal(t, uint8(1), base.lanes.txFails[i], "slot %d fails", i)
+		assert.True(t, base.lanes.txRetryAt[i].After(time.Now()), "slot %d retryAt", i)
+	}
+}

+ 115 - 25
main.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
+	"math"
 	"net"
 	"net/netip"
 	"os"
@@ -146,6 +147,22 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 	udpConns := make([]udp.Conn, routines)
 	port := c.GetInt("listen.port", 0)
 
+	// Multiport lanes: bind `routines` consecutive UDP ports (listen.port+i)
+	// instead of SO_REUSEPORT sharing one, and negotiate one extra tunnel per
+	// routine with capable peers so each routine's traffic rides its own
+	// underlay 5-tuple. Defaults on, degrading gracefully when preconditions
+	// aren't met — managed deployments (dnclient) can't be hard-errored on
+	// config they don't control.
+	multiport := c.GetBool("multiport.enabled", true)
+	if multiport && routines < 2 {
+		l.Info("multiport disabled: requires routines > 1")
+		multiport = false
+	}
+	if multiport && port != 0 && port+routines-1 > math.MaxUint16 {
+		l.Warn("multiport disabled: would bind ports beyond 65535", "listen.port", port, "routines", routines)
+		multiport = false
+	}
+
 	// Callers get no handle to these until the Control is returned, release them on any error.
 	defer func() {
 		if reterr != nil {
@@ -175,37 +192,99 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 			listenHost = ips[0].Unmap()
 		}
 
-		for i := 0; i < routines; i++ {
-			listen := netip.AddrPortFrom(listenHost, uint16(port))
-			l.Info("listening", "addr", listen)
-			batchSize := c.GetInt("listen.batch", 64)
-			if batchSize < 1 {
-				oldBatch := batchSize
-				batchSize = 1
-				l.Warn("listen.batch size is invalid", "provided", oldBatch, "overridden to", batchSize)
-			}
-			udpSettings := udp.Settings{
-				Listen:   listen,
-				Multi:    routines > 1,
-				Batch:    batchSize,
-				Offloads: c.GetBool("listen.udp_offloads", false),
-			}
-			udpServer, err := udp.NewListener(l, udpSettings)
+		batchSize := c.GetInt("listen.batch", 64)
+		if batchSize < 1 {
+			oldBatch := batchSize
+			batchSize = 1
+			l.Warn("listen.batch size is invalid", "provided", oldBatch, "overridden to", batchSize)
+		}
+		offloads := c.GetBool("listen.udp_offloads", false)
+
+		// Every lane socket needs its own reader; a platform that can't run
+		// multiple readers would silently strand sockets 1..n-1 as blackholes.
+		// Probe capability before committing to per-port binds.
+		if multiport {
+			probe, err := udp.NewListener(l, udp.Settings{
+				Listen: netip.AddrPortFrom(listenHost, 0),
+				Batch:  1,
+			})
 			if err != nil {
-				return nil, util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
+				// We could not confirm support, so don't gamble a bound port range on it. The real bind below reports
+				// the underlying error if it is not transient.
+				l.Warn("multiport disabled: could not probe udp reader support", "error", err)
+				multiport = false
+			} else {
+				if !probe.SupportsMultipleReaders() {
+					l.Warn("multiport disabled: this platform does not support multiple udp readers")
+					multiport = false
+				}
+				_ = probe.Close()
 			}
-			udpServer.ReloadConfig(c)
-			udpConns[i] = udpServer
+		}
 
-			// If port is dynamic, discover it before the next pass through the for loop
-			// This way all routines will use the same port correctly
-			if port == 0 {
-				uPort, err := udpServer.LocalAddr()
+		// With a dynamic listen.port, multiport binds socket 0 dynamically and
+		// then claims the next routines-1 ports above it; if that range turns
+		// out to be partially occupied, re-roll with a fresh dynamic port.
+		dynamic := port == 0
+		var bindErr error
+		for attempt := 0; attempt < 6; attempt++ {
+			bindErr = nil
+			for i := 0; i < routines; i++ {
+				lPort := port
+				if multiport {
+					lPort = port + i
+				}
+				udpServer, err := udp.NewListener(l, udp.Settings{
+					Listen: netip.AddrPortFrom(listenHost, uint16(lPort)),
+					// Multiport gives every routine its own port, so SO_REUSEPORT sharing is neither needed nor wanted:
+					// the destination port alone must decide which socket, and therefore which routine, sees a flow.
+					Multi:    routines > 1 && !multiport,
+					Batch:    batchSize,
+					Offloads: offloads,
+				})
 				if err != nil {
-					return nil, util.NewContextualError("Failed to get listening port", nil, err)
+					bindErr = util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
+					break
+				}
+				udpServer.ReloadConfig(c)
+				udpConns[i] = udpServer
+
+				// If port is dynamic, discover it before the next pass through the for loop
+				// This way all routines will use the same port correctly
+				if port == 0 {
+					uPort, err := udpServer.LocalAddr()
+					if err != nil {
+						return nil, util.NewContextualError("Failed to get listening port", nil, err)
+					}
+					port = int(uPort.Port())
+					if multiport && port+routines-1 > math.MaxUint16 {
+						bindErr = util.NewContextualError("multiport dynamic port too close to 65535", m{"port": port}, nil)
+						break
+					}
+				}
+				bound := port
+				if multiport {
+					bound = port + i
+				}
+				l.Info("listening", "addr", netip.AddrPortFrom(listenHost, uint16(bound)), "socket", i)
+			}
+			if bindErr == nil {
+				break
+			}
+			if !(multiport && dynamic) {
+				return nil, bindErr
+			}
+			for i := range udpConns {
+				if udpConns[i] != nil {
+					_ = udpConns[i].Close()
+					udpConns[i] = nil
 				}
-				port = int(uPort.Port())
 			}
+			port = 0
+			l.Debug("multiport dynamic port range collided, retrying", "attempt", attempt+1)
+		}
+		if bindErr != nil {
+			return nil, bindErr
 		}
 	}
 
@@ -231,6 +310,16 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 		messageMetrics: messageMetrics,
 	}
 
+	if multiport {
+		lanes := c.GetInt("multiport.lanes", 0)
+		if lanes <= 0 || lanes > routines {
+			lanes = routines
+		}
+		handshakeConfig.laneCount = lanes
+		handshakeConfig.lanePortCount = uint16(routines)
+		handshakeConfig.laneBasePort = uint16(port)
+	}
+
 	handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)
 	lightHouse.handshakeTrigger = handshakeManager.trigger
 
@@ -286,6 +375,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 		DropLocalBroadcast:    c.GetBool("tun.drop_local_broadcast", false),
 		DropMulticast:         c.GetBool("tun.drop_multicast", false),
 		routines:              routines,
+		Multiport:             multiport,
 		MessageMetrics:        messageMetrics,
 		version:               buildVersion,
 		relayManager:          NewRelayManager(ctx, l, hostMap, c),

+ 9 - 5
outside.go

@@ -102,7 +102,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
 	// recvError if necessary
 	if hostinfo == nil || hostinfo.ConnectionState == nil {
 		if !via.IsRelayed {
-			f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex)
+			f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex, via.SockIdx)
 		}
 		return
 	}
@@ -214,6 +214,7 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
 			relayHI:   hostinfo,
 			relay:     relay,
 			IsRelayed: true,
+			SockIdx:   via.SockIdx,
 		}
 		f.readOutsidePackets(via, signedPayload, rxc)
 	case ForwardingType:
@@ -492,17 +493,20 @@ func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, messageCounte
 	}
 }
 
-func (f *Interface) maybeSendRecvError(endpoint netip.AddrPort, index uint32) {
+func (f *Interface) maybeSendRecvError(endpoint netip.AddrPort, index uint32, q int) {
 	if f.sendRecvErrorConfig.ShouldRecvError(endpoint) {
-		f.sendRecvError(endpoint, index)
+		f.sendRecvError(endpoint, index, q)
 	}
 }
 
-func (f *Interface) sendRecvError(endpoint netip.AddrPort, index uint32) {
+// sendRecvError replies from the socket the offending packet arrived on (q).
+// A lane peer's spoof guard compares our source addr against the lane's
+// remote, so a reply from the base port would be discarded.
+func (f *Interface) sendRecvError(endpoint netip.AddrPort, index uint32, q int) {
 	f.messageMetrics.Tx(header.RecvError, 0, 1)
 
 	b := header.Encode(make([]byte, header.Len), header.Version, header.RecvError, 0, index, 0)
-	_ = f.outside.WriteTo(b, endpoint)
+	_ = f.writers[q].WriteTo(b, endpoint)
 	if f.l.Enabled(context.Background(), slog.LevelDebug) {
 		f.l.Debug("Recv error sent",
 			"index", index,