multiport: make a lane a session on the tunnel, not a tunnel

Lanes used to be separate HostInfos, each with its own handshake, its own
half-established states, its own lifetime and its own slot bookkeeping. That
bought nothing: a lane is the same tunnel over a different underlay 5-tuple.

Derive lane sessions instead. Noise leaves us with A.eKey == B.dKey, so both
sides HKDF-expand the same two keys with the same per-lane label and land on a
matched pair without exchanging anything. Lanes now cost no handshake, have no
half-established state, and die exactly when their base tunnel does. Which lane
a packet belongs to rides the low byte of the nebula header's Reserved field,
inside the AEAD's associated data.

Receiving on a lane needs no permission, since the session exists the moment the
base handshake completes. Sending on one needs proof the new 5-tuple works, so a
lane stays down until a probe on it is acked and falls back to the base tunnel
the moment it stops being acked. Probing is demand-driven off the connection
manager's per-tunnel traffic tick: a peer we exchange a trickle with never costs
more than its base tunnel, however many lanes are configured. The ack rides the
base session on purpose, so a broken reverse lane can't fail a working one.

Because the data now rides lane counters, the rehandshake, exhaustion and
swap-primary checks take the max counter across the base session and its lanes;
otherwise the base counter would sit near zero while a lane ran its keys past
the nonce ceiling.

Removes OutboundLaneTimer, EnsureLanes, startLaneHandshake, completeLane,
completeLaneResponder, makeLaneTrafficDecision and the lane fields on HostInfo.
Handshake payload field 3 (the per-lane handshake index) is permanently
reserved; peers advertise a TxLanes count instead.
This commit is contained in:
Wade Simmons
2026-09-02 14:48:17 -04:00
parent 8b914e67f4
commit fb20de39b2
19 changed files with 1223 additions and 1273 deletions
+16 -80
View File
@@ -197,17 +197,17 @@ func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte
} }
cm.resetRelayTrafficCheck(hostinfo) cm.resetRelayTrafficCheck(hostinfo)
cm.ensureLanes(localIndex, decision, hostinfo) cm.maintainLanes(localIndex, decision, hostinfo, now, nb, out)
} }
// ensureLanes piggybacks lane establishment on the per-tunnel traffic tick: // maintainLanes piggybacks multiport lane probing on the per-tunnel traffic
// any live base with lane state gets the slots its data plane asked for // tick. This tick is the right place for it precisely because lanes are
// started (subject to the per-slot backoff). This tick is the right place for // demand-driven: a tunnel only lands here when it has traffic, which is the
// it precisely because lanes are demand-driven — a base only lands here when // same condition that raises lane demand.
// it has traffic, which is the same condition that raises lane demand. //
// makeTrafficDecision returns a nil hostinfo on some keep-alive paths, so // makeTrafficDecision returns a nil hostinfo on some keep-alive paths, so
// re-resolve the index in that case. // re-resolve the index in that case.
func (cm *connectionManager) ensureLanes(localIndex uint32, decision trafficDecision, hostinfo *HostInfo) { func (cm *connectionManager) maintainLanes(localIndex uint32, decision trafficDecision, hostinfo *HostInfo, now time.Time, nb, out []byte) {
if decision == deleteTunnel || decision == closeTunnel { if decision == deleteTunnel || decision == closeTunnel {
return return
} }
@@ -217,10 +217,7 @@ func (cm *connectionManager) ensureLanes(localIndex uint32, decision trafficDeci
return return
} }
} }
if hostinfo.isLane() || hostinfo.lanes == nil { cm.intf.probeLanes(hostinfo, now, nb, out)
return
}
cm.intf.handshakeManager.EnsureLanes(hostinfo)
} }
func (cm *connectionManager) resetRelayTrafficCheck(hostinfo *HostInfo) { func (cm *connectionManager) resetRelayTrafficCheck(hostinfo *HostInfo) {
@@ -353,18 +350,15 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
return closeTunnel, hostinfo, nil return closeTunnel, hostinfo, nil
} }
// Checked ahead of the lane branch: an exhausted counter is fatal for lanes too, and makeLaneTrafficDecision // The highest counter across the base session and its lanes: the lanes carry
// only reasons about liveness. // the data, so the base counter alone would sit near zero while a lane runs
if hostinfo.ConnectionState != nil && hostinfo.ConnectionState.messageCounter.Load() >= RejectAfterMessages { // its keys past the nonce ceiling.
if hostinfo.maxMessageCounter() >= RejectAfterMessages {
// Send path can't encrypt a CloseTunnel notify, so just delete locally; the peer recovers via recv_error. // 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") hostinfo.logger(cm.l).Error("Dropping tunnel, message counter is exhausted")
return deleteTunnel, hostinfo, nil return deleteTunnel, hostinfo, nil
} }
if hostinfo.isLane() {
return cm.makeLaneTrafficDecision(hostinfo, now)
}
primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]] primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]]
mainHostInfo := true mainHostInfo := true
if primary != nil && primary != hostinfo { if primary != nil && primary != hostinfo {
@@ -461,72 +455,14 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
return decision, hostinfo, nil 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) { func (cm *connectionManager) isInactive(hostinfo *HostInfo, now time.Time) (time.Duration, bool) {
if cm.dropInactive.Load() == false { if cm.dropInactive.Load() == false {
// We aren't configured to drop inactive tunnels // We aren't configured to drop inactive tunnels
return 0, false return 0, false
} }
// With multiport the data rides the lanes and the base may look idle; // Lane traffic is this hostinfo's traffic, so lastUsed already covers it.
// a base is only inactive if its whole lane family is. lastUsed is only inactiveDuration := now.Sub(hostinfo.lastUsed)
// 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() { if inactiveDuration < cm.getInactivityTimeout() {
// It's not considered inactive // It's not considered inactive
return inactiveDuration, false return inactiveDuration, false
@@ -549,7 +485,7 @@ func (cm *connectionManager) shouldSwapPrimary(current *HostInfo) bool {
return false return false
} }
if current.ConnectionState.messageCounter.Load() >= RehandshakeAfterMessages { if current.maxMessageCounter() >= RehandshakeAfterMessages {
// This tunnel is being rolled for counter exhaustion, never swap back onto its spent key. // This tunnel is being rolled for counter exhaustion, never swap back onto its spent key.
return false return false
} }
@@ -653,7 +589,7 @@ func (cm *connectionManager) tryRehandshake(hostinfo *HostInfo) {
cm.intf.handshakeManager.StartHandshake(hostinfo.vpnAddrs[0], nil) cm.intf.handshakeManager.StartHandshake(hostinfo.vpnAddrs[0], nil)
return return
} }
if hostinfo.ConnectionState.messageCounter.Load() >= RehandshakeAfterMessages { if hostinfo.maxMessageCounter() >= RehandshakeAfterMessages {
cm.l.Info("Re-handshaking with remote", cm.l.Info("Re-handshaking with remote",
"vpnAddrs", hostinfo.vpnAddrs, "vpnAddrs", hostinfo.vpnAddrs,
"reason", "message counter rehandshake threshold reached", "reason", "message counter rehandshake threshold reached",
+50
View File
@@ -1,9 +1,12 @@
package nebula package nebula
import ( import (
"crypto/hkdf"
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -74,6 +77,53 @@ func newConnectionStateFromResult(r *handshake.Result) (*ConnectionState, error)
return ci, nil return ci, nil
} }
// newLaneConnectionState derives multiport lane s's session from the completed
// base handshake. Each key is an HKDF expansion of the base tunnel's matching
// key, labelled with the lane index, so the pair stays matched with no extra
// negotiation: Noise leaves our send key equal to the peer's receive key, and
// expanding both with the same label preserves that.
//
// The lane gets its own counter and replay window starting from zero. No
// handshake messages were spent on it, so unlike the base session there is
// nothing to seed.
func newLaneConnectionState(r *handshake.Result, lane uint8) (*ConnectionState, error) {
if lane == 0 {
return nil, fmt.Errorf("lane 0 is the base session")
}
eKey, err := deriveLaneKey(r.EKey.UnsafeKey(), lane)
if err != nil {
return nil, err
}
dKey, err := deriveLaneKey(r.DKey.UnsafeKey(), lane)
if err != nil {
return nil, err
}
return &ConnectionState{
myCert: r.MyCert,
initiator: r.Initiator,
peerCert: r.RemoteCert,
eKey: noiseutil.NewCipherStateFromKey(eKey, r.Cipher),
dKey: noiseutil.NewCipherStateFromKey(dKey, r.Cipher),
window: NewBits(ReplayWindow),
epoch: sessionEpoch.Add(1),
}, nil
}
// deriveLaneKey expands a base tunnel key into the key for one lane.
func deriveLaneKey(base [32]byte, lane uint8) ([32]byte, error) {
var out [32]byte
// The base key is already unique to this tunnel and direction, so the lane
// index is the only thing that needs to vary; no salt is required.
k, err := hkdf.Key(sha256.New, base[:], nil, laneKeyInfo+" "+strconv.Itoa(int(lane)), len(out))
if err != nil {
return out, err
}
copy(out[:], k)
return out, nil
}
func (cs *ConnectionState) MarshalJSON() ([]byte, error) { func (cs *ConnectionState) MarshalJSON() ([]byte, error) {
return json.Marshal(m{ return json.Marshal(m{
"certificate": cs.peerCert, "certificate": cs.peerCert,
+35 -11
View File
@@ -67,9 +67,16 @@ type ControlHostInfo struct {
CurrentRemote netip.AddrPort `json:"currentRemote"` CurrentRemote netip.AddrPort `json:"currentRemote"`
CurrentRelaysToMe []netip.Addr `json:"currentRelaysToMe"` CurrentRelaysToMe []netip.Addr `json:"currentRelaysToMe"`
CurrentRelaysThroughMe []netip.Addr `json:"currentRelaysThroughMe"` CurrentRelaysThroughMe []netip.Addr `json:"currentRelaysThroughMe"`
IsLane bool `json:"isLane,omitempty"` Lanes []ControlLane `json:"lanes,omitempty"`
LaneIndex uint16 `json:"laneIndex,omitempty"` }
SockIdx int `json:"sockIdx,omitempty"`
// ControlLane reports one multiport lane of a tunnel. Only lanes we may send on
// are listed; receive-only lanes have no state worth showing.
type ControlLane struct {
Index uint8 `json:"index"`
Up bool `json:"up"`
Remote netip.AddrPort `json:"remote,omitempty"`
MessageCounter uint64 `json:"messageCounter"`
} }
// Start actually runs nebula, this is a nonblocking call. // Start actually runs nebula, this is a nonblocking call.
@@ -365,11 +372,6 @@ func (c *Control) CloseAllTunnels(excludeLighthouses bool) (closed int) {
// Grab the hostMap lock to access the Hosts map // Grab the hostMap lock to access the Hosts map
c.f.hostMap.Lock() c.f.hostMap.Lock()
for _, relayHost := range c.f.hostMap.Indexes { 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 { if _, ok := relayingHosts[relayHost.vpnAddrs[0]]; !ok {
hostInfos = append(hostInfos, relayHost) hostInfos = append(hostInfos, relayHost)
} }
@@ -398,9 +400,7 @@ func copyHostInfo(h *HostInfo, preferredRanges []netip.Prefix) ControlHostInfo {
CurrentRelaysToMe: h.relayState.CopyRelayIps(), CurrentRelaysToMe: h.relayState.CopyRelayIps(),
CurrentRelaysThroughMe: h.relayState.CopyRelayForIps(), CurrentRelaysThroughMe: h.relayState.CopyRelayForIps(),
CurrentRemote: h.GetRemote(), CurrentRemote: h.GetRemote(),
IsLane: h.isLane(), Lanes: copyLanes(h),
LaneIndex: h.laneIndex,
SockIdx: h.sockIdx,
} }
for i, a := range h.vpnAddrs { for i, a := range h.vpnAddrs {
@@ -418,6 +418,30 @@ func copyHostInfo(h *HostInfo, preferredRanges []netip.Prefix) ControlHostInfo {
return chi return chi
} }
// copyLanes snapshots the sendable multiport lanes of a tunnel, or nil when it
// has none. txAddr is the lane's gate as well as its destination, so a nil load
// is exactly "this lane is down and its routine is riding the base tunnel".
func copyLanes(h *HostInfo) []ControlLane {
ls := h.lanes
if ls == nil || ls.txLanes < 2 {
return nil
}
lanes := make([]ControlLane, 0, ls.txLanes-1)
for s := 1; s < ls.txLanes; s++ {
l := ControlLane{Index: uint8(s)}
if addr := ls.txAddr[s].Load(); addr != nil {
l.Up = true
l.Remote = *addr
}
if cs := ls.sessions[s]; cs != nil {
l.MessageCounter = cs.messageCounter.Load()
}
lanes = append(lanes, l)
}
return lanes
}
func listHostMapHosts(hl controlHostLister) []ControlHostInfo { func listHostMapHosts(hl controlHostLister) []ControlHostInfo {
hosts := make([]ControlHostInfo, 0) hosts := make([]ControlHostInfo, 0)
pr := hl.GetPreferredRanges() pr := hl.GetPreferredRanges()
+1 -1
View File
@@ -105,7 +105,7 @@ func TestControl_GetHostInfoByVpnIp(t *testing.T) {
} }
// Make sure we don't have any unexpected fields // Make sure we don't have any unexpected fields
assertFields(t, []string{"VpnAddrs", "LocalIndex", "RemoteIndex", "RemoteAddrs", "Cert", "MessageCounter", "CurrentRemote", "CurrentRelaysToMe", "CurrentRelaysThroughMe", "IsLane", "LaneIndex", "SockIdx"}, thi) assertFields(t, []string{"VpnAddrs", "LocalIndex", "RemoteIndex", "RemoteAddrs", "Cert", "MessageCounter", "CurrentRemote", "CurrentRelaysToMe", "CurrentRelaysThroughMe", "Lanes"}, thi)
assert.Equal(t, &expectedInfo, thi) assert.Equal(t, &expectedInfo, thi)
test.AssertDeepCopyEqual(t, &expectedInfo, thi) test.AssertDeepCopyEqual(t, &expectedInfo, thi)
+7 -6
View File
@@ -42,10 +42,11 @@ type Result struct {
// Multiport lane negotiation, from the peer's LaneDetails. All zero when // Multiport lane negotiation, from the peer's LaneDetails. All zero when
// the peer did not advertise (vanilla peer or multiport disabled). // the peer did not advertise (vanilla peer or multiport disabled).
// PeerLaneIndex is nonzero only on the responder side of a lane handshake. // PeerTxLanes is how many lanes the peer may send on, which is how many lane
// sessions we need in order to receive everything it sends.
PeerPortCount uint32 PeerPortCount uint32
PeerBasePort uint32 PeerBasePort uint32
PeerLaneIndex uint32 PeerTxLanes uint32
} }
// Machine drives a Noise handshake through N messages. It handles Noise // Machine drives a Noise handshake through N messages. It handles Noise
@@ -344,18 +345,18 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
// Multiport advert from the peer's side of the exchange. Out-of-range // 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 // values mean a peer we can't pair lanes with; ignore the advert
// rather than failing the handshake — the tunnel itself is fine, it // rather than failing the handshake — the tunnel itself is fine, it
// just won't get lanes. Semantic policing (index bounds vs advert, // just won't get lanes. Semantic policing (port-count caps, lane
// port-count caps) belongs to the handshake manager. // clamping) belongs to the handshake manager.
var peerLanes *LaneDetails var peerLanes *LaneDetails
if m.result.Initiator { if m.result.Initiator {
peerLanes = payload.ResponderLanes peerLanes = payload.ResponderLanes
} else { } else {
peerLanes = payload.InitiatorLanes peerLanes = payload.InitiatorLanes
} }
if peerLanes != nil && peerLanes.BasePort <= 0xffff && peerLanes.PortCount <= 0xffff { if peerLanes != nil && peerLanes.BasePort <= 0xffff && peerLanes.PortCount <= 0xffff && peerLanes.TxLanes <= 0xffff {
m.result.PeerPortCount = peerLanes.PortCount m.result.PeerPortCount = peerLanes.PortCount
m.result.PeerBasePort = peerLanes.BasePort m.result.PeerBasePort = peerLanes.BasePort
m.result.PeerLaneIndex = peerLanes.LaneIndex m.result.PeerTxLanes = peerLanes.TxLanes
} }
} }
+16 -18
View File
@@ -61,30 +61,18 @@ func doFullLaneHandshake(t *testing.T, initLanes, respLanes *LaneDetails) (initR
func TestMachineLaneAdvertBothSides(t *testing.T) { func TestMachineLaneAdvertBothSides(t *testing.T) {
initR, respR := doFullLaneHandshake(t, initR, respR := doFullLaneHandshake(t,
&LaneDetails{PortCount: 8, BasePort: 4242}, &LaneDetails{PortCount: 8, BasePort: 4242, TxLanes: 8},
&LaneDetails{PortCount: 4, BasePort: 5353}, &LaneDetails{PortCount: 4, BasePort: 5353, TxLanes: 3},
) )
// Each side's Result carries the peer's advert. // Each side's Result carries the peer's advert.
assert.Equal(t, uint32(4), initR.PeerPortCount) assert.Equal(t, uint32(4), initR.PeerPortCount)
assert.Equal(t, uint32(5353), initR.PeerBasePort) assert.Equal(t, uint32(5353), initR.PeerBasePort)
assert.Equal(t, uint32(0), initR.PeerLaneIndex) assert.Equal(t, uint32(3), initR.PeerTxLanes)
assert.Equal(t, uint32(8), respR.PeerPortCount) assert.Equal(t, uint32(8), respR.PeerPortCount)
assert.Equal(t, uint32(4242), respR.PeerBasePort) assert.Equal(t, uint32(4242), respR.PeerBasePort)
assert.Equal(t, uint32(0), respR.PeerLaneIndex) assert.Equal(t, uint32(8), respR.PeerTxLanes)
}
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) { func TestMachineLaneAdvertAsymmetric(t *testing.T) {
@@ -103,11 +91,21 @@ func TestMachineLaneAdvertAsymmetric(t *testing.T) {
func TestMachineLaneAdvertOutOfRangeIgnored(t *testing.T) { func TestMachineLaneAdvertOutOfRangeIgnored(t *testing.T) {
// A BasePort that can't be a real UDP port is ignored, not fatal. // A BasePort that can't be a real UDP port is ignored, not fatal.
initR, respR := doFullLaneHandshake(t, initR, respR := doFullLaneHandshake(t,
&LaneDetails{PortCount: 8, BasePort: 70000}, &LaneDetails{PortCount: 8, BasePort: 70000, TxLanes: 8},
&LaneDetails{PortCount: 4, BasePort: 5353}, &LaneDetails{PortCount: 4, BasePort: 5353, TxLanes: 4},
) )
assert.Equal(t, uint32(0), respR.PeerPortCount) assert.Equal(t, uint32(0), respR.PeerPortCount)
assert.Equal(t, uint32(0), respR.PeerBasePort) assert.Equal(t, uint32(0), respR.PeerBasePort)
assert.Equal(t, uint32(0), respR.PeerTxLanes)
// The sane side still negotiates. // The sane side still negotiates.
assert.Equal(t, uint32(4), initR.PeerPortCount) assert.Equal(t, uint32(4), initR.PeerPortCount)
// An out-of-range TxLanes drops the whole advert the same way: a lane index
// that does not fit the header is as unusable as an impossible port.
initR, respR = doFullLaneHandshake(t,
&LaneDetails{PortCount: 8, BasePort: 4242, TxLanes: 0x10000},
&LaneDetails{PortCount: 4, BasePort: 5353, TxLanes: 4},
)
assert.Equal(t, uint32(0), respR.PeerPortCount)
assert.Equal(t, uint32(4), initR.PeerPortCount)
} }
+13 -10
View File
@@ -27,12 +27,14 @@ type Payload struct {
ResponderLanes *LaneDetails ResponderLanes *LaneDetails
} }
// LaneDetails advertises multiport lane capability. LaneIndex is zero on base // LaneDetails advertises multiport lane capability: the contiguous UDP port
// handshakes and the initiator's lane number (>= 1) on lane handshakes. // range the sender bound, and how many lanes it may send on. The receiver needs
// PortCount/BasePort to aim its own lanes and TxLanes to know how many lane
// sessions to derive for receiving.
type LaneDetails struct { type LaneDetails struct {
PortCount uint32 PortCount uint32
BasePort uint32 BasePort uint32
LaneIndex uint32 TxLanes uint32
} }
// Proto field numbers for NebulaHandshakeDetails // Proto field numbers for NebulaHandshakeDetails
@@ -46,11 +48,12 @@ const (
fieldResponderLanes = 10 // LaneDetails fieldResponderLanes = 10 // LaneDetails
) )
// Proto field numbers for LaneDetails // Proto field numbers for LaneDetails.
// Field 3 was a per-lane handshake index and is permanently reserved.
const ( const (
fieldLanePortCount = 1 // uint32 fieldLanePortCount = 1 // uint32
fieldLaneBasePort = 2 // uint32 fieldLaneBasePort = 2 // uint32
fieldLaneLaneIndex = 3 // uint32 fieldLaneTxLanes = 4 // uint32
) )
// MarshalPayload encodes a handshake payload in protobuf wire format compatible // MarshalPayload encodes a handshake payload in protobuf wire format compatible
@@ -105,8 +108,8 @@ func (d *LaneDetails) marshal(out []byte) []byte {
out = protowire.AppendVarint(out, uint64(d.PortCount)) out = protowire.AppendVarint(out, uint64(d.PortCount))
out = protowire.AppendTag(out, fieldLaneBasePort, protowire.VarintType) out = protowire.AppendTag(out, fieldLaneBasePort, protowire.VarintType)
out = protowire.AppendVarint(out, uint64(d.BasePort)) out = protowire.AppendVarint(out, uint64(d.BasePort))
out = protowire.AppendTag(out, fieldLaneLaneIndex, protowire.VarintType) out = protowire.AppendTag(out, fieldLaneTxLanes, protowire.VarintType)
out = protowire.AppendVarint(out, uint64(d.LaneIndex)) out = protowire.AppendVarint(out, uint64(d.TxLanes))
return out return out
} }
@@ -256,7 +259,7 @@ func unmarshalLaneDetails(d *LaneDetails, b []byte) error {
// wire-type mismatch, unknown fields are skipped, repeated singular // wire-type mismatch, unknown fields are skipped, repeated singular
// fields follow proto3 last-wins. // fields follow proto3 last-wins.
switch num { switch num {
case fieldLanePortCount, fieldLaneBasePort, fieldLaneLaneIndex: case fieldLanePortCount, fieldLaneBasePort, fieldLaneTxLanes:
if typ != protowire.VarintType { if typ != protowire.VarintType {
return errInvalidHandshakeDetails return errInvalidHandshakeDetails
} }
@@ -269,8 +272,8 @@ func unmarshalLaneDetails(d *LaneDetails, b []byte) error {
d.PortCount = uint32(v) d.PortCount = uint32(v)
case fieldLaneBasePort: case fieldLaneBasePort:
d.BasePort = uint32(v) d.BasePort = uint32(v)
case fieldLaneLaneIndex: case fieldLaneTxLanes:
d.LaneIndex = uint32(v) d.TxLanes = uint32(v)
} }
b = b[n:] b = b[n:]
default: default:
+4 -4
View File
@@ -146,14 +146,14 @@ func TestPayloadLaneDetails(t *testing.T) {
data := MarshalPayload(nil, Payload{ data := MarshalPayload(nil, Payload{
InitiatorIndex: 12345, InitiatorIndex: 12345,
Time: 999, Time: 999,
InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3}, InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, TxLanes: 3},
ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353}, ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353},
}) })
got, err := UnmarshalPayload(data) got, err := UnmarshalPayload(data)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got.InitiatorLanes) require.NotNil(t, got.InitiatorLanes)
assert.Equal(t, LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3}, *got.InitiatorLanes) assert.Equal(t, LaneDetails{PortCount: 8, BasePort: 4242, TxLanes: 3}, *got.InitiatorLanes)
require.NotNil(t, got.ResponderLanes) require.NotNil(t, got.ResponderLanes)
assert.Equal(t, LaneDetails{PortCount: 4, BasePort: 5353}, *got.ResponderLanes) assert.Equal(t, LaneDetails{PortCount: 4, BasePort: 5353}, *got.ResponderLanes)
}) })
@@ -237,7 +237,7 @@ func TestPayloadLaneDetails(t *testing.T) {
t.Run("lane subfield varint overflow rejected", func(t *testing.T) { t.Run("lane subfield varint overflow rejected", func(t *testing.T) {
var lane []byte var lane []byte
lane = protowire.AppendTag(lane, fieldLaneLaneIndex, protowire.VarintType) lane = protowire.AppendTag(lane, fieldLaneTxLanes, protowire.VarintType)
lane = protowire.AppendVarint(lane, math.MaxUint32+1) lane = protowire.AppendVarint(lane, math.MaxUint32+1)
var details []byte var details []byte
@@ -442,7 +442,7 @@ func FuzzPayload(f *testing.F) {
f.Add(MarshalPayload(nil, Payload{ f.Add(MarshalPayload(nil, Payload{
InitiatorIndex: 1, InitiatorIndex: 1,
Time: 3, Time: 3,
InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 2}, InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, TxLanes: 2},
ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353}, ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353},
})) }))
f.Add([]byte{}) f.Add([]byte{})
+34 -423
View File
@@ -52,7 +52,7 @@ type HandshakeConfig struct {
// Multiport lane parameters; laneCount == 0 means multiport is disabled. // Multiport lane parameters; laneCount == 0 means multiport is disabled.
// laneCount includes implicit lane 0 (the base tunnel), so lanes // laneCount includes implicit lane 0 (the base tunnel), so lanes
// 1..laneCount-1 are initiated. lanePortCount/laneBasePort describe our // 1..laneCount-1 may carry traffic. lanePortCount/laneBasePort describe our
// own bound port range and are advertised in every handshake payload. // own bound port range and are advertised in every handshake payload.
laneCount int laneCount int
lanePortCount uint16 lanePortCount uint16
@@ -73,15 +73,11 @@ type HandshakeManager struct {
outside udp.Conn outside udp.Conn
config HandshakeConfig config HandshakeConfig
OutboundHandshakeTimer *LockingTimerWheel[netip.Addr] OutboundHandshakeTimer *LockingTimerWheel[netip.Addr]
// OutboundLaneTimer drives lane handshake retries. Lanes never enter messageMetrics *MessageMetrics
// vpnIps (they would collide with base handshakes for the same address), metricInitiated metrics.Counter
// so their wheel is keyed by pending localIndexId instead. metricTimedOut metrics.Counter
OutboundLaneTimer *LockingTimerWheel[uint32] f *Interface
messageMetrics *MessageMetrics l *slog.Logger
metricInitiated metrics.Counter
metricTimedOut metrics.Counter
f *Interface
l *slog.Logger
// can be used to trigger outbound handshake for the given vpnIp // can be used to trigger outbound handshake for the given vpnIp
trigger chan netip.Addr trigger chan netip.Addr
@@ -100,11 +96,6 @@ type HandshakeHostInfo struct {
hostinfo *HostInfo hostinfo *HostInfo
machine *handshake.Machine // The handshake state machine, set during stage 0 (initiator) or beginHandshake (responder multi-message) 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) { func (hh *HandshakeHostInfo) cachePacket(l *slog.Logger, t header.MessageType, st header.MessageSubType, packet []byte, f packetCallback, m *cachedPacketMetrics) {
@@ -142,7 +133,6 @@ func NewHandshakeManager(l *slog.Logger, mainHostMap *HostMap, lightHouse *Light
config: config, config: config,
trigger: make(chan netip.Addr, config.triggerBuffer), trigger: make(chan netip.Addr, config.triggerBuffer),
OutboundHandshakeTimer: NewLockingTimerWheel[netip.Addr](config.tryInterval, hsTimeout(config.retries, config.tryInterval)), OutboundHandshakeTimer: NewLockingTimerWheel[netip.Addr](config.tryInterval, hsTimeout(config.retries, config.tryInterval)),
OutboundLaneTimer: NewLockingTimerWheel[uint32](config.tryInterval, hsTimeout(config.retries, config.tryInterval)),
messageMetrics: config.messageMetrics, messageMetrics: config.messageMetrics,
metricInitiated: metrics.GetOrRegisterCounter("handshake_manager.initiated", nil), metricInitiated: metrics.GetOrRegisterCounter("handshake_manager.initiated", nil),
metricTimedOut: metrics.GetOrRegisterCounter("handshake_manager.timed_out", nil), metricTimedOut: metrics.GetOrRegisterCounter("handshake_manager.timed_out", nil),
@@ -162,7 +152,6 @@ func (hm *HandshakeManager) Run(ctx context.Context) {
hm.handleOutbound(vpnIP, true) hm.handleOutbound(vpnIP, true)
case now := <-clockSource.C: case now := <-clockSource.C:
hm.NextOutboundHandshakeTimerTick(now) hm.NextOutboundHandshakeTimerTick(now)
hm.NextOutboundLaneTimerTick(now)
} }
} }
} }
@@ -554,10 +543,9 @@ func (hm *HandshakeManager) DeleteHostInfo(hostinfo *HostInfo) {
func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) { func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) {
for _, addr := range hostinfo.vpnAddrs { for _, addr := range hostinfo.vpnAddrs {
// Only delete the pending entry if it is actually ours. Lane // Only delete the pending entry if it is actually ours: an
// handshakes never live in vpnIps, and an unconditional delete here // unconditional delete could evict a concurrently pending handshake for
// could evict a concurrently pending base handshake for the same // the same address.
// address.
if cur, ok := hm.vpnIps[addr]; ok && cur.hostinfo == hostinfo { if cur, ok := hm.vpnIps[addr]; ok && cur.hostinfo == hostinfo {
delete(hm.vpnIps, addr) delete(hm.vpnIps, addr)
} }
@@ -695,7 +683,7 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
v, cs.GetCredential, v, cs.GetCredential,
hm.certVerifier(), func() (uint32, error) { return hm.allocateIndex(hh) }, hm.certVerifier(), func() (uint32, error) { return hm.allocateIndex(hh) },
true, header.HandshakeIXPSK0, true, header.HandshakeIXPSK0,
hm.laneAdvert(uint32(hh.hostinfo.laneIndex)), hm.laneAdvert(),
) )
if err != nil { if err != nil {
hm.f.l.Error("Failed to create handshake machine", hm.f.l.Error("Failed to create handshake machine",
@@ -721,227 +709,38 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
// laneAdvert returns our multiport advert for a handshake payload, or nil // laneAdvert returns our multiport advert for a handshake payload, or nil
// when multiport is disabled (which keeps the payload byte-identical to // when multiport is disabled (which keeps the payload byte-identical to
// vanilla). laneIndex is 0 for base handshakes and our lane number for lane // vanilla).
// handshakes. func (hm *HandshakeManager) laneAdvert() *handshake.LaneDetails {
func (hm *HandshakeManager) laneAdvert(laneIndex uint32) *handshake.LaneDetails {
if hm.config.laneCount == 0 { if hm.config.laneCount == 0 {
return nil return nil
} }
return &handshake.LaneDetails{ return &handshake.LaneDetails{
PortCount: uint32(hm.config.lanePortCount), PortCount: uint32(hm.config.lanePortCount),
BasePort: uint32(hm.config.laneBasePort), BasePort: uint32(hm.config.laneBasePort),
LaneIndex: laneIndex, TxLanes: uint32(hm.config.laneCount),
} }
} }
// maybeAllocLaneState attaches a laneState to a just-completed base tunnel // maybeAllocLanes derives the multiport lane sessions for a just-completed
// when both sides advertised multiport. Must run before the hostinfo becomes // tunnel. Must run before the hostinfo becomes visible in the hostmap: the data
// visible in the hostmap: the data plane reads base.lanes lock-free. // plane reads hostinfo.lanes with no synchronization at all.
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
}
var offset uint16
if len(hm.f.myVpnAddrs) > 0 && len(hostinfo.vpnAddrs) > 0 {
offset = lanePortOffset(hm.f.myVpnAddrs[0], hostinfo.vpnAddrs[0], uint16(peerPorts))
}
hostinfo.lanes = newLaneState(hm.config.laneCount, uint16(peerPorts), uint16(result.PeerBasePort), offset)
}
// EnsureLanes starts lane handshakes for every empty, non-pending, retry-due
// slot of a base tunnel that the data plane has asked for. Called on base
// handshake completion (both sides) and from the connection manager's
// per-tunnel tick, so a demanded lane is established, and a dead one
// re-established, within one check interval subject to per-slot backoff.
// //
// Demand is what makes lanes lazy: nothing is built when the base tunnel // A failure here costs the tunnel its lanes, not the tunnel: the base session is
// completes, only when a routine actually has traffic for the peer and finds // already usable and every lane the data plane wants falls back to it.
// its slot empty (see noteLaneDemand). Consuming the flag here rather than func (hm *HandshakeManager) maybeAllocLanes(hostinfo *HostInfo, result *handshake.Result) {
// leaving it set also means a lane that dies after its routine went quiet if hm.config.laneCount == 0 || result.PeerPortCount == 0 {
// stays dead instead of being rebuilt forever. return
func (hm *HandshakeManager) EnsureLanes(base *HostInfo) { }
ls := base.lanes if len(hm.f.myVpnAddrs) == 0 || len(hostinfo.vpnAddrs) == 0 {
if ls == nil || hm.config.laneCount <= 1 {
return return
} }
now := time.Now() ls, err := newLaneSet(result, hm.config.laneCount, hm.f.myVpnAddrs[0], hostinfo.vpnAddrs[0])
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
}
// Checked last: a slot that isn't startable keeps its demand for the
// tick that can act on it.
if !ls.takeLaneDemand(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.laneTargetPort(i))
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 { if err != nil {
hostinfo.logger(hm.l).Error("Failed to send lane handshake message", hostinfo.logger(hm.l).Error("Failed to derive multiport lanes", "error", err)
"laneIndex", hostinfo.laneIndex, return
"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,
)
} }
hostinfo.lanes = ls
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 // beginHandshake handles an incoming handshake packet that doesn't match any
@@ -962,7 +761,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
v, cs.GetCredential, v, cs.GetCredential,
hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) }, hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) },
false, header.HandshakeIXPSK0, false, header.HandshakeIXPSK0,
hm.laneAdvert(0), hm.laneAdvert(),
) )
if err != nil { if err != nil {
f.l.Error("Failed to create handshake machine", "from", via, "error", err) f.l.Error("Failed to create handshake machine", "from", via, "error", err)
@@ -999,13 +798,6 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
return 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) connState, err := newConnectionStateFromResult(result)
if err != nil { if err != nil {
f.l.Error("Discarding handshake with an invalid message index", "error", err, "vpnAddrs", vpnAddrs) f.l.Error("Discarding handshake with an invalid message index", "error", err, "vpnAddrs", vpnAddrs)
@@ -1056,7 +848,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
hostinfo.SetRemote(via.UdpAddr) hostinfo.SetRemote(via.UdpAddr)
} }
hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate) hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
hm.maybeAllocLaneState(hostinfo, result) hm.maybeAllocLanes(hostinfo, result)
existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f) existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
if err != nil { if err != nil {
@@ -1066,7 +858,6 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
hm.sendHandshakeResponse(via, response, hostinfo, false) hm.sendHandshakeResponse(via, response, hostinfo, false)
hostinfo.remotes.RefreshFromHandshake(vpnAddrs) hostinfo.remotes.RefreshFromHandshake(vpnAddrs)
hm.EnsureLanes(hostinfo)
// Don't wait for UpdateWorker // Don't wait for UpdateWorker
if f.lightHouse.IsAnyLighthouseAddr(vpnAddrs) { if f.lightHouse.IsAnyLighthouseAddr(vpnAddrs) {
@@ -1074,146 +865,6 @@ 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. // continueHandshake feeds an incoming packet to an existing pending handshake Machine.
func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostInfo, packet []byte) { func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostInfo, packet []byte) {
f := hm.f f := hm.f
@@ -1234,14 +885,6 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
} }
hostinfo := hh.hostinfo 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 !via.IsRelayed {
if !f.lightHouse.GetRemoteAllowList().AllowAll(hostinfo.vpnAddrs, via.UdpAddr.Addr()) { if !f.lightHouse.GetRemoteAllowList().AllowAll(hostinfo.vpnAddrs, via.UdpAddr.Addr()) {
f.l.Debug("lighthouse.remote_allow_list denied incoming handshake", f.l.Debug("lighthouse.remote_allow_list denied incoming handshake",
@@ -1254,7 +897,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
if machine == nil { if machine == nil {
f.l.Error("No handshake machine available for continuation", f.l.Error("No handshake machine available for continuation",
"vpnAddrs", hostinfo.vpnAddrs, "from", via) "vpnAddrs", hostinfo.vpnAddrs, "from", via)
hm.deletePendingHostInfo(hostinfo) hm.DeleteHostInfo(hostinfo)
return return
} }
@@ -1264,7 +907,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
if machine.Failed() { if machine.Failed() {
f.l.Warn("Failed to process handshake packet, abandoning", f.l.Warn("Failed to process handshake packet, abandoning",
"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err) "vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
hm.deletePendingHostInfo(hostinfo) hm.DeleteHostInfo(hostinfo)
} else { } else {
f.l.Debug("Failed to process handshake packet", f.l.Debug("Failed to process handshake packet",
"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err) "vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
@@ -1293,7 +936,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
if remoteCert == nil { if remoteCert == nil {
f.l.Error("Handshake completed without peer certificate", f.l.Error("Handshake completed without peer certificate",
"vpnAddrs", hostinfo.vpnAddrs, "from", via) "vpnAddrs", hostinfo.vpnAddrs, "from", via)
hm.deletePendingHostInfo(hostinfo) hm.DeleteHostInfo(hostinfo)
return return
} }
@@ -1327,7 +970,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
"issuer", remoteCert.Certificate.Issuer(), "issuer", remoteCert.Certificate.Issuer(),
"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())}, "handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())},
) )
hm.deletePendingHostInfo(hostinfo) hm.DeleteHostInfo(hostinfo)
return return
} }
vpnAddrs[i] = network.Addr() vpnAddrs[i] = network.Addr()
@@ -1351,15 +994,6 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())}, "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.DeleteHostInfo(hostinfo)
hm.StartHandshake(hostinfo.vpnAddrs[0], func(newHH *HandshakeHostInfo) { hm.StartHandshake(hostinfo.vpnAddrs[0], func(newHH *HandshakeHostInfo) {
newHH.hostinfo.remotes = hostinfo.remotes newHH.hostinfo.remotes = hostinfo.remotes
@@ -1394,31 +1028,8 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
hostinfo.vpnAddrs = vpnAddrs hostinfo.vpnAddrs = vpnAddrs
hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate) hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
if hostinfo.isLane() { hm.maybeAllocLanes(hostinfo, result)
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.Complete(hostinfo, f)
hm.EnsureLanes(hostinfo)
if len(hh.packetStore) > 0 { if len(hh.packetStore) > 0 {
if f.l.Enabled(context.Background(), slog.LevelDebug) { if f.l.Enabled(context.Background(), slog.LevelDebug) {
+32 -5
View File
@@ -12,6 +12,9 @@ import (
// |-----------------------------------------------------------------------| // |-----------------------------------------------------------------------|
// | Version (uint4) | Type (uint4) | Subtype (uint8) | Reserved (uint16) | 32 // | Version (uint4) | Type (uint4) | Subtype (uint8) | Reserved (uint16) | 32
// |-----------------------------------------------------------------------| // |-----------------------------------------------------------------------|
// The low 8 bits of Reserved carry the multiport lane index (0 is the base
// tunnel, which is what every non-multiport sender emits). The high 8 bits
// remain reserved and are always sent as zero.
// | Remote index (uint32) | 64 // | Remote index (uint32) | 64
// |-----------------------------------------------------------------------| // |-----------------------------------------------------------------------|
// | Message counter | 96 // | Message counter | 96
@@ -57,8 +60,18 @@ const (
const ( const (
TestRequest MessageSubType = 0 TestRequest MessageSubType = 0
TestReply MessageSubType = 1 TestReply MessageSubType = 1
// LaneProbe is sent on a multiport lane to prove the lane's 5-tuple is
// usable; LaneProbeAck answers it on the base tunnel.
LaneProbe MessageSubType = 2
LaneProbeAck MessageSubType = 3
) )
// MaxLane is the largest lane index the header can carry.
const MaxLane = 0xff
// laneMask covers the bits of Reserved that hold the lane index.
const laneMask uint16 = 0x00ff
const ( const (
HandshakeIXPSK0 MessageSubType = 0 HandshakeIXPSK0 MessageSubType = 0
HandshakeXXPSK0 MessageSubType = 1 HandshakeXXPSK0 MessageSubType = 1
@@ -67,8 +80,10 @@ const (
var ErrHeaderTooShort = errors.New("header is too short") var ErrHeaderTooShort = errors.New("header is too short")
var subTypeTestMap = map[MessageSubType]string{ var subTypeTestMap = map[MessageSubType]string{
TestRequest: "testRequest", TestRequest: "testRequest",
TestReply: "testReply", TestReply: "testReply",
LaneProbe: "laneProbe",
LaneProbeAck: "laneProbeAck",
} }
var subTypeNoneMap = map[MessageSubType]string{0: "none"} var subTypeNoneMap = map[MessageSubType]string{0: "none"}
@@ -100,10 +115,16 @@ type H struct {
// Encode uses the provided byte array to encode the provided header values into. // Encode uses the provided byte array to encode the provided header values into.
// Byte array must be capped higher than HeaderLen or this will panic // Byte array must be capped higher than HeaderLen or this will panic
func Encode(b []byte, v uint8, t MessageType, st MessageSubType, ri uint32, c uint64) []byte { func Encode(b []byte, v uint8, t MessageType, st MessageSubType, ri uint32, c uint64) []byte {
return EncodeLane(b, v, t, st, ri, c, 0)
}
// EncodeLane is Encode with an explicit multiport lane index, which is carried
// in the low 8 bits of Reserved.
func EncodeLane(b []byte, v uint8, t MessageType, st MessageSubType, ri uint32, c uint64, lane uint8) []byte {
b = b[:Len] b = b[:Len]
b[0] = v<<4 | byte(t&0x0f) b[0] = v<<4 | byte(t&0x0f)
b[1] = byte(st) b[1] = byte(st)
binary.BigEndian.PutUint16(b[2:4], 0) binary.BigEndian.PutUint16(b[2:4], uint16(lane))
binary.BigEndian.PutUint32(b[4:8], ri) binary.BigEndian.PutUint32(b[4:8], ri)
binary.BigEndian.PutUint64(b[8:16], c) binary.BigEndian.PutUint64(b[8:16], c)
return b return b
@@ -136,7 +157,13 @@ func (h *H) Encode(b []byte) ([]byte, error) {
return nil, errors.New("nil header") return nil, errors.New("nil header")
} }
return Encode(b, h.Version, h.Type, h.Subtype, h.RemoteIndex, h.MessageCounter), nil return EncodeLane(b, h.Version, h.Type, h.Subtype, h.RemoteIndex, h.MessageCounter, h.Lane()), nil
}
// Lane returns the multiport lane index carried in Reserved. Lane 0 is the base
// tunnel, which is what any sender that does not know about lanes will report.
func (h *H) Lane() uint8 {
return uint8(h.Reserved & laneMask)
} }
// Parse is a helper function to parses given bytes into new Header struct // Parse is a helper function to parses given bytes into new Header struct
@@ -196,7 +223,7 @@ func IsValidSubType(t MessageType, s MessageSubType) bool {
case Handshake: case Handshake:
return s == HandshakeIXPSK0 return s == HandshakeIXPSK0
case Test: case Test:
return s == TestReply || s == TestRequest return s == TestReply || s == TestRequest || s == LaneProbe || s == LaneProbeAck
case Control, CloseTunnel, RecvError, LightHouse: case Control, CloseTunnel, RecvError, LightHouse:
return s == 0 return s == 0
default: default:
+25 -1
View File
@@ -52,6 +52,28 @@ func TestParse(t *testing.T) {
} }
} }
func TestEncodeLane(t *testing.T) {
b := EncodeLane(make([]byte, Len), Version, Message, MessageNone, 10, 9, 3)
assert.Equal(t, []byte{0x11, 0x0, 0x0, 0x3}, b[:4])
h := &H{}
require.NoError(t, h.Parse(b))
assert.Equal(t, uint16(3), h.Reserved)
assert.Equal(t, uint8(3), h.Lane())
// Encode is EncodeLane on the base tunnel, and the H method round trips the lane.
assert.Equal(t,
EncodeLane(make([]byte, Len), Version, Message, MessageNone, 10, 9, 0),
Encode(make([]byte, Len), Version, Message, MessageNone, 10, 9))
rt, err := h.Encode(make([]byte, Len))
require.NoError(t, err)
assert.Equal(t, b, rt)
// Only the low 8 bits of Reserved are the lane.
assert.Equal(t, uint8(0x2a), (&H{Reserved: 0xff2a}).Lane())
}
func TestTypeName(t *testing.T) { func TestTypeName(t *testing.T) {
assert.Equal(t, "test", TypeName(Test)) assert.Equal(t, "test", TypeName(Test))
assert.Equal(t, "test", (&H{Type: Test}).TypeName()) assert.Equal(t, "test", (&H{Type: Test}).TypeName())
@@ -127,7 +149,9 @@ func TestIsValidSubType(t *testing.T) {
assert.True(t, IsValidSubType(Test, TestRequest)) assert.True(t, IsValidSubType(Test, TestRequest))
assert.True(t, IsValidSubType(Test, TestReply)) assert.True(t, IsValidSubType(Test, TestReply))
assert.False(t, IsValidSubType(Test, 2)) assert.True(t, IsValidSubType(Test, LaneProbe))
assert.True(t, IsValidSubType(Test, LaneProbeAck))
assert.False(t, IsValidSubType(Test, 4))
// These types only ever carry subtype 0. // These types only ever carry subtype 0.
for _, mt := range []MessageType{Control, CloseTunnel, RecvError, LightHouse} { for _, mt := range []MessageType{Control, CloseTunnel, RecvError, LightHouse} {
+6 -271
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"log/slog" "log/slog"
"net" "net"
"net/netip" "net/netip"
@@ -280,208 +279,10 @@ type HostInfo struct {
// This should only be used by the ConnectionManagers ticker routine. // This should only be used by the ConnectionManagers ticker routine.
lastUsed time.Time lastUsed time.Time
// sockIdx is the index into Interface.writers of the socket every packet // lanes holds this tunnel's multiport lane sessions. Allocated when the
// on this tunnel egresses from (and, for lanes, arrives on). 0 for base // handshake completes if both sides advertised multiport, nil otherwise.
// and vanilla tunnels — the zero value preserves stock behavior. // Immutable once the hostinfo is published to the data plane.
sockIdx int lanes *laneSet
// laneIndex is the owner's lane number for a lane tunnel; 0 for base.
laneIndex uint16
// laneOwned is true when we initiated this lane (it carries our TX data).
laneOwned bool
// parent points at the base tunnel a lane hangs off of; nil for base and
// vanilla tunnels. Set before the lane is registered in hostmap.Indexes.
parent *HostInfo
// lanes is allocated on a base tunnel when multiport is enabled and the
// peer advertised lane support; nil otherwise.
lanes *laneState
}
// isLane reports whether this HostInfo is a lane tunnel rather than a base
// (or vanilla) tunnel.
func (i *HostInfo) isLane() bool {
return i.parent != nil
}
// laneState hangs off a base HostInfo and tracks the multiport lane tunnels
// associated with it. txLanes is read lock-free on the TX hot path; the Mutex
// guards everything else.
type laneState struct {
sync.Mutex
// peerPortCount/peerBasePort are the peer's advert from the base
// handshake; portOffset is the per-pair rotation from lanePortOffset.
// Lane i targets peerBasePort + ((i + portOffset) % peerPortCount).
peerPortCount uint16
peerBasePort uint16
portOffset uint16
// txLanes[i] is our established, initiator-owned lane for routine i, or
// nil. Index 0 is always nil — the base tunnel is lane 0. A pointer is
// only Stored once the lane's ConnectionState is fully populated, so a
// data-plane routine that Loads non-nil always sees a usable tunnel.
txLanes []atomic.Pointer[HostInfo]
// txDemand[i] is set by the data plane when a routine riding slot i has
// traffic for this peer and no established lane, and consumed when
// EnsureLanes claims the slot. Lanes exist only where traffic asked for
// one: a peer we exchange a trickle with never costs more than the base
// tunnel, no matter how many routines are configured. Written without
// the Mutex.
txDemand []atomic.Bool
// Under Mutex: per-slot handshake-in-flight flag, consecutive failure
// count, and earliest next attempt, driving ensureLanes' backoff.
txPending []bool
txFails []uint8
txRetryAt []time.Time
// Under Mutex: responder-side records of peer-owned lanes, capped by
// same-laneIndex replacement.
peerLanes []*HostInfo
}
func newLaneState(laneCount int, peerPortCount, peerBasePort, portOffset uint16) *laneState {
return &laneState{
peerPortCount: peerPortCount,
peerBasePort: peerBasePort,
portOffset: portOffset,
txLanes: make([]atomic.Pointer[HostInfo], laneCount),
txDemand: make([]atomic.Bool, laneCount),
txPending: make([]bool, laneCount),
txFails: make([]uint8, laneCount),
txRetryAt: make([]time.Time, laneCount),
}
}
// noteLaneDemand records that a data-plane routine has traffic for lane slot
// i but found the slot empty. Called from the TX hot path on every packet
// that misses, so the store is load-guarded: once the flag is up, further
// misses are plain reads and cannot ping-pong a cache line shared with the
// neighbouring slots' flags. Slot 0 is the base tunnel and never a lane.
func (ls *laneState) noteLaneDemand(i int) {
if i <= 0 || i >= len(ls.txDemand) {
return
}
if !ls.txDemand[i].Load() {
ls.txDemand[i].Store(true)
}
}
// takeLaneDemand consumes slot i's demand flag, reporting whether the data
// plane had asked for the lane since the last time we looked.
func (ls *laneState) takeLaneDemand(i int) bool {
if i <= 0 || i >= len(ls.txDemand) {
return false
}
return ls.txDemand[i].Swap(false)
}
// laneTargetPort returns the peer port that owned lane i handshakes to and
// egresses toward. The caller must ensure peerPortCount != 0.
func (ls *laneState) laneTargetPort(i int) uint16 {
return ls.peerBasePort + uint16((i+int(ls.portOffset))%int(ls.peerPortCount))
}
// lanePortOffset returns the rotation applied to this pair's lane target
// ports, in [0, peerPortCount). Without it every low-routine peer would aim
// its few lanes at a big peer's first few ports, concentrating the big
// peer's receive work on a couple of sockets; the hash spreads pairs across
// the whole advertised range.
//
// Both sides hash the same sorted vpn-address pair and the higher address
// negates the result, so when port counts match the two sides' rotations
// cancel: our lane i's 4-tuple is still the reverse of a peer-owned lane's,
// and each outbound lane handshake opens the conntrack entry its partner
// arrives through. (The one lane a nonzero rotation lands on the peer's base
// port has no partner lane; behind a port-restricted NAT it may not form and
// its routine rides the base tunnel — the standard lane fallback.)
func lanePortOffset(myAddr, peerAddr netip.Addr, peerPortCount uint16) uint16 {
if peerPortCount == 0 {
return 0
}
lo, hi := myAddr, peerAddr
if hi.Less(lo) {
lo, hi = hi, lo
}
h := fnv.New32a()
b := lo.As16()
h.Write(b[:])
b = hi.As16()
h.Write(b[:])
o := uint16(h.Sum32() % uint32(peerPortCount))
if myAddr == hi {
o = (peerPortCount - o) % peerPortCount
}
return o
}
const (
laneRetryBase = 5 * time.Second
laneRetryMax = 60 * time.Second
)
// noteLaneFailure marks lane slot i as empty and pushes the next attempt out
// with exponential backoff. Called when an owned lane dies or its handshake
// times out.
func (ls *laneState) noteLaneFailure(i int) {
if i < 0 || i >= len(ls.txPending) {
return
}
ls.Lock()
ls.txPending[i] = false
if ls.txFails[i] < 200 { // just avoid wrapping; the delay caps far earlier
ls.txFails[i]++
}
d := laneRetryBase << min(ls.txFails[i], 4)
if d > laneRetryMax {
d = laneRetryMax
}
ls.txRetryAt[i] = time.Now().Add(d)
ls.Unlock()
}
// noteOwnedLaneDeath detaches an established owned lane from its slot
// (identity-checked, so a raced re-establishment is never clobbered) and
// applies failure backoff.
func (ls *laneState) noteOwnedLaneDeath(lane *HostInfo) {
i := int(lane.laneIndex)
if i >= len(ls.txLanes) {
return
}
ls.txLanes[i].CompareAndSwap(lane, nil)
ls.noteLaneFailure(i)
}
// removePeerLane drops a responder-side lane record by identity.
func (ls *laneState) removePeerLane(lane *HostInfo) {
ls.Lock()
for n, h := range ls.peerLanes {
if h == lane {
ls.peerLanes = append(ls.peerLanes[:n], ls.peerLanes[n+1:]...)
break
}
}
ls.Unlock()
}
// snapshotLanes returns every lane hostinfo currently attached, used by the
// base-delete cascade. Taken under the lock and returned as a copy so the
// caller can delete without holding it.
func (ls *laneState) snapshotLanes() []*HostInfo {
ls.Lock()
defer ls.Unlock()
out := make([]*HostInfo, 0, len(ls.txLanes)+len(ls.peerLanes))
for n := range ls.txLanes {
if h := ls.txLanes[n].Load(); h != nil {
out = append(out, h)
}
}
out = append(out, ls.peerLanes...)
return out
} }
type ViaSender struct { type ViaSender struct {
@@ -655,12 +456,6 @@ func (hm *HostMap) MakePrimary(hostinfo *HostInfo) {
// unlockedMakePrimary reports whether hostinfo is (now) the primary for each of its addresses, // 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. // false only when it is no longer in the hostmap at all.
func (hm *HostMap) unlockedMakePrimary(hostinfo *HostInfo) bool { 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 // 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 // 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 // after a delete fully unlinked the hostinfo (connection manager swapPrimary, AddRelay). Every
@@ -689,18 +484,8 @@ func (hm *HostMap) unlockedMakePrimary(hostinfo *HostInfo) bool {
// any tunnel to the peer), which the caller uses to decide whether to clear learned lighthouse // any tunnel to the peer), which the caller uses to decide whether to clear learned lighthouse
// state and disestablish relays. // state and disestablish relays.
func (hm *HostMap) unlockedDeleteHostInfo(hostinfo *HostInfo) bool { func (hm *HostMap) unlockedDeleteHostInfo(hostinfo *HostInfo) bool {
if hostinfo.isLane() { // Lane sessions hang off this hostinfo, so deleting it takes them with it
return hm.unlockedDeleteLane(hostinfo) // and there is nothing extra to unwind here.
}
// 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 // 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. // sibling is never promoted to an address it does not own and no other list is touched.
@@ -778,35 +563,6 @@ func (hm *HostMap) QueryIndexCached(index uint32, cache map[uint32]*HostInfo) *H
return out 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 { func (hm *HostMap) QueryIndex(index uint32) *HostInfo {
hm.RLock() hm.RLock()
if h, ok := hm.Indexes[index]; ok { if h, ok := hm.Indexes[index]; ok {
@@ -935,27 +691,6 @@ func (hm *HostMap) unlockedAddHostInfo(hostinfo *HostInfo, f *Interface) {
} }
} }
// unlockedAddLane registers a lane tunnel in the index maps (RX demux and
// recv_error need it there) without touching Hosts: lanes are never primary,
// never dns-visible, and never subject to the MaxHostInfosPerVpnIp eviction.
// The connection manager still tracks it for keepalive/death.
func (hm *HostMap) unlockedAddLane(lane *HostInfo, f *Interface) {
hm.Indexes[lane.localIndexId] = lane
hm.RemoteIndexes[lane.remoteIndexId] = lane
lane.markOutOnly()
if f.connectionManager != nil { // f.connectionManager is only nil in some unit tests
f.connectionManager.trafficTimer.Add(lane.localIndexId, f.connectionManager.checkInterval)
}
if hm.l.Enabled(context.Background(), slog.LevelDebug) {
hm.l.Debug("Hostmap lane added",
"hostMap", m{"vpnAddrs": lane.vpnAddrs, "laneIndex": lane.laneIndex,
"indexNumber": lane.localIndexId, "remoteIndexNumber": lane.remoteIndexId},
)
}
}
func (hm *HostMap) unlockedInnerAddHostInfo(vpnAddr netip.Addr, hostinfo *HostInfo, f *Interface) { func (hm *HostMap) unlockedInnerAddHostInfo(vpnAddr netip.Addr, hostinfo *HostInfo, f *Interface) {
existing, ok := hm.Hosts[vpnAddr] existing, ok := hm.Hosts[vpnAddr]
if !ok { if !ok {
+28 -47
View File
@@ -122,13 +122,13 @@ func (f *Interface) consumeInsidePacket(pkt tio.Packet, fwPacket *firewall.Parse
} }
} }
func (f *Interface) sendInsideEncrypt(hostinfo *HostInfo, ci *ConnectionState, seg, scratch, nb []byte) []byte { func (f *Interface) sendInsideEncrypt(hostinfo *HostInfo, ci *ConnectionState, lane uint8, seg, scratch, nb []byte) []byte {
if noiseutil.EncryptLockNeeded { if noiseutil.EncryptLockNeeded {
ci.writeLock.Lock() ci.writeLock.Lock()
} }
c := ci.messageCounter.Add(1) c := ci.messageCounter.Add(1)
out := header.Encode(scratch, header.Version, header.Message, 0, hostinfo.remoteIndexId, c) out := header.EncodeLane(scratch, header.Version, header.Message, 0, hostinfo.remoteIndexId, c, lane)
out, encErr := ci.eKey.EncryptDanger(out, out, seg, c, nb) out, encErr := ci.eKey.EncryptDanger(out, out, seg, c, nb)
if noiseutil.EncryptLockNeeded { if noiseutil.EncryptLockNeeded {
@@ -154,9 +154,8 @@ func (f *Interface) sendInsideEncrypt(hostinfo *HostInfo, ci *ConnectionState, s
// scratch arena: SegmentSuperpacket builds each segment's plaintext in // scratch arena: SegmentSuperpacket builds each segment's plaintext in
// segScratch[:segLen] in turn, and we encrypt directly into a fresh SendBatch slot. // segScratch[:segLen] in turn, and we encrypt directly into a fresh SendBatch slot.
// //
// hostinfo is always the base tunnel (the hostmap resolves by vpn address); // When routine q has a usable multiport lane to this peer, the direct path
// when routine q has an established lane to this peer, the direct path swaps // swaps to the lane's session and socket below. Relay and base traffic stays on
// to the lane's session and socket below. Relay and base traffic stays on
// tx.base (socket 0). // tx.base (socket 0).
func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []byte, tx *txQueue) { func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []byte, tx *txQueue) {
ci := hostinfo.ConnectionState ci := hostinfo.ConnectionState
@@ -208,7 +207,7 @@ func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []b
//relay header + header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305) + relay tag //relay header + header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305) + relay tag
scratch := sendBatch.Reserve(header.Len + header.Len + len(seg) + 16 + 16) scratch := sendBatch.Reserve(header.Len + header.Len + len(seg) + 16 + 16)
innerPacket := f.sendInsideEncrypt(hostinfo, ci, seg, scratch[header.Len:], nb) innerPacket := f.sendInsideEncrypt(hostinfo, ci, 0, seg, scratch[header.Len:], nb)
if innerPacket == nil { if innerPacket == nil {
return nil return nil
} }
@@ -229,35 +228,28 @@ func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []b
return return
} }
// Direct path: prefer this routine's lane tunnel when it is established. // Direct path: prefer this routine's multiport lane once it is proven
// The pointer is only published once the lane's ConnectionState is fully // usable. txLane hands back the lane's session and destination together, so
// populated, so a non-nil Load is always usable. On lane death the slot // there is no window where one is set and the other is not, and a demotion
// CAS-clears and traffic falls back to the base tunnel instantly. // drops us back onto the base tunnel on the very next packet.
// //
// A miss is also how lanes get built in the first place: flagging demand // A miss is also how lanes get probed in the first place: txLane raises
// here is the only thing that asks the handshake manager for this slot, // demand, which the connection manager's next tick on this tunnel picks up.
// so we pay for a lane exactly where real traffic wanted one. The // Until the lane is up the traffic rides the base tunnel, the same fallback
// connection manager's next tick on this tunnel picks the flag up, which // a demoted lane uses.
// bounds establishment by one check interval — until then the traffic lane := uint8(0)
// rides the base tunnel, the same fallback a dead lane uses. if lci, laneRemote := hostinfo.lanes.txLane(tx.laneSlot); lci != nil {
if ls := hostinfo.lanes; ls != nil && tx.laneSlot < len(ls.txLanes) { lane = uint8(tx.laneSlot)
if lane := ls.txLanes[tx.laneSlot].Load(); lane != nil { ci = lci
if lci := lane.ConnectionState; lci != nil && lci.eKey != nil { remote = laneRemote
hostinfo = lane sendBatch = tx.lane
ci = lci
remote = lane.GetRemote()
sendBatch = tx.lane
}
} else {
ls.noteLaneDemand(tx.laneSlot)
}
} }
err := tio.SegmentSuperpacket(pkt, func(seg []byte) error { err := tio.SegmentSuperpacket(pkt, func(seg []byte) error {
// header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305) // header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305)
scratch := sendBatch.Reserve(header.Len + len(seg) + 16) scratch := sendBatch.Reserve(header.Len + len(seg) + 16)
out := f.sendInsideEncrypt(hostinfo, ci, seg, scratch, nb) out := f.sendInsideEncrypt(hostinfo, ci, lane, seg, scratch, nb)
if out == nil { if out == nil {
return nil return nil
} }
@@ -546,8 +538,7 @@ func (f *Interface) SendVia(via *HostInfo, relay *Relay, ad, nb, out []byte, noc
return return
} }
// Relay carriers are base tunnels (sockIdx 0); indexing through the carrier keeps the invariant explicit. err = f.writers[f.egressSock(q)].WriteTo(toSend, via.GetRemote())
err = f.writers[f.egressSock(via, q)].WriteTo(toSend, via.GetRemote())
if err != nil { if err != nil {
via.logger(f.l).Info("Failed to WriteTo in sendVia", "error", err) via.logger(f.l).Info("Failed to WriteTo in sendVia", "error", err)
} }
@@ -555,15 +546,15 @@ func (f *Interface) SendVia(via *HostInfo, relay *Relay, ad, nb, out []byte, noc
// egressSock picks the socket a tunnel packet leaves from. // 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 // With multiport, everything that is not lane data plane egresses socket 0: handshakes, keepalives, close packets,
// spoof/roam checks accept keepalives, close packets and rejects and the NAT entry stays warm, and a base or relay // rejects and relay carriers all belong to the base tunnel's 4-tuple, which is the only one a peer's spoof/roam checks
// carrier must egress socket 0 so a vanilla peer never sees per-routine source ports. // and a vanilla peer's expectations know about. Lane data is sent directly through writers[lane] and never comes here.
// //
// Without multiport every socket shares one port under SO_REUSEPORT, so the source address is identical either way and // 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. // 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 { func (f *Interface) egressSock(q int) int {
if f.multiport { if f.multiport {
return hostinfo.sockIdx return 0
} }
return q return q
} }
@@ -572,7 +563,7 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
if ci.eKey == nil { if ci.eKey == nil {
return return
} }
q = f.egressSock(hostinfo, q) q = f.egressSock(q)
useRelay := !remote.IsValid() && !hostinfo.GetRemote().IsValid() useRelay := !remote.IsValid() && !hostinfo.GetRemote().IsValid()
fullOut := out fullOut := out
@@ -604,10 +595,7 @@ 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 // 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 // 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. // 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]) f.lightHouse.QueryServer(hostinfo.vpnAddrs[0])
if f.l.Enabled(context.Background(), slog.LevelDebug) { if f.l.Enabled(context.Background(), slog.LevelDebug) {
f.l.Debug("Lighthouse update triggered for punch due to rebind epoch", f.l.Debug("Lighthouse update triggered for punch due to rebind epoch",
@@ -647,13 +635,6 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
) )
} }
} else { } 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 // Try to send via a relay
for _, relayIP := range hostinfo.relayState.CopyRelayIps() { for _, relayIP := range hostinfo.relayState.CopyRelayIps() {
relayHostInfo, relay, err := f.hostMap.QueryVpnAddrsRelayFor(hostinfo.vpnAddrs, relayIP) relayHostInfo, relay, err := f.hostMap.QueryVpnAddrsRelayFor(hostinfo.vpnAddrs, relayIP)
+12 -9
View File
@@ -452,10 +452,12 @@ func (f *Interface) pinThisThread(i int) {
// txQueue is the per-routine TX state owned by one listenIn goroutine. // txQueue is the per-routine TX state owned by one listenIn goroutine.
// laneSlot is the lane this routine's traffic rides (laneSlotFor); lane is // laneSlot is the lane this routine's traffic rides (laneSlotFor); lane is
// bound to that slot's socket and carries lane-tunnel data; base is bound to // bound to that slot's socket and carries traffic encrypted with that lane's
// socket 0 and carries base-tunnel and relay data, which must keep the base // session; base is bound to socket 0 and carries base-session and relay data,
// source port (a vanilla peer would otherwise see per-routine source ports // which must keep the base source port (a vanilla peer would otherwise see
// and roam-thrash). The two alias when multiport is off or laneSlot is 0. // per-routine source ports and roam-thrash). A routine uses base whenever its
// lane is down, so both batches stay live for the life of the routine. The two
// alias when multiport is off or laneSlot is 0.
// Concurrent sendmmsg on a shared fd (socket 0, or a lane socket shared by // Concurrent sendmmsg on a shared fd (socket 0, or a lane socket shared by
// overflow routines) is safe: a flow is pinned to one routine by tun // overflow routines) is safe: a flow is pinned to one routine by tun
// steering, so per-flow wire order still holds. // steering, so per-flow wire order still holds.
@@ -473,8 +475,8 @@ func (tx *txQueue) full() bool {
} }
// flush drains base before lane so that when a flow moves from the base // 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 // session onto a freshly promoted lane mid-window, its packets still leave this
// this host in encryption order. // host in encryption order.
func (tx *txQueue) flush(f *Interface) { func (tx *txQueue) flush(f *Interface) {
if tx.base != tx.lane { if tx.base != tx.lane {
f.flushSendBatch(tx.base, 0) f.flushSendBatch(tx.base, 0)
@@ -485,9 +487,10 @@ func (tx *txQueue) flush(f *Interface) {
// laneSlotFor maps a routine index to the lane its traffic rides. When // laneSlotFor maps a routine index to the lane its traffic rides. When
// multiport.lanes is below routines, overflow routines share the configured // multiport.lanes is below routines, overflow routines share the configured
// lanes round-robin instead of all falling back onto the base tunnel's // lanes round-robin instead of all falling back onto the base tunnel's
// single underlay flow. Sharers use the lane's own socket and 4-tuple, so // single underlay flow. Sharers use the lane's own socket, 4-tuple and
// this is vanilla-style same-flow sharing: no cross-path replay skew, and // session, so this is vanilla-style same-flow sharing: no cross-path replay
// per-flow ordering still holds (a flow stays pinned to one routine). // skew, and per-flow ordering still holds (a flow stays pinned to one
// routine).
func (f *Interface) laneSlotFor(i int) int { func (f *Interface) laneSlotFor(i int) int {
if f.multiport && f.laneCount > 0 { if f.multiport && f.laneCount > 0 {
return i % f.laneCount return i % f.laneCount
+462
View File
@@ -0,0 +1,462 @@
package nebula
import (
"context"
"hash/fnv"
"log/slog"
"net/netip"
"sync"
"sync/atomic"
"time"
"github.com/slackhq/nebula/handshake"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/noiseutil"
)
// Multiport lanes give each data-plane routine its own underlay 5-tuple, so one
// tunnel's traffic spreads over ECMP paths, NIC receive queues and per-flow
// policers instead of funnelling through a single flow.
//
// A lane is not a second tunnel: it is an extra session on the same HostInfo.
// Noise leaves us with A.eKey == B.dKey, so both sides expand the same two keys
// with the same per-lane label and land on a matched pair without exchanging
// anything. A lane therefore costs no handshake, has no half-established state,
// and dies exactly when its base tunnel does. Which lane a packet belongs to
// travels in the nebula header, inside the AEAD's associated data.
//
// Lane 0 is the base tunnel itself: HostInfo.ConnectionState, socket 0, and the
// peer's real remote address. Lane s > 0 egresses writers[s] (bound to
// listen.port+s) toward the peer's advertised port range. Receiving on a lane
// needs no permission — the session exists as soon as the base handshake
// completes — but sending on one needs proof the new 5-tuple actually works,
// since nothing else would notice a middlebox quietly dropping it. So a lane
// stays down until a probe on it is acked, and falls back to the base tunnel the
// moment it stops being acked.
const (
// laneKeyInfo is the HKDF label prefix for lane key expansion. Changing it
// means older builds derive different keys and drop our lane traffic; the
// base tunnel would keep working, so the failure would be a silent loss of
// lanes rather than of connectivity.
laneKeyInfo = "nebula multiport lane v1"
// laneRetryBase and laneRetryMax bound the backoff between probes of a lane
// that will not come up, so a peer whose lane ports are firewalled costs one
// packet a minute rather than one per traffic tick.
laneRetryBase = 5 * time.Second
laneRetryMax = 60 * time.Second
// laneMaxFails caps the failure counter; the backoff saturates well before.
laneMaxFails = 8
// laneProbeTimeout is how long a probe may go unacked before it counts as a
// failure. It is shorter than the connection manager's check interval on
// purpose: an outstanding probe is judged on the next tick either way, and a
// longer timeout would only delay that by a whole tick.
laneProbeTimeout = 2 * time.Second
// laneKeepalive is how often a lane that is up re-proves its path. Traffic
// on a lane is not evidence the lane works — that is the whole reason lanes
// need probing — so a lane that silently breaks is only caught here.
laneKeepalive = 30 * time.Second
)
// laneSet holds a peer's lane sessions and the state deciding which lanes may
// carry traffic. It is built when the base handshake completes and never
// resized, so the slices and their lengths are immutable; mu guards the fields
// under it, and txAddr/demand are atomics read by the data plane without it.
type laneSet struct {
// sessions[s] is lane s's session. sessions[0] is nil: lane 0 is the base
// tunnel's own ConnectionState. Immutable, so the RX path reads it with no
// synchronization at all.
sessions []*ConnectionState
// txAddr[s] holds lane s's remote address while the lane is proven usable
// and nil otherwise. This single atomic is both the TX gate and the
// destination, so a routine that loads non-nil has everything it needs.
txAddr []atomic.Pointer[netip.AddrPort]
// demand[s] is raised by the TX path when a routine riding lane s has
// traffic for this peer and the lane is down. Probing is demand-driven: a
// peer we exchange a trickle with never costs more than its base tunnel, no
// matter how many lanes are configured.
demand []atomic.Bool
// txLanes is how many lanes we may send on — our lane count clamped to the
// ports the peer bound. Lanes from txLanes up exist only to receive, which
// is how a peer with more routines than us still spreads its own traffic.
// Immutable.
txLanes int
mu sync.Mutex
// peerPortCount and peerBasePort are the peer's advertised port range and
// portOffset is this pair's rotation within it: lane s targets
// peerBasePort + ((s + portOffset) % peerPortCount).
peerPortCount uint16
peerBasePort uint16
portOffset uint16
// peerAddr is the address the current lane targets were built from. The
// peer's lane ports have no derivable relationship to a new NAT mapping, so
// a roam invalidates every lane rather than moving it.
peerAddr netip.Addr
// probe[s] is lane s's probe and backoff state.
probe []laneProbeState
}
type laneProbeState struct {
// gen is the generation of the last probe sent, echoed in the ack so a late
// ack cannot promote a lane on the strength of a superseded probe.
gen uint8
// fails is the consecutive failure count driving retryAt.
fails uint8
// sentAt is when the outstanding probe went out, zero when none is pending.
sentAt time.Time
// target is where the outstanding probe went, promoted to txAddr on ack.
target netip.AddrPort
// lastAck is when the lane was last confirmed usable, driving the keepalive.
lastAck time.Time
// retryAt is the earliest we may probe this lane again.
retryAt time.Time
}
// newLaneSet derives the lane sessions for a freshly completed base handshake.
// It returns nil when the pair has no lane beyond the base tunnel, which is the
// normal answer for a peer running without multiport.
func newLaneSet(r *handshake.Result, myLanes int, myAddr, peerAddr netip.Addr) (*laneSet, error) {
// PeerPortCount and PeerBasePort are already bounded to uint16 by the
// handshake payload parser. A zero port count is a peer that did not
// advertise multiport at all, so there is no lane to be had in either
// direction.
peerPorts := uint16(r.PeerPortCount)
if peerPorts == 0 {
return nil, nil
}
// Sessions have to cover both directions: we send on our lanes and receive
// on the peer's, and one derived session serves both ends of a lane index.
n := min(max(myLanes, int(r.PeerTxLanes)), header.MaxLane+1)
if n < 2 {
return nil, nil
}
ls := &laneSet{
sessions: make([]*ConnectionState, n),
txAddr: make([]atomic.Pointer[netip.AddrPort], n),
demand: make([]atomic.Bool, n),
probe: make([]laneProbeState, n),
txLanes: min(myLanes, int(peerPorts), n),
peerPortCount: peerPorts,
peerBasePort: uint16(r.PeerBasePort),
portOffset: lanePortOffset(myAddr, peerAddr, peerPorts),
}
for s := 1; s < n; s++ {
cs, err := newLaneConnectionState(r, uint8(s))
if err != nil {
return nil, err
}
ls.sessions[s] = cs
}
return ls, nil
}
// lanePortOffset returns the rotation applied to this pair's lane target ports,
// in [0, peerPortCount). Without it every low-routine peer would aim its few
// lanes at a big peer's first few ports, concentrating the big peer's receive
// work on a couple of sockets; the hash spreads pairs across the whole range.
//
// Both sides hash the same sorted vpn-address pair and the higher address
// negates the result, so when port counts match the two sides' rotations
// cancel: our lane s's 4-tuple stays the reverse of the peer's lane s, and each
// side's probe opens the conntrack entry the other's arrives through. (The one
// lane a nonzero rotation lands on the peer's base port has no partner lane;
// behind a port-restricted NAT it may never come up, and its routine rides the
// base tunnel — the standard lane fallback.)
func lanePortOffset(myAddr, peerAddr netip.Addr, peerPortCount uint16) uint16 {
if peerPortCount == 0 {
return 0
}
lo, hi := myAddr, peerAddr
if hi.Less(lo) {
lo, hi = hi, lo
}
h := fnv.New32a()
b := lo.As16()
h.Write(b[:])
b = hi.As16()
h.Write(b[:])
o := uint16(h.Sum32() % uint32(peerPortCount))
if myAddr == hi {
o = (peerPortCount - o) % peerPortCount
}
return o
}
// laneSession returns lane s's session, or nil if this peer has no lane s.
func (i *HostInfo) laneSession(s uint8) *ConnectionState {
ls := i.lanes
if ls == nil || int(s) >= len(ls.sessions) {
return nil
}
return ls.sessions[s]
}
// maxMessageCounter returns the highest counter across the base session and
// every lane session. Data rides the lanes, so the base counter alone would
// never reach the rehandshake or exhaustion thresholds and the lane keys would
// be used past their data-volume margin. Rolling the base tunnel replaces the
// lane keys with it, since lanes are derived from it.
func (i *HostInfo) maxMessageCounter() uint64 {
if i.ConnectionState == nil {
return 0
}
c := i.ConnectionState.messageCounter.Load()
if ls := i.lanes; ls != nil {
for _, cs := range ls.sessions {
if cs == nil {
continue
}
if lc := cs.messageCounter.Load(); lc > c {
c = lc
}
}
}
return c
}
// txLane returns the session and destination for lane s, or a nil session when
// the lane is down and the caller must use the base tunnel. A miss raises
// demand, which is the only thing that gets a lane probed, so we pay for a lane
// exactly where real traffic wanted one.
func (ls *laneSet) txLane(s int) (*ConnectionState, netip.AddrPort) {
if ls == nil || s <= 0 || s >= ls.txLanes {
return nil, netip.AddrPort{}
}
if addr := ls.txAddr[s].Load(); addr != nil {
return ls.sessions[s], *addr
}
// Load-guarded so the common case of a lane that will not come up is a
// plain read and cannot ping-pong the cache line these flags share.
if !ls.demand[s].Load() {
ls.demand[s].Store(true)
}
return nil, netip.AddrPort{}
}
// laneTargetPortLocked returns the peer port lane s aims at. Only meaningful
// when peerPortCount is nonzero, which txLanes > 0 guarantees.
func (ls *laneSet) laneTargetPortLocked(s int) uint16 {
return ls.peerBasePort + uint16((s+int(ls.portOffset))%int(ls.peerPortCount))
}
// laneRetryDelay is the backoff after fails consecutive probe failures.
func laneRetryDelay(fails uint8) time.Duration {
d := laneRetryBase << min(fails, 4)
if d > laneRetryMax {
d = laneRetryMax
}
return d
}
// noteAck records an acked probe for lane s, promoting the lane if it was down.
// gen must match the outstanding probe. Reports whether the lane was promoted.
func (ls *laneSet) noteAck(s int, gen uint8, now time.Time) bool {
if s <= 0 || s >= len(ls.probe) {
return false
}
ls.mu.Lock()
defer ls.mu.Unlock()
p := &ls.probe[s]
if p.sentAt.IsZero() || p.gen != gen {
// No probe outstanding, or an ack for a probe we have already given up
// on. Either way it says nothing about the lane's current path.
return false
}
p.sentAt = time.Time{}
p.lastAck = now
p.fails = 0
p.retryAt = time.Time{}
if ls.txAddr[s].Load() != nil {
// Keepalive for a lane already up.
return false
}
target := p.target
ls.txAddr[s].Store(&target)
return true
}
// probeLanes runs one lane maintenance pass for a peer: it demotes lanes whose
// probe went unanswered, re-proves lanes that have been up a while without one,
// and probes down lanes the data plane asked for. Driven by the connection
// manager's per-tunnel traffic tick, which only fires for a live tunnel — the
// same condition that produces lane demand in the first place.
func (f *Interface) probeLanes(hostinfo *HostInfo, now time.Time, nb, out []byte) {
ls := hostinfo.lanes
if ls == nil || ls.txLanes < 2 {
return
}
remote := hostinfo.GetRemote()
ls.mu.Lock()
defer ls.mu.Unlock()
if !remote.IsValid() {
// Relayed, or otherwise without a direct path. Lanes are direct-only,
// so drop them all; a later tick rebuilds if a direct path returns.
ls.resetLocked()
return
}
if ls.peerAddr != remote.Addr() {
if ls.peerAddr.IsValid() {
// A roam is a new path, not a failure: forget the lanes built on
// the old one and let demand re-probe from a clean backoff. On the
// first pass there is nothing built yet, so just record the address.
ls.resetLocked()
}
ls.peerAddr = remote.Addr()
}
for s := 1; s < ls.txLanes; s++ {
p := &ls.probe[s]
up := ls.txAddr[s].Load() != nil
if !p.sentAt.IsZero() {
if now.Sub(p.sentAt) < laneProbeTimeout {
continue
}
// An aged-out probe is a failure whether it was bringing the lane
// up or keeping it up.
p.sentAt = time.Time{}
p.fails = min(p.fails+1, laneMaxFails)
p.retryAt = now.Add(laneRetryDelay(p.fails))
if up {
ls.txAddr[s].Store(nil)
hostinfo.logger(f.l).Info("Multiport lane demoted, probe unanswered", "lane", s)
}
continue
}
if up {
if now.Sub(p.lastAck) < laneKeepalive {
continue
}
} else if now.Before(p.retryAt) || !ls.demand[s].Swap(false) {
continue
}
p.gen++
p.target = netip.AddrPortFrom(remote.Addr(), ls.laneTargetPortLocked(s))
if f.sendLaneProbe(hostinfo, s, p.gen, p.target, nb, out) {
p.sentAt = now
} else {
p.fails = min(p.fails+1, laneMaxFails)
p.retryAt = now.Add(laneRetryDelay(p.fails))
}
}
}
// resetLocked takes every lane down and clears its probe state, without
// counting it as a failure.
//
// Demand is deliberately left standing: it records that a routine has real
// traffic for this peer, which a roam or a relay detour does not change. Keeping
// it re-probes the lanes that were actually carrying data as soon as a path
// exists again, while a lane whose routine has gone quiet stays down.
func (ls *laneSet) resetLocked() {
for s := 1; s < len(ls.probe); s++ {
ls.txAddr[s].Store(nil)
ls.probe[s] = laneProbeState{}
}
}
// sendLaneProbe sends a probe on lane s to addr from writers[s]. The probe is an
// ordinary Test packet encrypted with the lane's session, so an ack proves the
// whole lane: our source port reached the peer, its reply reached us, and the
// keys we derived for this lane match the ones it derived. Reports whether the
// probe made it onto the wire.
func (f *Interface) sendLaneProbe(hostinfo *HostInfo, s int, gen uint8, addr netip.AddrPort, nb, out []byte) bool {
ci := hostinfo.lanes.sessions[s]
if ci == nil || ci.eKey == nil {
return false
}
if noiseutil.EncryptLockNeeded {
ci.writeLock.Lock()
}
c, ok := ci.NextMessageCounter()
if !ok {
if noiseutil.EncryptLockNeeded {
ci.writeLock.Unlock()
}
f.dropExhausted(hostinfo, c, "Dropping multiport lane probe, lane message counter is exhausted")
return false
}
b := header.EncodeLane(out[:0], header.Version, header.Test, header.LaneProbe, hostinfo.remoteIndexId, c, uint8(s))
b, err := ci.eKey.EncryptDanger(b, b, []byte{uint8(s), gen}, c, nb)
if noiseutil.EncryptLockNeeded {
ci.writeLock.Unlock()
}
if err != nil {
hostinfo.logger(f.l).Error("Failed to encrypt multiport lane probe", "error", err, "lane", s)
return false
}
f.messageMetrics.Tx(header.Test, header.LaneProbe, 1)
if err := f.writers[s].WriteTo(b, addr); err != nil {
hostinfo.logger(f.l).Error("Failed to send multiport lane probe", "error", err, "lane", s, "udpAddr", addr)
return false
}
if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("Multiport lane probe sent", "lane", s, "gen", gen, "udpAddr", addr)
}
return true
}
// handleLaneProbe answers a peer's lane probe. The ack rides the base tunnel on
// purpose: a probe proves the peer's lane s works in its send direction, and
// answering on our own lane s would make the result depend on a second path
// that may be broken independently.
func (f *Interface) handleLaneProbe(hostinfo *HostInfo, lane uint8, payload []byte, rxc *rxContext) {
if lane == 0 || len(payload) < 2 {
return
}
// Echo the header's lane rather than the payload's, so a peer cannot get us
// to vouch for a lane it did not actually probe.
f.send(header.Test, header.LaneProbeAck, hostinfo.ConnectionState, hostinfo,
[]byte{lane, payload[1]}, rxc.nb, rxc.scratch[:0])
}
// handleLaneProbeAck promotes the lane a peer just acked.
func (f *Interface) handleLaneProbeAck(hostinfo *HostInfo, payload []byte) {
ls := hostinfo.lanes
if ls == nil || len(payload) < 2 {
return
}
if ls.noteAck(int(payload[0]), payload[1], time.Now()) {
hostinfo.logger(f.l).Info("Multiport lane up", "lane", payload[0])
}
}
+402 -379
View File
@@ -8,6 +8,7 @@ import (
"github.com/gaissmai/bart" "github.com/gaissmai/bart"
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/handshake"
"github.com/slackhq/nebula/header" "github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/overlay/batch" "github.com/slackhq/nebula/overlay/batch"
"github.com/slackhq/nebula/overlay/overlaytest" "github.com/slackhq/nebula/overlay/overlaytest"
@@ -18,33 +19,38 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func newTestBaseHostInfo(vpnIp netip.Addr, localIdx, remoteIdx uint32, laneCount int) *HostInfo { var (
base := &HostInfo{ testMyAddr = netip.MustParseAddr("10.0.0.1")
vpnAddrs: []netip.Addr{vpnIp}, testPeerAddr = netip.MustParseAddr("10.0.0.2")
localIndexId: localIdx, )
remoteIndexId: remoteIdx,
remotes: NewRemoteList([]netip.Addr{vpnIp}, nil), // newTestLaneSet derives a lane set from a real handshake result so the sessions
HandshakePacket: map[uint8][]byte{}, // hold usable keys.
} func newTestLaneSet(t *testing.T, r *handshake.Result, myLanes int, peerPorts, peerBase, peerTxLanes uint32) *laneSet {
base.SetRemote(netip.MustParseAddrPort("192.0.2.1:4242")) t.Helper()
base.lanes = newLaneState(laneCount, uint16(laneCount), 4242, 0) r.PeerPortCount = peerPorts
return base r.PeerBasePort = peerBase
r.PeerTxLanes = peerTxLanes
ls, err := newLaneSet(r, myLanes, testMyAddr, testPeerAddr)
require.NoError(t, err)
return ls
} }
func newTestLaneHostInfo(base *HostInfo, laneIndex uint16, localIdx, remoteIdx uint32, owned bool) *HostInfo { func newTestLaneHostInfo(t *testing.T, r *handshake.Result, ls *laneSet) *HostInfo {
lane := &HostInfo{ t.Helper()
vpnAddrs: base.vpnAddrs, cs, err := newConnectionStateFromResult(r)
localIndexId: localIdx, require.NoError(t, err)
remoteIndexId: remoteIdx, hi := &HostInfo{
remotes: NewRemoteList(base.vpnAddrs, nil), vpnAddrs: []netip.Addr{testPeerAddr},
localIndexId: 100,
remoteIndexId: 200,
remotes: NewRemoteList([]netip.Addr{testPeerAddr}, nil),
HandshakePacket: map[uint8][]byte{}, HandshakePacket: map[uint8][]byte{},
sockIdx: int(laneIndex), ConnectionState: cs,
laneIndex: laneIndex, lanes: ls,
laneOwned: owned,
parent: base,
} }
lane.SetRemote(netip.MustParseAddrPort("192.0.2.1:4243")) hi.SetRemote(netip.MustParseAddrPort("192.0.2.1:4242"))
return lane return hi
} }
func TestLanePortOffset(t *testing.T) { func TestLanePortOffset(t *testing.T) {
@@ -83,132 +89,149 @@ func TestLanePortOffset(t *testing.T) {
} }
func TestLaneTargetPort(t *testing.T) { func TestLaneTargetPort(t *testing.T) {
ls := &laneSet{peerBasePort: 4242, peerPortCount: 4}
// No rotation: lane i targets base+i, wrapping past the peer's range. // No rotation: lane i targets base+i, wrapping past the peer's range.
ls := newLaneState(4, 4, 4242, 0)
for i, want := range map[int]uint16{1: 4243, 2: 4244, 3: 4245, 5: 4243} { for i, want := range map[int]uint16{1: 4243, 2: 4244, 3: 4245, 5: 4243} {
assert.Equal(t, want, ls.laneTargetPort(i), "lane %d", i) assert.Equal(t, want, ls.laneTargetPortLocked(i), "lane %d", i)
} }
// Rotation shifts the whole mapping; the wrapped lane lands on the base // Rotation shifts the whole mapping; the wrapped lane lands on the base
// port itself, which is a valid distinct 4-tuple (our source port differs). // port itself, which is a valid distinct 4-tuple (our source port differs).
ls = newLaneState(4, 4, 4242, 3) ls.portOffset = 3
for i, want := range map[int]uint16{1: 4242, 2: 4243, 3: 4244} { for i, want := range map[int]uint16{1: 4242, 2: 4243, 3: 4244} {
assert.Equal(t, want, ls.laneTargetPort(i), "rotated lane %d", i) assert.Equal(t, want, ls.laneTargetPortLocked(i), "rotated lane %d", i)
} }
// Fewer peer ports than local lanes: rotation still spreads across all of // Fewer peer ports than local lanes: rotation still spreads across all of
// the peer's ports. // the peer's ports.
ls = newLaneState(16, 2, 4242, 1) ls = &laneSet{peerBasePort: 4242, peerPortCount: 2, portOffset: 1}
assert.Equal(t, uint16(4242), ls.laneTargetPort(1)) assert.Equal(t, uint16(4242), ls.laneTargetPortLocked(1))
assert.Equal(t, uint16(4243), ls.laneTargetPort(2)) assert.Equal(t, uint16(4243), ls.laneTargetPortLocked(2))
} }
func TestLaneHostmapLifecycle(t *testing.T) { // A lane's keys are derived, not negotiated, so the whole design rests on the
l := test.NewLogger() // two sides landing on the same pair without exchanging anything.
hostMap := newHostMap(l) func TestLaneKeyDerivationSymmetry(t *testing.T) {
ifce := &Interface{l: l} // connectionManager nil is tolerated by unlockedAddLane initR, respR := runTestHandshake(t)
vpnIp := netip.MustParseAddr("172.1.1.2") initLS := newTestLaneSet(t, initR, 4, 4, 4242, 4)
base := newTestBaseHostInfo(vpnIp, 100, 200, 4) respLS := newTestLaneSet(t, respR, 4, 4, 4242, 4)
require.Len(t, initLS.sessions, 4)
assert.Nil(t, initLS.sessions[0], "lane 0 is the base session, not a derived one")
hostMap.Lock() nb := make([]byte, 12)
hostMap.unlockedAddHostInfo(base, ifce) for s := 1; s < 4; s++ {
hostMap.Unlock() out := header.EncodeLane(make([]byte, 0, mtu), header.Version, header.Message, 0, 200, 1, uint8(s))
ct, err := initLS.sessions[s].eKey.EncryptDanger(out, out, []byte("lane payload"), 1, nb)
require.NoError(t, err)
lane := newTestLaneHostInfo(base, 1, 101, 201, true) pt, err := respLS.sessions[s].Decrypt(test.NewLogger(), 1, ct, nb)
hostMap.Lock() require.NoError(t, err, "lane %d keys did not match", s)
hostMap.unlockedAddLane(lane, ifce) assert.Equal(t, []byte("lane payload"), pt)
hostMap.Unlock() }
base.lanes.txLanes[1].Store(lane)
// The lane is reachable by index (RX demux, recv_error) but never a Hosts primary. // Distinct lanes get distinct keys: lane 2's session must not open lane 1's
assert.Equal(t, lane, hostMap.QueryIndex(101)) // ciphertext, or the header's lane index would be forgeable in effect.
assert.Equal(t, lane, hostMap.QueryReverseIndex(201)) out := header.EncodeLane(make([]byte, 0, mtu), header.Version, header.Message, 0, 200, 7, 1)
assert.Equal(t, base, hostMap.Hosts[vpnIp]) ct, err := initLS.sessions[1].eKey.EncryptDanger(out, out, []byte("lane payload"), 7, nb)
require.NoError(t, err)
// A lane can never be promoted to primary. _, err = respLS.sessions[2].Decrypt(test.NewLogger(), 7, ct, nb)
hostMap.Lock() assert.Error(t, err)
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) { func TestNewLaneSetSizing(t *testing.T) {
l := test.NewLogger() initR, _ := runTestHandshake(t)
hostMap := newHostMap(l)
ifce := &Interface{l: l}
vpnIp := netip.MustParseAddr("172.1.1.3") // A peer with no multiport advert gets no lanes at all.
base := newTestBaseHostInfo(vpnIp, 300, 400, 4) ls, err := newLaneSet(&handshake.Result{}, 4, testMyAddr, testPeerAddr)
require.NoError(t, err)
assert.Nil(t, ls)
hostMap.Lock() // One lane means only the base tunnel, which is not a lane set.
hostMap.unlockedAddHostInfo(base, ifce) ls, err = newLaneSet(&handshake.Result{PeerPortCount: 4, PeerTxLanes: 1}, 1, testMyAddr, testPeerAddr)
hostMap.Unlock() require.NoError(t, err)
assert.Nil(t, ls)
owned := newTestLaneHostInfo(base, 1, 301, 401, true) // Sessions cover both directions: enough for everything the peer may send,
peer := newTestLaneHostInfo(base, 2, 302, 402, false) // even though we may only send on a few.
hostMap.Lock() ls = newTestLaneSet(t, initR, 2, 8, 4242, 6)
hostMap.unlockedAddLane(owned, ifce) assert.Len(t, ls.sessions, 6, "sessions must cover the peer's tx lanes")
hostMap.unlockedAddLane(peer, ifce) assert.Equal(t, 2, ls.txLanes, "we may only send on our own lanes")
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. // Our tx lanes are clamped to the ports the peer actually bound: a lane
final := hostMap.DeleteHostInfo(base) // aimed past the peer's range would land on some unrelated socket.
assert.True(t, final) ls = newTestLaneSet(t, initR, 8, 3, 4242, 8)
assert.Nil(t, hostMap.QueryIndex(300)) assert.Equal(t, 3, ls.txLanes)
assert.Nil(t, hostMap.QueryIndex(301)) assert.Len(t, ls.sessions, 8)
assert.Nil(t, hostMap.QueryIndex(302))
assert.Nil(t, hostMap.Hosts[vpnIp])
} }
// Regression: deleting a hostinfo whose pending entry is NOT the one recorded func TestLaneTxGate(t *testing.T) {
// in vpnIps (e.g. a lane, whose vpnAddrs alias the base's) must not evict a initR, _ := runTestHandshake(t)
// concurrently pending base handshake for the same address. ls := newTestLaneSet(t, initR, 4, 4, 4242, 4)
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") // A down lane hands back nothing and raises demand, which is what gets it
pendingBase := hm.StartHandshake(vpnIp, nil) // probed.
require.NotNil(t, pendingBase) ci, addr := ls.txLane(1)
assert.Nil(t, ci)
assert.False(t, addr.IsValid())
assert.True(t, ls.demand[1].Load())
assert.False(t, ls.demand[2].Load(), "demand raised on an untouched lane")
other := &HostInfo{vpnAddrs: []netip.Addr{vpnIp}, localIndexId: 999} // Promotion publishes the session and destination together.
hm.DeleteHostInfo(other) want := netip.MustParseAddrPort("192.0.2.1:4243")
ls.txAddr[1].Store(&want)
ls.demand[1].Store(false)
ci, addr = ls.txLane(1)
assert.Equal(t, ls.sessions[1], ci)
assert.Equal(t, want, addr)
assert.False(t, ls.demand[1].Load(), "a hit must not raise demand")
// The pending base handshake must still be tracked. // Lane 0 is the base tunnel and lanes at or above txLanes are receive-only.
assert.Equal(t, pendingBase, hm.QueryVpnAddr(vpnIp)) ci, _ = ls.txLane(0)
assert.Nil(t, ci)
// And deleting the actual owner still works. ci, _ = ls.txLane(4)
hm.DeleteHostInfo(pendingBase) assert.Nil(t, ci)
assert.Nil(t, hm.QueryVpnAddr(vpnIp))
} }
func newLaneTestConnectionManager(hostMap *HostMap) (*connectionManager, *Interface) { func TestLaneSessionLookup(t *testing.T) {
initR, _ := runTestHandshake(t)
ls := newTestLaneSet(t, initR, 3, 4, 4242, 3)
hi := newTestLaneHostInfo(t, initR, ls)
assert.Nil(t, hi.laneSession(0), "lane 0 is the base session")
assert.Equal(t, ls.sessions[2], hi.laneSession(2))
assert.Nil(t, hi.laneSession(3), "a lane we never derived")
assert.Nil(t, hi.laneSession(255))
// A peer without lanes answers nil for every lane rather than panicking.
bare := &HostInfo{}
assert.Nil(t, bare.laneSession(1))
}
func TestMaxMessageCounter(t *testing.T) {
initR, _ := runTestHandshake(t)
ls := newTestLaneSet(t, initR, 3, 4, 4242, 3)
hi := newTestLaneHostInfo(t, initR, ls)
hi.ConnectionState.messageCounter.Store(5)
assert.Equal(t, uint64(5), hi.maxMessageCounter())
// A lane past the base is what the rehandshake threshold has to notice: the
// base counter would sit still while the lane burns through its nonces.
ls.sessions[2].messageCounter.Store(9000)
assert.Equal(t, uint64(9000), hi.maxMessageCounter())
assert.Equal(t, uint64(0), (&HostInfo{}).maxMessageCounter())
}
func TestLaneRetryDelay(t *testing.T) {
assert.Equal(t, laneRetryBase, laneRetryDelay(0))
assert.Equal(t, 2*laneRetryBase, laneRetryDelay(1))
assert.Equal(t, laneRetryMax, laneRetryDelay(laneMaxFails), "backoff must saturate")
}
func newLaneTestInterface(hostMap *HostMap) *Interface {
l := test.NewLogger() l := test.NewLogger()
lh := newTestLighthouse() lh := newTestLighthouse()
cs := &CertState{ cs := &CertState{
@@ -226,86 +249,225 @@ func newLaneTestConnectionManager(hostMap *HostMap) (*connectionManager, *Interf
pki: &PKI{}, pki: &PKI{},
handshakeManager: NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig), handshakeManager: NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig),
myVpnNetworksTable: new(bart.Lite), myVpnNetworksTable: new(bart.Lite),
messageMetrics: newMessageMetricsOnlyRecvError(),
writers: []udp.Conn{&udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}},
l: l, l: l,
} }
ifce.pki.cs.Store(cs) ifce.pki.cs.Store(cs)
conf := config.NewC(test.NewLogger()) conf := config.NewC(l)
punchy := NewPunchyFromConfig(test.NewLogger(), conf, nil) punchy := NewPunchyFromConfig(l, conf, nil)
cm := newConnectionManagerFromConfig(test.NewLogger(), conf, hostMap, punchy) cm := newConnectionManagerFromConfig(l, conf, hostMap, punchy)
cm.intf = ifce cm.intf = ifce
ifce.connectionManager = cm ifce.connectionManager = cm
ifce.handshakeManager.f = ifce ifce.handshakeManager.f = ifce
return cm, ifce return ifce
} }
func TestLaneTrafficDecision(t *testing.T) { // The full TX lifecycle of a lane: demand -> probe -> ack -> up, then an
// unanswered keepalive -> demoted.
func TestLaneProbeLifecycle(t *testing.T) {
hostMap := newHostMap(test.NewLogger()) hostMap := newHostMap(test.NewLogger())
cm, ifce := newLaneTestConnectionManager(hostMap) ifce := newLaneTestInterface(hostMap)
vpnIp := netip.MustParseAddr("172.1.1.5") initR, _ := runTestHandshake(t)
base := newTestBaseHostInfo(vpnIp, 500, 600, 4) ls := newTestLaneSet(t, initR, 4, 4, 5353, 4)
base.ConnectionState = &ConnectionState{} hi := newTestLaneHostInfo(t, initR, ls)
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)
nb := make([]byte, 12)
out := make([]byte, mtu)
now := time.Now() now := time.Now()
// A lane with inbound traffic is alive and never swaps primary or // No demand: nothing is probed, so a peer we barely talk to costs nothing
// migrates relays. // beyond its base tunnel.
lane.markIn() ifce.probeLanes(hi, now, nb, out)
decision, resolved, _ := cm.makeTrafficDecision(lane.localIndexId, now) ls.mu.Lock()
assert.Equal(t, doNothing, decision) for s := 1; s < 4; s++ {
assert.Equal(t, lane, resolved) assert.True(t, ls.probe[s].sentAt.IsZero(), "lane %d probed without demand", s)
assert.False(t, lane.isPendingDeletion()) }
ls.mu.Unlock()
// An idle lane gets an active keepalive test... // Demand on lane 1 alone probes lane 1 alone, aimed at the peer's port for
decision, _, _ = cm.makeTrafficDecision(lane.localIndexId, now) // that lane.
assert.Equal(t, sendTestPacket, decision) ls.demand[1].Store(true)
assert.True(t, lane.isPendingDeletion()) ifce.probeLanes(hi, now, nb, out)
ls.mu.Lock()
require.False(t, ls.probe[1].sentAt.IsZero(), "demand did not produce a probe")
assert.True(t, ls.probe[2].sentAt.IsZero())
wantPort := ls.laneTargetPortLocked(1)
assert.Equal(t, netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), wantPort), ls.probe[1].target)
gen := ls.probe[1].gen
assert.False(t, ls.demand[1].Load(), "the probe did not consume the demand")
ls.mu.Unlock()
// ...and is declared dead when the test goes unanswered. // The lane stays down until the ack lands, and a stale generation cannot
decision, _, _ = cm.makeTrafficDecision(lane.localIndexId, now) // bring it up.
assert.Equal(t, deleteTunnel, decision) assert.Nil(t, ls.txAddr[1].Load())
assert.False(t, ls.noteAck(1, gen+1, now), "an ack for a superseded probe promoted the lane")
assert.Nil(t, ls.txAddr[1].Load())
// The matching ack promotes it, and the destination is the probed target.
assert.True(t, ls.noteAck(1, gen, now))
addr := ls.txAddr[1].Load()
require.NotNil(t, addr)
assert.Equal(t, netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), wantPort), *addr)
// A second ack is a keepalive, not a promotion.
ls.mu.Lock()
ls.probe[1].sentAt = now
ls.mu.Unlock()
assert.False(t, ls.noteAck(1, gen, now))
assert.NotNil(t, ls.txAddr[1].Load())
// An up lane is left alone until the keepalive comes due.
ifce.probeLanes(hi, now, nb, out)
ls.mu.Lock()
assert.True(t, ls.probe[1].sentAt.IsZero(), "an up lane was re-probed early")
ls.mu.Unlock()
// Past the keepalive it re-proves its path...
now = now.Add(laneKeepalive + time.Second)
ifce.probeLanes(hi, now, nb, out)
ls.mu.Lock()
require.False(t, ls.probe[1].sentAt.IsZero(), "keepalive did not probe")
ls.mu.Unlock()
assert.NotNil(t, ls.txAddr[1].Load(), "lane demoted before its probe aged out")
// ...and an unanswered keepalive demotes it with backoff, so the routine
// falls back to the base tunnel.
now = now.Add(laneProbeTimeout + time.Second)
ifce.probeLanes(hi, now, nb, out)
assert.Nil(t, ls.txAddr[1].Load(), "unanswered keepalive did not demote the lane")
ls.mu.Lock()
assert.Equal(t, uint8(1), ls.probe[1].fails)
assert.True(t, ls.probe[1].retryAt.After(now))
ls.mu.Unlock()
// The backoff holds even with fresh demand.
ls.demand[1].Store(true)
ifce.probeLanes(hi, now, nb, out)
ls.mu.Lock()
assert.True(t, ls.probe[1].sentAt.IsZero(), "backoff was ignored")
ls.mu.Unlock()
} }
func TestBaseInactiveConsidersLanes(t *testing.T) { // A roam is a new path with no derivable relationship to the old lane ports, so
// every lane has to be rebuilt rather than moved.
func TestLaneProbeRoamResets(t *testing.T) {
hostMap := newHostMap(test.NewLogger()) hostMap := newHostMap(test.NewLogger())
cm, _ := newLaneTestConnectionManager(hostMap) ifce := newLaneTestInterface(hostMap)
cm.dropInactive.Store(true)
cm.inactivityTimeout.Store(int64(10 * time.Minute)) initR, _ := runTestHandshake(t)
ls := newTestLaneSet(t, initR, 4, 4, 5353, 4)
hi := newTestLaneHostInfo(t, initR, ls)
now := time.Now() now := time.Now()
vpnIp := netip.MustParseAddr("172.1.1.6") nb := make([]byte, 12)
base := newTestBaseHostInfo(vpnIp, 700, 800, 4) out := make([]byte, mtu)
base.lastUsed = now.Add(-time.Hour)
// Base alone: inactive. ls.demand[1].Store(true)
_, inactive := cm.isInactive(base, now) ifce.probeLanes(hi, now, nb, out)
assert.True(t, inactive) ls.mu.Lock()
gen := ls.probe[1].gen
ls.mu.Unlock()
require.True(t, ls.noteAck(1, gen, now))
require.NotNil(t, ls.txAddr[1].Load())
// A recently used lane keeps the base alive. // New remote address: the lane is taken down, not retargeted.
lane := newTestLaneHostInfo(base, 1, 701, 801, true) hi.SetRemote(netip.MustParseAddrPort("198.51.100.7:4242"))
lane.lastUsed = now.Add(-time.Minute) ifce.probeLanes(hi, now, nb, out)
base.lanes.txLanes[1].Store(lane) assert.Nil(t, ls.txAddr[1].Load(), "lane survived a roam")
_, inactive = cm.isInactive(base, now) ls.mu.Lock()
assert.False(t, inactive) assert.Zero(t, ls.probe[1].fails, "a roam is not a lane failure")
assert.Equal(t, netip.MustParseAddr("198.51.100.7"), ls.peerAddr)
ls.mu.Unlock()
// Peer-owned lanes count too. // Relayed (no direct remote) means no lanes at all.
base.lanes.txLanes[1].Store(nil) ls.demand[1].Store(true)
base.lanes.Lock() ifce.probeLanes(hi, now, nb, out)
base.lanes.peerLanes = append(base.lanes.peerLanes, lane) ls.mu.Lock()
base.lanes.Unlock() gen = ls.probe[1].gen
_, inactive = cm.isInactive(base, now) ls.mu.Unlock()
assert.False(t, inactive) require.True(t, ls.noteAck(1, gen, now))
hi.SetRemote(netip.AddrPort{})
ifce.probeLanes(hi, now, nb, out)
assert.Nil(t, ls.txAddr[1].Load(), "lane survived losing the direct path")
}
// A lane probe is answered on the base tunnel, echoing the header's lane, so a
// peer cannot get us to vouch for a lane it never probed.
func TestHandleLaneProbe(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
ifce := newLaneTestInterface(hostMap)
initR, respR := runTestHandshake(t)
ls := newTestLaneSet(t, respR, 4, 4, 4242, 4)
hi := newTestLaneHostInfo(t, respR, ls)
rxc := &rxContext{nb: make([]byte, 12), scratch: make([]byte, mtu)}
sent := &recordingUdpConn{}
ifce.writers = []udp.Conn{sent, &udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}}
// The payload's lane is ignored in favour of the header's.
ifce.handleLaneProbe(hi, 2, []byte{3, 42}, rxc)
require.Len(t, sent.bufs, 1, "the ack must ride the base tunnel's socket")
h := &header.H{}
require.NoError(t, h.Parse(sent.bufs[0]))
assert.Equal(t, header.Test, h.Type)
assert.Equal(t, header.LaneProbeAck, h.Subtype)
assert.Equal(t, uint8(0), h.Lane(), "the ack is base-tunnel traffic")
pt, err := newTestLaneSet(t, initR, 4, 4, 4242, 4).sessions[1].dKey.DecryptDanger(
nil, sent.bufs[0][:header.Len], sent.bufs[0][header.Len:], h.MessageCounter, make([]byte, 12))
_ = pt
assert.Error(t, err, "the ack must not be readable with a lane key")
// A probe claiming lane 0 or with a truncated payload is answered with
// nothing at all.
sent.bufs = nil
ifce.handleLaneProbe(hi, 0, []byte{1, 2}, rxc)
ifce.handleLaneProbe(hi, 1, []byte{1}, rxc)
assert.Empty(t, sent.bufs)
}
func TestHandleLaneProbeAck(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
ifce := newLaneTestInterface(hostMap)
initR, _ := runTestHandshake(t)
ls := newTestLaneSet(t, initR, 4, 4, 5353, 4)
hi := newTestLaneHostInfo(t, initR, ls)
now := time.Now()
ls.demand[1].Store(true)
ifce.probeLanes(hi, now, make([]byte, 12), make([]byte, mtu))
ls.mu.Lock()
gen := ls.probe[1].gen
ls.mu.Unlock()
// A short or out-of-range ack is ignored, and a peer without lanes does not
// panic the handler.
ifce.handleLaneProbeAck(hi, []byte{1})
ifce.handleLaneProbeAck(hi, []byte{99, gen})
ifce.handleLaneProbeAck(&HostInfo{}, []byte{1, gen})
assert.Nil(t, ls.txAddr[1].Load())
ifce.handleLaneProbeAck(hi, []byte{1, gen})
assert.NotNil(t, ls.txAddr[1].Load())
}
// recordingUdpConn records what was written to it, one datagram per write.
type recordingUdpConn struct {
udp.NoopConn
bufs [][]byte
dsts []netip.AddrPort
}
func (c *recordingUdpConn) WriteTo(b []byte, addr netip.AddrPort) error {
c.bufs = append(c.bufs, append([]byte(nil), b...))
c.dsts = append(c.dsts, addr)
return nil
} }
// recordingBatchWriter satisfies batch's writer interface and records what // recordingBatchWriter satisfies batch's writer interface and records what
@@ -325,21 +487,12 @@ func (w *recordingBatchWriter) WriteBatch(bufs [][]byte, addrs []netip.AddrPort)
func TestSendInsideMessageLaneSwap(t *testing.T) { func TestSendInsideMessageLaneSwap(t *testing.T) {
hostMap := newHostMap(test.NewLogger()) hostMap := newHostMap(test.NewLogger())
cm, ifce := newLaneTestConnectionManager(hostMap) ifce := newLaneTestInterface(hostMap)
_ = cm
vpnIp := netip.MustParseAddr("172.1.1.7") initR, respR := runTestHandshake(t)
base := newTestBaseHostInfo(vpnIp, 900, 1000, 4) ls := newTestLaneSet(t, initR, 4, 4, 5353, 4)
lane := newTestLaneHostInfo(base, 1, 901, 1001, true) hi := newTestLaneHostInfo(t, initR, ls)
peerLS := newTestLaneSet(t, respR, 4, 4, 4242, 4)
// 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{} baseWriter := &recordingBatchWriter{}
laneWriter := &recordingBatchWriter{} laneWriter := &recordingBatchWriter{}
@@ -356,41 +509,60 @@ func TestSendInsideMessageLaneSwap(t *testing.T) {
pkt := tio.Packet{Bytes: []byte{0x45, 0, 0, 4, 1, 2, 3, 4}} pkt := tio.Packet{Bytes: []byte{0x45, 0, 0, 4, 1, 2, 3, 4}}
nb := make([]byte, 12) nb := make([]byte, 12)
// With the lane published, slot-1 traffic uses the lane session and the // A down lane rides the base tunnel and asks for a probe.
// lane batch. ifce.sendInsideMessage(hi, pkt, nb, tx1)
base.lanes.txLanes[1].Store(lane)
ifce.sendInsideMessage(base, pkt, nb, tx1)
tx1.flush(ifce) tx1.flush(ifce)
require.Len(t, laneWriter.bufs, 1) require.Len(t, baseWriter.bufs, 1)
require.Empty(t, baseWriter.bufs) assert.Empty(t, laneWriter.bufs)
assert.Equal(t, lane.GetRemote(), laneWriter.dsts[0]) assert.True(t, ls.demand[1].Load(), "a miss did not raise demand")
assert.False(t, ls.demand[2].Load(), "demand raised on an untouched lane")
h := &header.H{} h := &header.H{}
require.NoError(t, h.Parse(laneWriter.bufs[0])) require.NoError(t, h.Parse(baseWriter.bufs[0]))
assert.Equal(t, lane.remoteIndexId, h.RemoteIndex) assert.Equal(t, uint8(0), h.Lane())
// An overflow routine sharing slot 1 (multiport.lanes < routines) rides // Once the lane is up, slot-1 traffic rides the lane session, the lane
// the same lane session. // socket and the lane's destination, tagged with the lane index.
laneRemote := netip.MustParseAddrPort("192.0.2.1:5354")
ls.txAddr[1].Store(&laneRemote)
ls.demand[1].Store(false)
ifce.sendInsideMessage(hi, pkt, nb, tx1)
tx1.flush(ifce)
require.Len(t, laneWriter.bufs, 1)
assert.Equal(t, laneRemote, laneWriter.dsts[0])
assert.False(t, ls.demand[1].Load(), "a hit raised demand")
require.NoError(t, h.Parse(laneWriter.bufs[0]))
assert.Equal(t, uint8(1), h.Lane())
assert.Equal(t, hi.remoteIndexId, h.RemoteIndex)
// And the peer's derived lane-1 session is what opens it.
pt, err := peerLS.sessions[1].Decrypt(test.NewLogger(), h.MessageCounter, laneWriter.bufs[0], nb)
require.NoError(t, err)
assert.Equal(t, pkt.Bytes, pt)
// An overflow routine sharing slot 1 (multiport.lanes < routines) rides the
// same lane session.
tx1b := newTx(1) tx1b := newTx(1)
ifce.sendInsideMessage(base, pkt, nb, tx1b) ifce.sendInsideMessage(hi, pkt, nb, tx1b)
tx1b.flush(ifce) tx1b.flush(ifce)
require.Len(t, laneWriter.bufs, 2) require.Len(t, laneWriter.bufs, 2)
require.NoError(t, h.Parse(laneWriter.bufs[1])) require.NoError(t, h.Parse(laneWriter.bufs[1]))
assert.Equal(t, lane.remoteIndexId, h.RemoteIndex) assert.Equal(t, uint8(1), h.Lane())
// Slot 2 has no lane: base tunnel, base batch. // Slot 2's lane is still down: base tunnel, base batch, lane 0.
ifce.sendInsideMessage(base, pkt, nb, tx2) ifce.sendInsideMessage(hi, pkt, nb, tx2)
tx2.flush(ifce) tx2.flush(ifce)
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, tx1)
tx1.flush(ifce)
require.Len(t, baseWriter.bufs, 2) require.Len(t, baseWriter.bufs, 2)
assert.Equal(t, hi.GetRemote(), baseWriter.dsts[1])
require.NoError(t, h.Parse(baseWriter.bufs[1]))
assert.Equal(t, uint8(0), h.Lane())
// Demotion falls back to the base tunnel on the very next packet.
ls.txAddr[1].Store(nil)
ifce.sendInsideMessage(hi, pkt, nb, tx1)
tx1.flush(ifce)
require.Len(t, baseWriter.bufs, 3)
require.Len(t, laneWriter.bufs, 2) require.Len(t, laneWriter.bufs, 2)
} }
@@ -414,174 +586,25 @@ func TestLaneSlotFor(t *testing.T) {
} }
} }
func TestCompleteLaneResponder(t *testing.T) { // Regression: deleting a hostinfo whose pending entry is NOT the one recorded
hostMap := newHostMap(test.NewLogger()) // in vpnIps must not evict a concurrently pending handshake for that address.
_, ifce := newLaneTestConnectionManager(hostMap) func TestHandshakeManagerVpnIpsIdentityDelete(t *testing.T) {
ifce.writers = []udp.Conn{&udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}} l := test.NewLogger()
ifce.messageMetrics = newMessageMetricsOnlyRecvError() hostMap := newHostMap(l)
lh := newTestLighthouse()
hm := NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig)
hm := ifce.handshakeManager vpnIp := netip.MustParseAddr("172.1.1.4")
hm.config.laneCount = 4 pendingBase := hm.StartHandshake(vpnIp, nil)
hm.config.lanePortCount = 4 require.NotNil(t, pendingBase)
hm.config.laneBasePort = 4242
// A real handshake supplies usable keys and a peer cert. other := &HostInfo{vpnAddrs: []netip.Addr{vpnIp}, localIndexId: 999}
_, respR := runTestHandshake(t) hm.DeleteHostInfo(other)
respR.PeerLaneIndex = 2
respR.PeerPortCount = 4
respR.PeerBasePort = 5353
via := ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.9:5355"), SockIdx: 2} // The pending base handshake must still be tracked.
packet := make([]byte, header.Len+8) assert.Equal(t, pendingBase, hm.QueryVpnAddr(vpnIp))
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. // And deleting the actual owner still works.
hm.completeLaneResponder(via, packet, []byte("resp"), respR, vpnAddrs) hm.DeleteHostInfo(pendingBase)
assert.Nil(t, hostMap.QueryIndex(respR.LocalIndex)) assert.Nil(t, hm.QueryVpnAddr(vpnIp))
// 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)
for i := 1; i < 4; i++ {
base.lanes.noteLaneDemand(i)
}
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)
}
}
// Lanes are demand-driven: a base tunnel with no data-plane interest in a slot
// must not start a handshake for it, and one demand must not turn into an
// unbounded retry loop.
func TestEnsureLanesLazy(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
_, ifce := newLaneTestConnectionManager(hostMap)
hm := ifce.handshakeManager
hm.config.laneCount = 4
hm.config.lanePortCount = 4
hm.config.laneBasePort = 4242
base := newTestBaseHostInfo(netip.MustParseAddr("172.1.1.9"), 1300, 1400, 4)
ls := base.lanes
// No demand: nothing is attempted, so nothing fails or backs off either.
hm.EnsureLanes(base)
ls.Lock()
for i := 1; i < 4; i++ {
assert.False(t, ls.txPending[i], "slot %d claimed without demand", i)
assert.Zero(t, ls.txFails[i], "slot %d attempted without demand", i)
assert.True(t, ls.txRetryAt[i].IsZero(), "slot %d backed off without demand", i)
}
ls.Unlock()
// Demand on one slot starts that slot alone. (Stage 0 fails on the test
// CertState, so the observable effect is a failure charged to slot 2.)
ls.noteLaneDemand(2)
hm.EnsureLanes(base)
ls.Lock()
assert.Equal(t, uint8(1), ls.txFails[2])
assert.Zero(t, ls.txFails[1], "untouched slot attempted")
assert.Zero(t, ls.txFails[3], "untouched slot attempted")
// The attempt consumed the demand, so a later tick past the backoff does
// not retry a lane nobody is asking for any more.
ls.txRetryAt[2] = time.Time{}
ls.Unlock()
hm.EnsureLanes(base)
ls.Lock()
assert.Equal(t, uint8(1), ls.txFails[2], "consumed demand was retried")
ls.Unlock()
// Slot 0 is the base tunnel and is never a lane.
ls.noteLaneDemand(0)
assert.False(t, ls.takeLaneDemand(0))
}
// A TX miss on an empty slot is what asks for the lane; a hit must not, and a
// relay-only peer never gets that far.
func TestSendInsideMessageRecordsLaneDemand(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
_, ifce := newLaneTestConnectionManager(hostMap)
base := newTestBaseHostInfo(netip.MustParseAddr("172.1.1.10"), 1500, 1600, 4)
lane := newTestLaneHostInfo(base, 1, 1501, 1601, true)
init, _ := runTestHandshake(t)
cs, err := newConnectionStateFromResult(init)
require.NoError(t, err)
base.ConnectionState = cs
lane.ConnectionState = cs
writer := &recordingBatchWriter{}
newTx := func(laneSlot int) *txQueue {
sb := batch.NewSendBatch(writer, batch.SendBatchCap, 1<<16)
return &txQueue{laneSlot: laneSlot, base: sb, lane: sb}
}
pkt := tio.Packet{Bytes: []byte{0x45, 0, 0, 4, 1, 2, 3, 4}}
nb := make([]byte, 12)
// Empty slot: the send rides the base tunnel and flags demand for slot 1.
ifce.sendInsideMessage(base, pkt, nb, newTx(1))
assert.True(t, base.lanes.txDemand[1].Load(), "miss did not raise demand")
assert.False(t, base.lanes.txDemand[2].Load(), "demand raised on an unused slot")
// Established lane: the send uses it and asks for nothing.
base.lanes.txDemand[1].Store(false)
base.lanes.txLanes[1].Store(lane)
ifce.sendInsideMessage(base, pkt, nb, newTx(1))
assert.False(t, base.lanes.txDemand[1].Load(), "hit raised demand")
} }
+7
View File
@@ -15,6 +15,7 @@ import (
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/cpupick" "github.com/slackhq/nebula/cpupick"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/noiseutil" "github.com/slackhq/nebula/noiseutil"
"github.com/slackhq/nebula/overlay" "github.com/slackhq/nebula/overlay"
"github.com/slackhq/nebula/sshd" "github.com/slackhq/nebula/sshd"
@@ -315,6 +316,12 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
if lanes <= 0 || lanes > routines { if lanes <= 0 || lanes > routines {
lanes = routines lanes = routines
} }
if lanes > header.MaxLane+1 {
// The lane index rides in one byte of the nebula header, so lanes
// above that are unaddressable.
l.Warn("multiport.lanes clamped to header limit", "lanes", lanes, "limit", header.MaxLane+1)
lanes = header.MaxLane + 1
}
handshakeConfig.laneCount = lanes handshakeConfig.laneCount = lanes
handshakeConfig.lanePortCount = uint16(routines) handshakeConfig.lanePortCount = uint16(routines)
handshakeConfig.laneBasePort = uint16(port) handshakeConfig.laneBasePort = uint16(port)
+27
View File
@@ -1,6 +1,7 @@
package noiseutil package noiseutil
import ( import (
"crypto/cipher"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@@ -52,3 +53,29 @@ func NewCipherState(s *noise.CipherState, cipherFunc noise.CipherFunc) CipherSta
panic(fmt.Sprintf("noiseutil: unsupported cipher %q", cipherFunc.CipherName())) panic(fmt.Sprintf("noiseutil: unsupported cipher %q", cipherFunc.CipherName()))
} }
} }
// NewCipherStateFromKey builds a data-plane CipherState directly from raw key
// bytes, bypassing the noise handshake. Multiport lanes use this to key a
// derived session off the base tunnel's negotiated keys; the caller owns the
// guarantee that key is used with exactly one CipherState so nonces never
// repeat.
func NewCipherStateFromKey(key [32]byte, cipherFunc noise.CipherFunc) CipherState {
c := cipherFunc.Cipher(key)
if cs, ok := c.(CipherState); ok {
return cs
}
aead, ok := c.(cipher.AEAD)
if !ok {
panic(fmt.Sprintf("noiseutil: cipher %q does not expose an AEAD", cipherFunc.CipherName()))
}
switch cipherFunc.CipherName() {
case noise.CipherAESGCM.CipherName():
return &CipherStateAESGCM{c: aead}
case noise.CipherChaChaPoly.CipherName():
return &CipherStateChaChaPoly{c: aead}
default:
panic(fmt.Sprintf("noiseutil: unsupported cipher %q", cipherFunc.CipherName()))
}
}
+46 -8
View File
@@ -107,7 +107,35 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
return return
} }
if len(packet) < header.Len+hostinfo.ConnectionState.dKey.Overhead() { // Which session decrypts this packet is the lane index in the header. Lane 0
// is the base tunnel; a higher lane is one of the sessions derived from it.
ci := hostinfo.ConnectionState
lane := h.Lane()
if lane != 0 {
ci = hostinfo.laneSession(lane)
if ci == nil {
// A lane we have no session for: a stale lane from a tunnel that has
// since rolled, or a peer sending above what it advertised. Dropping
// silently is right for both — a recv_error would tear down a
// perfectly good base tunnel on the strength of one odd packet.
f.messageMetrics.RxInvalid(1)
if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("Unknown multiport lane", "from", via, "header", h)
}
return
}
if isMessageRelay {
// A relay carrier is always the base tunnel, so lane ciphertext can
// never legitimately arrive wrapped in one.
f.messageMetrics.RxInvalid(1)
if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("Refusing relayed multiport lane packet", "from", via, "header", h)
}
return
}
}
if len(packet) < header.Len+ci.dKey.Overhead() {
f.messageMetrics.RxInvalid(1) f.messageMetrics.RxInvalid(1)
if f.l.Enabled(context.Background(), slog.LevelDebug) { if f.l.Enabled(context.Background(), slog.LevelDebug) {
f.l.Debug("packet too small", "from", via, "length", len(packet)) f.l.Debug("packet too small", "from", via, "length", len(packet))
@@ -118,7 +146,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
// All remaining packets are encrypted // All remaining packets are encrypted
if isMessageRelay { if isMessageRelay {
// Relay packets are special, this branch should always early-return // Relay packets are special, this branch should always early-return
err = hostinfo.ConnectionState.VerifyRelay(f.l, h.MessageCounter, packet, rxc.nb) err = ci.VerifyRelay(f.l, h.MessageCounter, packet, rxc.nb)
if err != nil { if err != nil {
if f.l.Enabled(context.Background(), slog.LevelDebug) { if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("Failed to verify relay packet", "error", err, "from", via, "header", h) hostinfo.logger(f.l).Debug("Failed to verify relay packet", "error", err, "from", via, "header", h)
@@ -129,7 +157,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
return return
} }
out, err := hostinfo.ConnectionState.Decrypt(f.l, h.MessageCounter, packet, rxc.nb) out, err := ci.Decrypt(f.l, h.MessageCounter, packet, rxc.nb)
if err != nil { if err != nil {
if f.l.Enabled(context.Background(), slog.LevelDebug) { if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("Failed to decrypt packet", "error", err, "from", via, "header", h) hostinfo.logger(f.l).Debug("Failed to decrypt packet", "error", err, "from", via, "header", h)
@@ -137,15 +165,19 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
return return
} }
// Roam before we respond // Roam before we respond, but only on the base tunnel: a lane's source
f.handleHostRoaming(hostinfo, via) // address is a per-lane 4-tuple, not the tunnel's remote, and letting it
// roam the hostinfo would point every non-lane packet at a lane port.
if lane == 0 {
f.handleHostRoaming(hostinfo, via)
}
f.connectionManager.In(hostinfo) f.connectionManager.In(hostinfo)
switch h.Type { switch h.Type {
case header.Message: case header.Message:
switch h.Subtype { switch h.Subtype {
case header.MessageNone: case header.MessageNone:
f.handleOutsideMessagePacket(hostinfo, h.MessageCounter, out, rxc) f.handleOutsideMessagePacket(hostinfo, ci, h.MessageCounter, out, rxc)
default: default:
hostinfo.logger(f.l).Error("IsValidSubType was true, but unexpected message subtype seen", "from", via, "header", h) hostinfo.logger(f.l).Error("IsValidSubType was true, but unexpected message subtype seen", "from", via, "header", h)
return return
@@ -170,6 +202,10 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
return return
} }
f.send(header.Test, header.TestReply, hostinfo.ConnectionState, hostinfo, out, rxc.nb, rxc.scratch[:0]) f.send(header.Test, header.TestReply, hostinfo.ConnectionState, hostinfo, out, rxc.nb, rxc.scratch[:0])
case header.LaneProbe:
f.handleLaneProbe(hostinfo, lane, out, rxc)
case header.LaneProbeAck:
f.handleLaneProbeAck(hostinfo, out)
default: default:
hostinfo.logger(f.l).Error("IsValidSubType was true, but unexpected test subtype seen", "from", via, "header", h) hostinfo.logger(f.l).Error("IsValidSubType was true, but unexpected test subtype seen", "from", via, "header", h)
return return
@@ -471,7 +507,7 @@ func parseV4(data []byte, incoming bool, fp *firewall.ParsedPacket) error {
return nil return nil
} }
func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, messageCounter uint64, out []byte, rxc *rxContext) { func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, ci *ConnectionState, messageCounter uint64, out []byte, rxc *rxContext) {
err := newPacket(out, true, rxc.fwPacket) err := newPacket(out, true, rxc.fwPacket)
if err != nil { if err != nil {
hostinfo.logger(f.l).Warn("Error while validating inbound packet", "error", err, "packet", out) hostinfo.logger(f.l).Warn("Error while validating inbound packet", "error", err, "packet", out)
@@ -480,6 +516,8 @@ func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, messageCounte
dropReason := f.firewall.Drop(rxc.fwPacket.Packet, true, hostinfo, f.pki.GetCAPool(), rxc.ctCache.Get()) dropReason := f.firewall.Drop(rxc.fwPacket.Packet, true, hostinfo, f.pki.GetCAPool(), rxc.ctCache.Get())
if dropReason != nil { if dropReason != nil {
// The reject rides the base tunnel: it is a control response, not lane
// data, and the lane it arrived on says nothing about where it belongs.
f.rejectOutside(out, hostinfo.ConnectionState, hostinfo, rxc.nb, rxc.scratch, rxc.q) f.rejectOutside(out, hostinfo.ConnectionState, hostinfo, rxc.nb, rxc.scratch, rxc.q)
if f.l.Enabled(context.Background(), slog.LevelDebug) { if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("dropping inbound packet", "fwPacket", rxc.fwPacket, "reason", dropReason) hostinfo.logger(f.l).Debug("dropping inbound packet", "fwPacket", rxc.fwPacket, "reason", dropReason)
@@ -487,7 +525,7 @@ func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, messageCounte
return return
} }
err = f.batchers[rxc.q].Commit(out, batch.SortKey{Epoch: hostinfo.ConnectionState.epoch, Counter: messageCounter}, rxc.fwPacket) err = f.batchers[rxc.q].Commit(out, batch.SortKey{Epoch: ci.epoch, Counter: messageCounter}, rxc.fwPacket)
if err != nil { if err != nil {
f.l.Error("Failed to write to tun", "error", err) f.l.Error("Failed to write to tun", "error", err)
} }