crazy multiport stuff

This commit is contained in:
JackDoan
2026-07-21 10:52:24 -05:00
parent 59ecea92ce
commit 0488793a62
19 changed files with 1801 additions and 76 deletions
+86 -1
View File
@@ -191,6 +191,28 @@ func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte
}
cm.resetRelayTrafficCheck(hostinfo)
cm.ensureLanes(localIndex, decision, hostinfo)
}
// ensureLanes piggybacks lane re-establishment on the per-tunnel traffic
// tick: any live base with lane state gets its empty slots retried (subject to
// the per-slot backoff). makeTrafficDecision returns a nil hostinfo on some
// keep-alive paths, so re-resolve the index in that case — an idle base must
// still restart lanes that died while it was quiet.
func (cm *connectionManager) ensureLanes(localIndex uint32, decision trafficDecision, hostinfo *HostInfo) {
if decision == deleteTunnel || decision == closeTunnel {
return
}
if hostinfo == nil {
hostinfo = cm.hostMap.QueryIndex(localIndex)
if hostinfo == nil {
return
}
}
if hostinfo.isLane() || hostinfo.lanes == nil {
return
}
cm.intf.handshakeManager.EnsureLanes(hostinfo)
}
func (cm *connectionManager) resetRelayTrafficCheck(hostinfo *HostInfo) {
@@ -323,6 +345,10 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
return closeTunnel, hostinfo, nil
}
if hostinfo.isLane() {
return cm.makeLaneTrafficDecision(hostinfo, now)
}
primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]]
mainHostInfo := true
if primary != nil && primary != hostinfo {
@@ -419,13 +445,72 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
return decision, hostinfo, nil
}
// makeLaneTrafficDecision is the lane-tunnel subset of makeTrafficDecision:
// no primary/swap/rehandshake logic (lanes are never primary), no punches
// (lane keepalives egress the lane's own socket and are its NAT keepalive),
// just alive / test / dead. A dead lane's DeleteHostInfo clears its base slot
// with backoff, and ensureLanes re-establishes it.
func (cm *connectionManager) makeLaneTrafficDecision(hostinfo *HostInfo, now time.Time) (trafficDecision, *HostInfo, *HostInfo) {
inTraffic, _ := cm.getAndResetTrafficCheck(hostinfo, now)
if inTraffic {
if cm.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(cm.l).Debug("Tunnel status",
"tunnelCheck", m{"state": "alive", "method": "passive"},
"laneIndex", hostinfo.laneIndex,
)
}
hostinfo.pendingDeletion.Store(false)
cm.trafficTimer.Add(hostinfo.localIndexId, cm.checkInterval)
return doNothing, hostinfo, nil
}
if hostinfo.pendingDeletion.Load() {
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.pendingDeletion.Store(true)
cm.trafficTimer.Add(hostinfo.localIndexId, cm.pendingDeletionInterval)
return decision, hostinfo, nil
}
func (cm *connectionManager) isInactive(hostinfo *HostInfo, now time.Time) (time.Duration, bool) {
if cm.dropInactive.Load() == false {
// We aren't configured to drop inactive tunnels
return 0, false
}
inactiveDuration := now.Sub(hostinfo.lastUsed)
// With multiport the data rides the lanes and the base may look idle;
// a base is only inactive if its whole lane family is. lastUsed is only
// written by this ticker goroutine, so these reads are safe.
lastUsed := hostinfo.lastUsed
if ls := hostinfo.lanes; ls != nil {
for i := range ls.txLanes {
if lane := ls.txLanes[i].Load(); lane != nil && lane.lastUsed.After(lastUsed) {
lastUsed = lane.lastUsed
}
}
ls.Lock()
for _, lane := range ls.peerLanes {
if lane.lastUsed.After(lastUsed) {
lastUsed = lane.lastUsed
}
}
ls.Unlock()
}
inactiveDuration := now.Sub(lastUsed)
if inactiveDuration < cm.getInactivityTimeout() {
// It's not considered inactive
return inactiveDuration, false
+2
View File
@@ -55,6 +55,7 @@ func runTestHandshake(t *testing.T) (initR, respR *handshake.Result) {
cert.Version2, initCreds, verifier,
func() (uint32, error) { return 1000, nil },
true, header.HandshakeIXPSK0,
nil,
)
require.NoError(t, err)
@@ -62,6 +63,7 @@ func runTestHandshake(t *testing.T) (initR, respR *handshake.Result) {
cert.Version2, respCreds, verifier,
func() (uint32, error) { return 2000, nil },
false, header.HandshakeIXPSK0,
nil,
)
require.NoError(t, err)
+14 -1
View File
@@ -66,6 +66,9 @@ type ControlHostInfo struct {
CurrentRemote netip.AddrPort `json:"currentRemote"`
CurrentRelaysToMe []netip.Addr `json:"currentRelaysToMe"`
CurrentRelaysThroughMe []netip.Addr `json:"currentRelaysThroughMe"`
IsLane bool `json:"isLane,omitempty"`
LaneIndex uint16 `json:"laneIndex,omitempty"`
SockIdx int `json:"sockIdx,omitempty"`
}
// Start actually runs nebula, this is a nonblocking call.
@@ -198,7 +201,9 @@ func (c *Control) RebindUDPServer() {
return
}
_ = c.f.outside.Rebind()
for _, w := range c.f.writers {
_ = w.Rebind()
}
// Trigger a lighthouse update, useful for mobile clients that should have an update interval of 0
c.f.lightHouse.SendUpdate()
@@ -349,6 +354,11 @@ func (c *Control) CloseAllTunnels(excludeLighthouses bool) (closed int) {
// Grab the hostMap lock to access the Hosts map
c.f.hostMap.Lock()
for _, relayHost := range c.f.hostMap.Indexes {
// Lanes ride along with their base tunnel's shutdown cascade; closing
// them individually would race the cascade's identity-checked deletes.
if relayHost.isLane() {
continue
}
if _, ok := relayingHosts[relayHost.vpnAddrs[0]]; !ok {
hostInfos = append(hostInfos, relayHost)
}
@@ -377,6 +387,9 @@ func copyHostInfo(h *HostInfo, preferredRanges []netip.Prefix) ControlHostInfo {
CurrentRelaysToMe: h.relayState.CopyRelayIps(),
CurrentRelaysThroughMe: h.relayState.CopyRelayForIps(),
CurrentRemote: h.GetRemote(),
IsLane: h.isLane(),
LaneIndex: h.laneIndex,
SockIdx: h.sockIdx,
}
for i, a := range h.vpnAddrs {
+1 -1
View File
@@ -105,7 +105,7 @@ func TestControl_GetHostInfoByVpnIp(t *testing.T) {
}
// Make sure we don't have any unexpected fields
assertFields(t, []string{"VpnAddrs", "LocalIndex", "RemoteIndex", "RemoteAddrs", "Cert", "MessageCounter", "CurrentRemote", "CurrentRelaysToMe", "CurrentRelaysThroughMe"}, thi)
assertFields(t, []string{"VpnAddrs", "LocalIndex", "RemoteIndex", "RemoteAddrs", "Cert", "MessageCounter", "CurrentRemote", "CurrentRelaysToMe", "CurrentRelaysThroughMe", "IsLane", "LaneIndex", "SockIdx"}, thi)
assert.Equal(t, &expectedInfo, thi)
test.AssertDeepCopyEqual(t, &expectedInfo, thi)
+30
View File
@@ -169,6 +169,36 @@ listen:
# This option is only supported on Linux.
#routines: 1
# EXPERIMENTAL: multiport lanes give each pair of hosts multiple underlay UDP
# flows so overlay traffic is no longer bottlenecked by a single 5-tuple
# (one ECMP path, one NIC RSS queue, one per-flow policer). Socket i binds
# listen.port+i instead of sharing one port via SO_REUSEPORT, and one extra
# tunnel ("lane") per routine is negotiated with capable peers: lane i
# handshakes from local port listen.port+i to the peer's advertised
# base+(i mod peer_ports). Each lane is a full Noise session with its own
# keys, nonce counter and replay window, so flows taking different paths
# never fight over shared replay state.
#
# Peers negotiate lanes in the handshake; vanilla peers get a single normal
# tunnel. All control traffic (handshakes, lighthouse, punching, relays) and
# the data fallback stay on the base tunnel/port. Lanes are established after
# the base tunnel comes up, are kept alive with their own keepalives, and
# traffic falls back to the base tunnel while a lane is down.
#
# Requirements: routines > 1, Linux, and the port range
# [listen.port, listen.port+routines-1] reachable through firewalls on both
# sides (peers behind NAT fall back to the base tunnel). With listen.port 0
# the base port is dynamic and the next routines-1 ports above it are
# claimed. Enabled by default when the requirements hold; degrades to a
# single port otherwise. Not reloadable.
#multiport:
# Bind `routines` consecutive UDP ports and negotiate lanes with peers.
#enabled: true
# How many lanes to run, counting the base tunnel as lane 0. 0 (default)
# means one per routine. Lowering this bounds how many extra tunnels each
# peer pair maintains; routines without a lane use the base tunnel.
#lanes: 0
punchy:
# Continues to punch inbound/outbound at a regular interval to avoid expiration of firewall nat mappings
# This setting is reloadable.
+14 -2
View File
@@ -23,7 +23,19 @@ message NebulaHandshakeDetails {
// hand-written parser silently skips it on read.
uint64 Cookie = 4 [deprecated = true];
uint64 Time = 5;
// Multiport lane negotiation. Absent on hosts without multiport enabled;
// vanilla nebula treats 6 and 7 as unknown fields and skips them.
LaneDetails InitiatorLanes = 6;
LaneDetails ResponderLanes = 7;
uint32 CertVersion = 8;
// reserved for WIP multiport
reserved 6, 7;
}
// LaneDetails advertises a host's multiport lane capability. On a base
// handshake LaneIndex is 0 and PortCount/BasePort describe the sender's
// consecutively bound UDP ports. On a lane handshake the initiator sets
// LaneIndex to its (nonzero) lane number.
message LaneDetails {
uint32 PortCount = 1;
uint32 BasePort = 2;
uint32 LaneIndex = 3;
}
+1
View File
@@ -71,6 +71,7 @@ func newTestMachine(
cs.version, cs.getCredential,
verifier, func() (uint32, error) { return localIndex, nil },
initiator, header.HandshakeIXPSK0,
nil,
)
require.NoError(t, err)
return m
+33 -1
View File
@@ -39,6 +39,13 @@ type Result struct {
HandshakeTime uint64
MessageIndex uint64 // number of messages exchanged during the handshake
Initiator bool
// Multiport lane negotiation, from the peer's LaneDetails. All zero when
// the peer did not advertise (vanilla peer or multiport disabled).
// PeerLaneIndex is nonzero only on the responder side of a lane handshake.
PeerPortCount uint32
PeerBasePort uint32
PeerLaneIndex uint32
}
// Machine drives a Noise handshake through N messages. It handles Noise
@@ -61,6 +68,7 @@ type Machine struct {
verifier CertVerifier
result *Result
msgs []msgFlags
lanes *LaneDetails // our multiport advert; nil emits a vanilla payload
myVersion cert.Version
subtype header.MessageSubType
indexAllocated bool
@@ -73,6 +81,8 @@ type Machine struct {
// the noise pattern and the per-message content layout. The credential for
// `version` is fetched via getCred and used to seed the noise.HandshakeState.
// IndexAllocator is called lazily when the first outgoing payload is built.
// lanes, when non-nil, is emitted as this side's multiport advert on every
// payload-bearing message; nil produces byte-identical vanilla payloads.
func NewMachine(
version cert.Version,
getCred GetCredentialFunc,
@@ -80,6 +90,7 @@ func NewMachine(
allocIndex IndexAllocator,
initiator bool,
subtype header.MessageSubType,
lanes *LaneDetails,
) (*Machine, error) {
info, err := subtypeInfoFor(subtype)
if err != nil {
@@ -103,6 +114,7 @@ func NewMachine(
getCred: getCred,
allocIndex: allocIndex,
verifier: verifier,
lanes: lanes,
myVersion: version,
result: &Result{
Initiator: initiator,
@@ -298,7 +310,8 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
}
// Assert the payload contains exactly what we expect
hasPayloadData := payload.InitiatorIndex != 0 || payload.ResponderIndex != 0 || payload.Time != 0
hasPayloadData := payload.InitiatorIndex != 0 || payload.ResponderIndex != 0 || payload.Time != 0 ||
payload.InitiatorLanes != nil || payload.ResponderLanes != nil
if hasPayloadData != flags.expectsPayload {
m.failed = true
return ErrUnexpectedContent
@@ -327,6 +340,23 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
m.result.RemoteIndex = remoteIndex
m.result.HandshakeTime = payload.Time
m.payloadSet = true
// Multiport advert from the peer's side of the exchange. Out-of-range
// values mean a peer we can't pair lanes with; ignore the advert
// rather than failing the handshake — the tunnel itself is fine, it
// just won't get lanes. Semantic policing (index bounds vs advert,
// port-count caps) belongs to the handshake manager.
var peerLanes *LaneDetails
if m.result.Initiator {
peerLanes = payload.ResponderLanes
} else {
peerLanes = payload.InitiatorLanes
}
if peerLanes != nil && peerLanes.BasePort <= 0xffff && peerLanes.PortCount <= 0xffff {
m.result.PeerPortCount = peerLanes.PortCount
m.result.PeerBasePort = peerLanes.BasePort
m.result.PeerLaneIndex = peerLanes.LaneIndex
}
}
// Process certificate
@@ -397,9 +427,11 @@ func (m *Machine) marshalOutgoing(flags msgFlags) ([]byte, error) {
if m.result.Initiator {
p.InitiatorIndex = m.result.LocalIndex
p.InitiatorLanes = m.lanes
} else {
p.ResponderIndex = m.result.LocalIndex
p.InitiatorIndex = m.result.RemoteIndex
p.ResponderLanes = m.lanes
}
p.Time = uint64(time.Now().UnixNano())
}
+113
View File
@@ -0,0 +1,113 @@
package handshake
import (
"net/netip"
"testing"
"time"
"github.com/slackhq/nebula/cert"
ct "github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/header"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestLaneMachine is newTestMachine with a lane advert attached.
func newTestLaneMachine(
t *testing.T,
cs *testCertState,
verifier CertVerifier,
initiator bool,
localIndex uint32,
lanes *LaneDetails,
) *Machine {
t.Helper()
m, err := NewMachine(
cs.version, cs.getCredential,
verifier, func() (uint32, error) { return localIndex, nil },
initiator, header.HandshakeIXPSK0,
lanes,
)
require.NoError(t, err)
return m
}
func doFullLaneHandshake(t *testing.T, initLanes, respLanes *LaneDetails) (initR, respR *Result) {
t.Helper()
ca, _, caKey, _ := ct.NewTestCaCert(
cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil,
)
caPool := ct.NewTestCAPool(ca)
v := testVerifier(caPool)
initCS := newTestCertState(t, ca, caKey, "initiator", []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")})
respCS := newTestCertState(t, ca, caKey, "responder", []netip.Prefix{netip.MustParsePrefix("10.0.0.2/24")})
initM := newTestLaneMachine(t, initCS, v, true, 1000, initLanes)
respM := newTestLaneMachine(t, respCS, v, false, 2000, respLanes)
msg1, err := initM.Initiate(nil)
require.NoError(t, err)
resp, respR, err := respM.ProcessPacket(nil, msg1)
require.NoError(t, err)
require.NotNil(t, respR)
_, initR, err = initM.ProcessPacket(nil, resp)
require.NoError(t, err)
require.NotNil(t, initR)
return initR, respR
}
func TestMachineLaneAdvertBothSides(t *testing.T) {
initR, respR := doFullLaneHandshake(t,
&LaneDetails{PortCount: 8, BasePort: 4242},
&LaneDetails{PortCount: 4, BasePort: 5353},
)
// Each side's Result carries the peer's advert.
assert.Equal(t, uint32(4), initR.PeerPortCount)
assert.Equal(t, uint32(5353), initR.PeerBasePort)
assert.Equal(t, uint32(0), initR.PeerLaneIndex)
assert.Equal(t, uint32(8), respR.PeerPortCount)
assert.Equal(t, uint32(4242), respR.PeerBasePort)
assert.Equal(t, uint32(0), respR.PeerLaneIndex)
}
func TestMachineLaneHandshakeCarriesLaneIndex(t *testing.T) {
// A lane handshake: initiator tags its lane number; responder still
// adverts (harmlessly).
initR, respR := doFullLaneHandshake(t,
&LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3},
&LaneDetails{PortCount: 4, BasePort: 5353},
)
assert.Equal(t, uint32(3), respR.PeerLaneIndex)
assert.Equal(t, uint32(0), initR.PeerLaneIndex)
}
func TestMachineLaneAdvertAsymmetric(t *testing.T) {
// Vanilla initiator, multiport responder and vice versa: the nil side
// yields all-zero peer fields on the other end.
initR, respR := doFullLaneHandshake(t, nil, &LaneDetails{PortCount: 4, BasePort: 5353})
assert.Equal(t, uint32(4), initR.PeerPortCount)
assert.Equal(t, uint32(0), respR.PeerPortCount)
assert.Equal(t, uint32(0), respR.PeerBasePort)
initR, respR = doFullLaneHandshake(t, &LaneDetails{PortCount: 8, BasePort: 4242}, nil)
assert.Equal(t, uint32(0), initR.PeerPortCount)
assert.Equal(t, uint32(8), respR.PeerPortCount)
}
func TestMachineLaneAdvertOutOfRangeIgnored(t *testing.T) {
// A BasePort that can't be a real UDP port is ignored, not fatal.
initR, respR := doFullLaneHandshake(t,
&LaneDetails{PortCount: 8, BasePort: 70000},
&LaneDetails{PortCount: 4, BasePort: 5353},
)
assert.Equal(t, uint32(0), respR.PeerPortCount)
assert.Equal(t, uint32(0), respR.PeerBasePort)
// The sane side still negotiates.
assert.Equal(t, uint32(4), initR.PeerPortCount)
}
+2
View File
@@ -444,6 +444,7 @@ func TestMachineThreeMessagePattern(t *testing.T) {
initCS.getCredential, v,
func() (uint32, error) { return 1000, nil },
true, header.HandshakeXXPSK0,
nil,
)
require.NoError(t, err)
@@ -452,6 +453,7 @@ func TestMachineThreeMessagePattern(t *testing.T) {
respCS.getCredential, v,
func() (uint32, error) { return 2000, nil },
false, header.HandshakeXXPSK0,
nil,
)
require.NoError(t, err)
+110
View File
@@ -20,6 +20,19 @@ type Payload struct {
ResponderIndex uint32
Time uint64
CertVersion uint32
// Multiport lane negotiation; nil when the sender has multiport disabled
// (which keeps the encoded payload byte-identical to a vanilla one).
InitiatorLanes *LaneDetails
ResponderLanes *LaneDetails
}
// LaneDetails advertises multiport lane capability. LaneIndex is zero on base
// handshakes and the initiator's lane number (>= 1) on lane handshakes.
type LaneDetails struct {
PortCount uint32
BasePort uint32
LaneIndex uint32
}
// Proto field numbers for NebulaHandshakeDetails
@@ -28,9 +41,18 @@ const (
fieldInitiatorIndex = 2 // uint32
fieldResponderIndex = 3 // uint32
fieldTime = 5 // uint64
fieldInitiatorLanes = 6 // LaneDetails
fieldResponderLanes = 7 // LaneDetails
fieldCertVersion = 8 // uint32
)
// Proto field numbers for LaneDetails
const (
fieldLanePortCount = 1 // uint32
fieldLaneBasePort = 2 // uint32
fieldLaneLaneIndex = 3 // uint32
)
// MarshalPayload encodes a handshake payload in protobuf wire format compatible
// with NebulaHandshake{Details: NebulaHandshakeDetails{...}}.
// Returns out (which may be nil), with the marshalled Payload appended to it.
@@ -53,6 +75,14 @@ func MarshalPayload(out []byte, p Payload) []byte {
details = protowire.AppendTag(details, fieldTime, protowire.VarintType)
details = protowire.AppendVarint(details, p.Time)
}
if p.InitiatorLanes != nil {
details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
details = protowire.AppendBytes(details, p.InitiatorLanes.marshal(nil))
}
if p.ResponderLanes != nil {
details = protowire.AppendTag(details, fieldResponderLanes, protowire.BytesType)
details = protowire.AppendBytes(details, p.ResponderLanes.marshal(nil))
}
if p.CertVersion != 0 {
details = protowire.AppendTag(details, fieldCertVersion, protowire.VarintType)
details = protowire.AppendVarint(details, uint64(p.CertVersion))
@@ -64,6 +94,20 @@ func MarshalPayload(out []byte, p Payload) []byte {
return out
}
// marshal appends the LaneDetails submessage fields to out. All fields are
// emitted unconditionally: a LaneDetails is only present at all when multiport
// is negotiating, and explicit zeros keep the parser's presence semantics
// trivial.
func (d *LaneDetails) marshal(out []byte) []byte {
out = protowire.AppendTag(out, fieldLanePortCount, protowire.VarintType)
out = protowire.AppendVarint(out, uint64(d.PortCount))
out = protowire.AppendTag(out, fieldLaneBasePort, protowire.VarintType)
out = protowire.AppendVarint(out, uint64(d.BasePort))
out = protowire.AppendTag(out, fieldLaneLaneIndex, protowire.VarintType)
out = protowire.AppendVarint(out, uint64(d.LaneIndex))
return out
}
// UnmarshalPayload decodes a protobuf-encoded NebulaHandshake message.
func UnmarshalPayload(b []byte) (Payload, error) {
var p Payload
@@ -161,6 +205,72 @@ func unmarshalPayloadDetails(p *Payload, b []byte) error {
}
p.CertVersion = uint32(v)
b = b[n:]
case fieldInitiatorLanes:
if typ != protowire.BytesType {
return errInvalidHandshakeDetails
}
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return errInvalidHandshakeDetails
}
p.InitiatorLanes = new(LaneDetails)
if err := unmarshalLaneDetails(p.InitiatorLanes, v); err != nil {
return err
}
b = b[n:]
case fieldResponderLanes:
if typ != protowire.BytesType {
return errInvalidHandshakeDetails
}
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return errInvalidHandshakeDetails
}
p.ResponderLanes = new(LaneDetails)
if err := unmarshalLaneDetails(p.ResponderLanes, v); err != nil {
return err
}
b = b[n:]
default:
n := protowire.ConsumeFieldValue(num, typ, b)
if n < 0 {
return errInvalidHandshakeDetails
}
b = b[n:]
}
}
return nil
}
func unmarshalLaneDetails(d *LaneDetails, b []byte) error {
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
if n < 0 {
return errInvalidHandshakeDetails
}
b = b[n:]
// Same contract as the details parser: known fields hard-fail on a
// wire-type mismatch, unknown fields are skipped, repeated singular
// fields follow proto3 last-wins.
switch num {
case fieldLanePortCount, fieldLaneBasePort, fieldLaneLaneIndex:
if typ != protowire.VarintType {
return errInvalidHandshakeDetails
}
v, n := protowire.ConsumeVarint(b)
if n < 0 || v > math.MaxUint32 {
return errInvalidHandshakeDetails
}
switch num {
case fieldLanePortCount:
d.PortCount = uint32(v)
case fieldLaneBasePort:
d.BasePort = uint32(v)
case fieldLaneLaneIndex:
d.LaneIndex = uint32(v)
}
b = b[n:]
default:
n := protowire.ConsumeFieldValue(num, typ, b)
if n < 0 {
+137 -11
View File
@@ -117,23 +117,134 @@ func TestPayloadUnknownFields(t *testing.T) {
assert.Equal(t, uint32(88), got.ResponderIndex)
})
t.Run("reserved fields 6 and 7 are skipped", func(t *testing.T) {
// Fields 6 and 7 are reserved in the proto definition
t.Run("unknown field inside LaneDetails is skipped", func(t *testing.T) {
var lane []byte
lane = protowire.AppendTag(lane, fieldLanePortCount, protowire.VarintType)
lane = protowire.AppendVarint(lane, 4)
lane = protowire.AppendTag(lane, 50, protowire.VarintType) // unknown subfield
lane = protowire.AppendVarint(lane, 9999)
lane = protowire.AppendTag(lane, fieldLaneBasePort, protowire.VarintType)
lane = protowire.AppendVarint(lane, 4242)
var details []byte
details = protowire.AppendTag(details, fieldInitiatorIndex, protowire.VarintType)
details = protowire.AppendVarint(details, 100)
details = protowire.AppendTag(details, 6, protowire.VarintType)
details = protowire.AppendVarint(details, 1)
details = protowire.AppendTag(details, 7, protowire.VarintType)
details = protowire.AppendVarint(details, 2)
details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
details = protowire.AppendBytes(details, lane)
var data []byte
data = protowire.AppendTag(data, 1, protowire.BytesType)
data = protowire.AppendBytes(data, details)
got, err := UnmarshalPayload(wrapDetails(details))
require.NoError(t, err)
assert.Equal(t, uint32(100), got.InitiatorIndex)
require.NotNil(t, got.InitiatorLanes)
assert.Equal(t, uint32(4), got.InitiatorLanes.PortCount)
assert.Equal(t, uint32(4242), got.InitiatorLanes.BasePort)
})
}
func TestPayloadLaneDetails(t *testing.T) {
t.Run("round trip both sides", func(t *testing.T) {
data := MarshalPayload(nil, Payload{
InitiatorIndex: 12345,
Time: 999,
InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3},
ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353},
})
got, err := UnmarshalPayload(data)
require.NoError(t, err)
assert.Equal(t, uint32(100), got.InitiatorIndex)
require.NotNil(t, got.InitiatorLanes)
assert.Equal(t, LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 3}, *got.InitiatorLanes)
require.NotNil(t, got.ResponderLanes)
assert.Equal(t, LaneDetails{PortCount: 4, BasePort: 5353}, *got.ResponderLanes)
})
t.Run("zero-valued LaneDetails survives the round trip", func(t *testing.T) {
// Presence is what negotiation keys on; an all-zero advert must not
// decay to nil.
data := MarshalPayload(nil, Payload{
InitiatorIndex: 1,
InitiatorLanes: &LaneDetails{},
})
got, err := UnmarshalPayload(data)
require.NoError(t, err)
require.NotNil(t, got.InitiatorLanes)
assert.Equal(t, LaneDetails{}, *got.InitiatorLanes)
assert.Nil(t, got.ResponderLanes)
})
t.Run("nil lanes marshal byte-identical to a vanilla payload", func(t *testing.T) {
p := Payload{
Cert: []byte("cert"),
CertVersion: 2,
InitiatorIndex: 100,
Time: 999,
}
// The vanilla encoding of the same fields, built by hand in field order.
var details []byte
details = protowire.AppendTag(details, fieldCert, protowire.BytesType)
details = protowire.AppendBytes(details, p.Cert)
details = protowire.AppendTag(details, fieldInitiatorIndex, protowire.VarintType)
details = protowire.AppendVarint(details, uint64(p.InitiatorIndex))
details = protowire.AppendTag(details, fieldTime, protowire.VarintType)
details = protowire.AppendVarint(details, p.Time)
details = protowire.AppendTag(details, fieldCertVersion, protowire.VarintType)
details = protowire.AppendVarint(details, uint64(p.CertVersion))
assert.Equal(t, wrapDetails(details), MarshalPayload(nil, p))
})
t.Run("lane field with wrong wire type rejected", func(t *testing.T) {
for _, field := range []protowire.Number{fieldInitiatorLanes, fieldResponderLanes} {
var details []byte
details = protowire.AppendTag(details, field, protowire.VarintType)
details = protowire.AppendVarint(details, 1)
_, err := UnmarshalPayload(wrapDetails(details))
assert.Error(t, err)
}
})
t.Run("lane subfield with wrong wire type rejected", func(t *testing.T) {
var lane []byte
lane = protowire.AppendTag(lane, fieldLanePortCount, protowire.BytesType)
lane = protowire.AppendBytes(lane, []byte{1, 2, 3})
var details []byte
details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
details = protowire.AppendBytes(details, lane)
_, err := UnmarshalPayload(wrapDetails(details))
assert.Error(t, err)
})
t.Run("truncated LaneDetails submessage rejected", func(t *testing.T) {
var details []byte
details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
details = append(details, 0x0a, 0x01, 0x02) // length 10, only 2 bytes
_, err := UnmarshalPayload(wrapDetails(details))
assert.Error(t, err)
})
t.Run("truncated varint inside LaneDetails rejected", func(t *testing.T) {
var lane []byte
lane = protowire.AppendTag(lane, fieldLaneBasePort, protowire.VarintType)
lane = append(lane, 0x80) // incomplete varint
var details []byte
details = protowire.AppendTag(details, fieldResponderLanes, protowire.BytesType)
details = protowire.AppendBytes(details, lane)
_, err := UnmarshalPayload(wrapDetails(details))
assert.Error(t, err)
})
t.Run("lane subfield varint overflow rejected", func(t *testing.T) {
var lane []byte
lane = protowire.AppendTag(lane, fieldLaneLaneIndex, protowire.VarintType)
lane = protowire.AppendVarint(lane, math.MaxUint32+1)
var details []byte
details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
details = protowire.AppendBytes(details, lane)
_, err := UnmarshalPayload(wrapDetails(details))
assert.Error(t, err)
})
}
@@ -328,6 +439,12 @@ func FuzzPayload(f *testing.F) {
Time: 3,
CertVersion: 2,
}))
f.Add(MarshalPayload(nil, Payload{
InitiatorIndex: 1,
Time: 3,
InitiatorLanes: &LaneDetails{PortCount: 8, BasePort: 4242, LaneIndex: 2},
ResponderLanes: &LaneDetails{PortCount: 4, BasePort: 5353},
}))
f.Add([]byte{})
f.Add([]byte{0xff})
@@ -357,5 +474,14 @@ func payloadsEqual(a, b Payload) bool {
a.InitiatorIndex == b.InitiatorIndex &&
a.ResponderIndex == b.ResponderIndex &&
a.Time == b.Time &&
a.CertVersion == b.CertVersion
a.CertVersion == b.CertVersion &&
laneDetailsEqual(a.InitiatorLanes, b.InitiatorLanes) &&
laneDetailsEqual(a.ResponderLanes, b.ResponderLanes)
}
func laneDetailsEqual(a, b *LaneDetails) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
+433 -11
View File
@@ -50,6 +50,14 @@ type HandshakeConfig struct {
retries int64
triggerBuffer int
// Multiport lane parameters; laneCount == 0 means multiport is disabled.
// laneCount includes implicit lane 0 (the base tunnel), so lanes
// 1..laneCount-1 are initiated. lanePortCount/laneBasePort describe our
// own bound port range and are advertised in every handshake payload.
laneCount int
lanePortCount uint16
laneBasePort uint16
messageMetrics *MessageMetrics
}
@@ -65,11 +73,15 @@ type HandshakeManager struct {
outside udp.Conn
config HandshakeConfig
OutboundHandshakeTimer *LockingTimerWheel[netip.Addr]
messageMetrics *MessageMetrics
metricInitiated metrics.Counter
metricTimedOut metrics.Counter
f *Interface
l *slog.Logger
// OutboundLaneTimer drives lane handshake retries. Lanes never enter
// vpnIps (they would collide with base handshakes for the same address),
// so their wheel is keyed by pending localIndexId instead.
OutboundLaneTimer *LockingTimerWheel[uint32]
messageMetrics *MessageMetrics
metricInitiated metrics.Counter
metricTimedOut metrics.Counter
f *Interface
l *slog.Logger
// can be used to trigger outbound handshake for the given vpnIp
trigger chan netip.Addr
@@ -88,6 +100,11 @@ type HandshakeHostInfo struct {
hostinfo *HostInfo
machine *handshake.Machine // The handshake state machine, set during stage 0 (initiator) or beginHandshake (responder multi-message)
// laneTarget is the single pinned destination for a lane handshake
// (base-tunnel remote IP at the peer's lane port). Lane handshakes never
// broadcast to the RemoteList.
laneTarget netip.AddrPort
}
func (hh *HandshakeHostInfo) cachePacket(l *slog.Logger, t header.MessageType, st header.MessageSubType, packet []byte, f packetCallback, m *cachedPacketMetrics) {
@@ -125,6 +142,7 @@ func NewHandshakeManager(l *slog.Logger, mainHostMap *HostMap, lightHouse *Light
config: config,
trigger: make(chan netip.Addr, config.triggerBuffer),
OutboundHandshakeTimer: NewLockingTimerWheel[netip.Addr](config.tryInterval, hsTimeout(config.retries, config.tryInterval)),
OutboundLaneTimer: NewLockingTimerWheel[uint32](config.tryInterval, hsTimeout(config.retries, config.tryInterval)),
messageMetrics: config.messageMetrics,
metricInitiated: metrics.GetOrRegisterCounter("handshake_manager.initiated", nil),
metricTimedOut: metrics.GetOrRegisterCounter("handshake_manager.timed_out", nil),
@@ -144,6 +162,7 @@ func (hm *HandshakeManager) Run(ctx context.Context) {
hm.handleOutbound(vpnIP, true)
case now := <-clockSource.C:
hm.NextOutboundHandshakeTimerTick(now)
hm.NextOutboundLaneTimerTick(now)
}
}
}
@@ -529,7 +548,13 @@ func (hm *HandshakeManager) DeleteHostInfo(hostinfo *HostInfo) {
func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) {
for _, addr := range hostinfo.vpnAddrs {
delete(hm.vpnIps, addr)
// Only delete the pending entry if it is actually ours. Lane
// handshakes never live in vpnIps, and an unconditional delete here
// could evict a concurrently pending base handshake for the same
// address.
if cur, ok := hm.vpnIps[addr]; ok && cur.hostinfo == hostinfo {
delete(hm.vpnIps, addr)
}
}
if len(hm.vpnIps) == 0 {
@@ -664,6 +689,7 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
v, cs.GetCredential,
hm.certVerifier(), func() (uint32, error) { return hm.allocateIndex(hh) },
true, header.HandshakeIXPSK0,
hm.laneAdvert(uint32(hh.hostinfo.laneIndex)),
)
if err != nil {
hm.f.l.Error("Failed to create handshake machine",
@@ -687,6 +713,215 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
return true
}
// laneAdvert returns our multiport advert for a handshake payload, or nil
// when multiport is disabled (which keeps the payload byte-identical to
// vanilla). laneIndex is 0 for base handshakes and our lane number for lane
// handshakes.
func (hm *HandshakeManager) laneAdvert(laneIndex uint32) *handshake.LaneDetails {
if hm.config.laneCount == 0 {
return nil
}
return &handshake.LaneDetails{
PortCount: uint32(hm.config.lanePortCount),
BasePort: uint32(hm.config.laneBasePort),
LaneIndex: laneIndex,
}
}
// maybeAllocLaneState attaches a laneState to a just-completed base tunnel
// when both sides advertised multiport. Must run before the hostinfo becomes
// visible in the hostmap: the data plane reads base.lanes lock-free.
func (hm *HandshakeManager) maybeAllocLaneState(hostinfo *HostInfo, result *handshake.Result) {
if hm.config.laneCount == 0 || result.PeerPortCount == 0 || result.PeerLaneIndex != 0 {
return
}
peerPorts := result.PeerPortCount
if peerPorts > 256 {
// A certified-but-hostile peer doesn't get to size our state.
peerPorts = 256
}
hostinfo.lanes = newLaneState(hm.f.routines, uint16(peerPorts), uint16(result.PeerBasePort))
}
// EnsureLanes starts lane handshakes for every empty, non-pending, retry-due
// slot of a base tunnel. Called on base handshake completion (both sides) and
// from the connection manager's per-tunnel tick, so a dead lane re-establishes
// within one check interval, subject to per-slot backoff.
func (hm *HandshakeManager) EnsureLanes(base *HostInfo) {
ls := base.lanes
if ls == nil || hm.config.laneCount <= 1 {
return
}
now := time.Now()
var starts []int
ls.Lock()
n := min(len(ls.txLanes), hm.config.laneCount)
for i := 1; i < n; i++ {
if ls.txLanes[i].Load() != nil || ls.txPending[i] || now.Before(ls.txRetryAt[i]) {
continue
}
ls.txPending[i] = true
starts = append(starts, i)
}
ls.Unlock()
for _, i := range starts {
hm.startLaneHandshake(base, i)
}
}
// startLaneHandshake initiates lane i of base: a full Noise handshake from
// local socket i to the peer's advertised lane port. The caller has already
// claimed the slot (txPending[i] = true); every failure path must release it
// via noteLaneFailure.
func (hm *HandshakeManager) startLaneHandshake(base *HostInfo, i int) {
ls := base.lanes
remote := base.GetRemote()
if !remote.IsValid() || ls.peerPortCount == 0 {
// Relay-only peer (or a zero advert that should not have allocated
// lanes). Retried if a direct path shows up later.
ls.noteLaneFailure(i)
return
}
target := netip.AddrPortFrom(remote.Addr(), ls.peerBasePort+uint16(i%int(ls.peerPortCount)))
hostinfo := &HostInfo{
vpnAddrs: slices.Clone(base.vpnAddrs),
HandshakePacket: make(map[uint8][]byte, 0),
relayState: RelayState{
relays: nil,
relayForByAddr: map[netip.Addr]*Relay{},
relayForByIdx: map[uint32]*Relay{},
},
// A private RemoteList: SetRemote must never leak lane ports into the
// shared lighthouse-learned cache that base handshakes broadcast to.
remotes: NewRemoteList(base.vpnAddrs, nil),
sockIdx: i,
laneIndex: uint16(i),
laneOwned: true,
parent: base,
}
hh := &HandshakeHostInfo{
hostinfo: hostinfo,
startTime: time.Now(),
laneTarget: target,
}
if cs := base.ConnectionState; cs != nil && cs.myCert != nil {
// Pin the lane to the base tunnel's negotiated cert version rather
// than re-running version selection.
hh.initiatingVersionOverride = cs.myCert.Version()
}
// Build stage 0 eagerly: allocateIndex registers hh in hm.indexes so
// continueHandshake can find it, and gives us the key for the lane timer.
hh.Lock()
ok := hm.buildStage0Packet(hh)
hh.Unlock()
if !ok {
ls.noteLaneFailure(i)
return
}
hostinfo.logger(hm.l).Info("Lane handshake started",
"laneIndex", i,
"udpAddr", target,
"initiatorIndex", hostinfo.localIndexId,
)
hm.metricInitiated.Inc(1)
hm.OutboundLaneTimer.Add(hostinfo.localIndexId, hm.config.tryInterval)
hm.handleOutboundLane(hostinfo.localIndexId)
}
func (hm *HandshakeManager) NextOutboundLaneTimerTick(now time.Time) {
hm.OutboundLaneTimer.Advance(now)
for {
idx, has := hm.OutboundLaneTimer.Purge()
if !has {
break
}
hm.handleOutboundLane(idx)
}
}
// handleOutboundLane is handleOutbound for lane handshakes: single pinned
// target, egress via the lane's own socket, no lighthouse and no relays.
func (hm *HandshakeManager) handleOutboundLane(localIndex uint32) {
hh := hm.queryIndex(localIndex)
if hh == nil || !hh.hostinfo.isLane() {
return
}
hh.Lock()
defer hh.Unlock()
hostinfo := hh.hostinfo
if hh.counter >= hm.config.retries {
hostinfo.logger(hm.l).Info("Lane handshake timed out",
"laneIndex", hostinfo.laneIndex,
"udpAddr", hh.laneTarget,
"initiatorIndex", hostinfo.localIndexId,
"durationNs", time.Since(hh.startTime).Nanoseconds(),
)
hm.metricTimedOut.Inc(1)
hm.DeleteHostInfo(hostinfo)
hostinfo.parent.lanes.noteLaneFailure(int(hostinfo.laneIndex))
return
}
hh.counter++
stage0 := hostinfo.HandshakePacket[handshakePacketStage0]
hm.messageMetrics.Tx(header.Handshake, hh.machine.Subtype(), 1)
err := hm.f.writers[hostinfo.sockIdx].WriteTo(stage0, hh.laneTarget)
if err != nil {
hostinfo.logger(hm.l).Error("Failed to send lane handshake message",
"laneIndex", hostinfo.laneIndex,
"udpAddr", hh.laneTarget,
"initiatorIndex", hostinfo.localIndexId,
"error", err,
)
} else if hm.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(hm.l).Debug("Lane handshake message sent",
"laneIndex", hostinfo.laneIndex,
"udpAddr", hh.laneTarget,
"initiatorIndex", hostinfo.localIndexId,
)
}
hm.OutboundLaneTimer.Add(localIndex, hm.config.tryInterval*time.Duration(hh.counter))
}
// deletePendingHostInfo abandons a pending handshake. For lanes it also
// releases the base's slot claim with failure backoff so ensureLanes can
// retry later.
func (hm *HandshakeManager) deletePendingHostInfo(hostinfo *HostInfo) {
hm.DeleteHostInfo(hostinfo)
if hostinfo.isLane() {
hostinfo.parent.lanes.noteLaneFailure(int(hostinfo.laneIndex))
}
}
// completeLane moves a finished lane out of the pending map and registers it
// in the main hostmap's index maps (never Hosts). The caller publishes it to
// the base's txLanes/peerLanes afterwards.
func (hm *HandshakeManager) completeLane(hostinfo *HostInfo, f *Interface) {
hm.mainHostMap.Lock()
defer hm.mainHostMap.Unlock()
hm.Lock()
defer hm.Unlock()
existingRemoteIndex, found := hm.mainHostMap.RemoteIndexes[hostinfo.remoteIndexId]
if found && existingRemoteIndex != nil {
hostinfo.logger(hm.l).Info("New lane shadows existing host remoteIndex",
"collision", existingRemoteIndex.vpnAddrs,
)
}
hm.unlockedDeleteHostInfo(hostinfo)
hm.mainHostMap.unlockedAddLane(hostinfo, f)
}
// beginHandshake handles an incoming handshake packet that doesn't match any
// existing pending handshake. It creates a new responder Machine and processes
// the first message.
@@ -705,6 +940,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
v, cs.GetCredential,
hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) },
false, header.HandshakeIXPSK0,
hm.laneAdvert(0),
)
if err != nil {
f.l.Error("Failed to create handshake machine", "from", via, "error", err)
@@ -741,6 +977,13 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
return
}
// A nonzero lane tag makes this a lane handshake for an existing base
// tunnel rather than a (possibly duplicate) tunnel of its own.
if result.PeerLaneIndex > 0 {
hm.completeLaneResponder(via, packet, response, result, vpnAddrs)
return
}
hostinfo := &HostInfo{
ConnectionState: newConnectionStateFromResult(result),
localIndexId: result.LocalIndex,
@@ -785,6 +1028,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
hostinfo.SetRemote(via.UdpAddr)
}
hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
hm.maybeAllocLaneState(hostinfo, result)
existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
if err != nil {
@@ -794,6 +1038,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
hm.sendHandshakeResponse(via, response, hostinfo, false)
hostinfo.remotes.RefreshFromHandshake(vpnAddrs)
hm.EnsureLanes(hostinfo)
// Don't wait for UpdateWorker
if f.lightHouse.IsAnyLighthouseAddr(vpnAddrs) {
@@ -801,6 +1046,139 @@ 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)
}
hostinfo := &HostInfo{
ConnectionState: newConnectionStateFromResult(result),
localIndexId: result.LocalIndex,
remoteIndexId: result.RemoteIndex,
vpnAddrs: vpnAddrs,
HandshakePacket: make(map[uint8][]byte, 0),
lastHandshakeTime: result.HandshakeTime,
relayState: RelayState{
relays: nil,
relayForByAddr: map[netip.Addr]*Relay{},
relayForByIdx: map[uint32]*Relay{},
},
// Private RemoteList: lane roaming must not touch the shared
// lighthouse-learned cache.
remotes: NewRemoteList(vpnAddrs, nil),
sockIdx: via.SockIdx,
laneIndex: uint16(laneIndex),
laneOwned: false,
parent: base,
}
// packet aliases the listener's incoming buffer, so this copy must stay.
hostinfo.HandshakePacket[handshakePacketStage0] = make([]byte, len(stage0))
copy(hostinfo.HandshakePacket[handshakePacketStage0], stage0)
if response != nil {
hostinfo.HandshakePacket[handshakePacketStage2] = response
}
hostinfo.SetRemote(via.UdpAddr)
hostinfo.buildNetworks(f.myVpnNetworksTable, result.RemoteCert.Certificate)
// Index-collision checks, mirroring CheckAndComplete minus the Hosts
// dedup (lanes are keyed by (base, laneIndex), not by address).
hm.mainHostMap.Lock()
hm.Lock()
if existingIndex, found := hm.mainHostMap.Indexes[hostinfo.localIndexId]; found && existingIndex != hostinfo {
hm.Unlock()
hm.mainHostMap.Unlock()
hostinfo.logger(hm.l).Error("Failed to add lane due to localIndex collision", "laneIndex", laneIndex)
return
}
if existingPendingIndex, found := hm.indexes[hostinfo.localIndexId]; found && existingPendingIndex.hostinfo != hostinfo {
hm.Unlock()
hm.mainHostMap.Unlock()
hostinfo.logger(hm.l).Error("Failed to add lane due to pending localIndex collision", "laneIndex", laneIndex)
return
}
if existingRemoteIndex, found := hm.mainHostMap.RemoteIndexes[hostinfo.remoteIndexId]; found && existingRemoteIndex != nil {
hostinfo.logger(hm.l).Info("New lane shadows existing host remoteIndex",
"collision", existingRemoteIndex.vpnAddrs,
)
}
hm.mainHostMap.unlockedAddLane(hostinfo, f)
hm.Unlock()
hm.mainHostMap.Unlock()
ls.Lock()
ls.peerLanes = append(ls.peerLanes, hostinfo)
ls.Unlock()
hostinfo.logger(hm.l).Info("Lane handshake received",
"laneIndex", laneIndex,
"from", via,
"initiatorIndex", result.RemoteIndex,
"responderIndex", result.LocalIndex,
)
hm.sendHandshakeResponse(via, response, hostinfo, false)
}
// continueHandshake feeds an incoming packet to an existing pending handshake Machine.
func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostInfo, packet []byte) {
f := hm.f
@@ -821,6 +1199,14 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
}
hostinfo := hh.hostinfo
if hostinfo.isLane() && via.IsRelayed {
// Lane handshakes are direct-only; a relayed continuation is a stray
// or a protocol violation. Drop without failing the machine so the
// direct stage-2 can still land.
f.l.Debug("dropping relayed lane handshake continuation",
"vpnAddrs", hostinfo.vpnAddrs, "from", via)
return
}
if !via.IsRelayed {
if !f.lightHouse.GetRemoteAllowList().AllowAll(hostinfo.vpnAddrs, via.UdpAddr.Addr()) {
f.l.Debug("lighthouse.remote_allow_list denied incoming handshake",
@@ -833,7 +1219,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
if machine == nil {
f.l.Error("No handshake machine available for continuation",
"vpnAddrs", hostinfo.vpnAddrs, "from", via)
hm.DeleteHostInfo(hostinfo)
hm.deletePendingHostInfo(hostinfo)
return
}
@@ -843,7 +1229,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
if machine.Failed() {
f.l.Warn("Failed to process handshake packet, abandoning",
"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
hm.DeleteHostInfo(hostinfo)
hm.deletePendingHostInfo(hostinfo)
} else {
f.l.Debug("Failed to process handshake packet",
"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
@@ -866,7 +1252,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
if remoteCert == nil {
f.l.Error("Handshake completed without peer certificate",
"vpnAddrs", hostinfo.vpnAddrs, "from", via)
hm.DeleteHostInfo(hostinfo)
hm.deletePendingHostInfo(hostinfo)
return
}
@@ -900,7 +1286,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
"issuer", remoteCert.Certificate.Issuer(),
"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())},
)
hm.DeleteHostInfo(hostinfo)
hm.deletePendingHostInfo(hostinfo)
return
}
vpnAddrs[i] = network.Addr()
@@ -924,6 +1310,15 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())},
)
if hostinfo.isLane() {
// No restart dance for lanes: close what the peer completed,
// release the slot with backoff, and let ensureLanes retry.
hm.deletePendingHostInfo(hostinfo)
hostinfo.vpnAddrs = vpnAddrs
f.sendCloseTunnel(hostinfo)
return
}
hm.DeleteHostInfo(hostinfo)
hm.StartHandshake(hostinfo.vpnAddrs[0], func(newHH *HandshakeHostInfo) {
newHH.hostinfo.remotes = hostinfo.remotes
@@ -958,7 +1353,31 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
hostinfo.vpnAddrs = vpnAddrs
hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
if hostinfo.isLane() {
hm.completeLane(hostinfo, f)
// Publish to the data plane only after the lane is registered and its
// ConnectionState is fully populated; a routine that Loads non-nil
// must always see a usable tunnel.
ls := hostinfo.parent.lanes
i := int(hostinfo.laneIndex)
ls.Lock()
if i < len(ls.txPending) {
ls.txPending[i] = false
ls.txFails[i] = 0
}
ls.Unlock()
if i < len(ls.txLanes) {
ls.txLanes[i].Store(hostinfo)
}
f.metricHandshakes.Update(duration)
return
}
hm.maybeAllocLaneState(hostinfo, result)
hm.Complete(hostinfo, f)
hm.EnsureLanes(hostinfo)
if len(hh.packetStore) > 0 {
if f.l.Enabled(context.Background(), slog.LevelDebug) {
@@ -1064,7 +1483,10 @@ func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hos
if !via.IsRelayed {
fields := append(logFields, "from", via)
err := f.outside.WriteTo(msg, via.UdpAddr)
// Reply from the socket the handshake arrived on so the initiator sees
// the source port it targeted. Identical to f.outside under vanilla
// config (all writers share one port); required for multiport lanes.
err := f.writers[via.SockIdx].WriteTo(msg, via.UdpAddr)
if err != nil {
f.l.Error("Failed to send handshake message", append(fields, "error", err)...)
} else {
+203
View File
@@ -282,6 +282,135 @@ type HostInfo struct {
// This value will be behind against actual tunnel utilization in the hot path.
// This should only be used by the ConnectionManagers ticker routine.
lastUsed time.Time
// sockIdx is the index into Interface.writers of the socket every packet
// on this tunnel egresses from (and, for lanes, arrives on). 0 for base
// and vanilla tunnels — the zero value preserves stock behavior.
sockIdx int
// laneIndex is the owner's lane number for a lane tunnel; 0 for base.
laneIndex uint16
// laneOwned is true when we initiated this lane (it carries our TX data).
laneOwned bool
// parent points at the base tunnel a lane hangs off of; nil for base and
// vanilla tunnels. Set before the lane is registered in hostmap.Indexes.
parent *HostInfo
// lanes is allocated on a base tunnel when multiport is enabled and the
// peer advertised lane support; nil otherwise.
lanes *laneState
}
// isLane reports whether this HostInfo is a lane tunnel rather than a base
// (or vanilla) tunnel.
func (i *HostInfo) isLane() bool {
return i.parent != nil
}
// laneState hangs off a base HostInfo and tracks the multiport lane tunnels
// associated with it. txLanes is read lock-free on the TX hot path; the Mutex
// guards everything else.
type laneState struct {
sync.Mutex
// peerPortCount/peerBasePort are the peer's advert from the base
// handshake; lane i targets peerBasePort + (i % peerPortCount).
peerPortCount uint16
peerBasePort uint16
// txLanes[i] is our established, initiator-owned lane for routine i, or
// nil. Index 0 is always nil — the base tunnel is lane 0. A pointer is
// only Stored once the lane's ConnectionState is fully populated, so a
// data-plane routine that Loads non-nil always sees a usable tunnel.
txLanes []atomic.Pointer[HostInfo]
// Under Mutex: per-slot handshake-in-flight flag, consecutive failure
// count, and earliest next attempt, driving ensureLanes' backoff.
txPending []bool
txFails []uint8
txRetryAt []time.Time
// Under Mutex: responder-side records of peer-owned lanes, capped by
// same-laneIndex replacement.
peerLanes []*HostInfo
}
func newLaneState(laneCount int, peerPortCount, peerBasePort uint16) *laneState {
return &laneState{
peerPortCount: peerPortCount,
peerBasePort: peerBasePort,
txLanes: make([]atomic.Pointer[HostInfo], laneCount),
txPending: make([]bool, laneCount),
txFails: make([]uint8, laneCount),
txRetryAt: make([]time.Time, laneCount),
}
}
const (
laneRetryBase = 5 * time.Second
laneRetryMax = 60 * time.Second
)
// noteLaneFailure marks lane slot i as empty and pushes the next attempt out
// with exponential backoff. Called when an owned lane dies or its handshake
// times out.
func (ls *laneState) noteLaneFailure(i int) {
if i < 0 || i >= len(ls.txPending) {
return
}
ls.Lock()
ls.txPending[i] = false
if ls.txFails[i] < 200 { // just avoid wrapping; the delay caps far earlier
ls.txFails[i]++
}
d := laneRetryBase << min(ls.txFails[i], 4)
if d > laneRetryMax {
d = laneRetryMax
}
ls.txRetryAt[i] = time.Now().Add(d)
ls.Unlock()
}
// noteOwnedLaneDeath detaches an established owned lane from its slot
// (identity-checked, so a raced re-establishment is never clobbered) and
// applies failure backoff.
func (ls *laneState) noteOwnedLaneDeath(lane *HostInfo) {
i := int(lane.laneIndex)
if i >= len(ls.txLanes) {
return
}
ls.txLanes[i].CompareAndSwap(lane, nil)
ls.noteLaneFailure(i)
}
// removePeerLane drops a responder-side lane record by identity.
func (ls *laneState) removePeerLane(lane *HostInfo) {
ls.Lock()
for n, h := range ls.peerLanes {
if h == lane {
ls.peerLanes = append(ls.peerLanes[:n], ls.peerLanes[n+1:]...)
break
}
}
ls.Unlock()
}
// snapshotLanes returns every lane hostinfo currently attached, used by the
// base-delete cascade. Taken under the lock and returned as a copy so the
// caller can delete without holding it.
func (ls *laneState) snapshotLanes() []*HostInfo {
ls.Lock()
defer ls.Unlock()
out := make([]*HostInfo, 0, len(ls.txLanes)+len(ls.peerLanes))
for n := range ls.txLanes {
if h := ls.txLanes[n].Load(); h != nil {
out = append(out, h)
}
}
out = append(out, ls.peerLanes...)
return out
}
type ViaSender struct {
@@ -289,6 +418,11 @@ type ViaSender struct {
relayHI *HostInfo // relayHI is the host info object of the relay
relay *Relay // relay contains the rest of the relay information, including the PeerIP of the host trying to communicate with us.
IsRelayed bool // IsRelayed is true if the packet was sent through a relay
// SockIdx is the local socket (Interface.writers index) the packet
// arrived on. Replies that must originate from the same 4-tuple egress
// f.writers[SockIdx].
SockIdx int
}
func (v ViaSender) String() string {
@@ -450,6 +584,12 @@ func (hm *HostMap) MakePrimary(hostinfo *HostInfo) {
// unlockedMakePrimary reports whether hostinfo is (now) the primary for each of its addresses,
// false only when it is no longer in the hostmap at all.
func (hm *HostMap) unlockedMakePrimary(hostinfo *HostInfo) bool {
// A lane must never become a Hosts primary: it would start carrying all
// traffic for the peer and become a relay candidate.
if hostinfo.isLane() {
return false
}
// A hostinfo that is no longer in the hostmap must not be re-inserted here. Callers can race
// tunnel teardown, deciding to promote under the read lock and only taking the write lock
// after a delete fully unlinked the hostinfo (connection manager swapPrimary, AddRelay). Every
@@ -478,6 +618,19 @@ func (hm *HostMap) unlockedMakePrimary(hostinfo *HostInfo) bool {
// any tunnel to the peer), which the caller uses to decide whether to clear learned lighthouse
// state and disestablish relays.
func (hm *HostMap) unlockedDeleteHostInfo(hostinfo *HostInfo) bool {
if hostinfo.isLane() {
return hm.unlockedDeleteLane(hostinfo)
}
// A dying base takes its lanes with it. The peer converges symmetrically
// when it processes the base's CloseTunnel, so lanes need no signaling of
// their own. Depth-1 recursion: lanes have no children.
if hostinfo.lanes != nil {
for _, lane := range hostinfo.lanes.snapshotLanes() {
hm.unlockedDeleteLane(lane)
}
}
// Remove this hostinfo from each of its address lists. The lists are independent, so a
// sibling is never promoted to an address it does not own and no other list is touched.
final := true
@@ -543,6 +696,35 @@ func (hm *HostMap) unlockedDeleteHostInfo(hostinfo *HostInfo) bool {
return final
}
// unlockedDeleteLane removes a lane tunnel from the index maps and detaches it
// from its base. Lanes never live in Hosts and their death never means "no
// tunnel to the peer", so the return is always false (the lighthouse cache and
// relays stay untouched). Idempotent: every step is identity-checked.
func (hm *HostMap) unlockedDeleteLane(lane *HostInfo) bool {
if ls := lane.parent.lanes; ls != nil {
if lane.laneOwned {
ls.noteOwnedLaneDeath(lane)
} else {
ls.removePeerLane(lane)
}
}
if hostinfo2, ok := hm.RemoteIndexes[lane.remoteIndexId]; ok && hostinfo2 == lane {
delete(hm.RemoteIndexes, lane.remoteIndexId)
}
if hostinfo2, ok := hm.Indexes[lane.localIndexId]; ok && hostinfo2 == lane {
delete(hm.Indexes, lane.localIndexId)
}
if hm.l.Enabled(context.Background(), slog.LevelDebug) {
hm.l.Debug("Hostmap lane deleted",
"hostMap", m{"vpnAddrs": lane.vpnAddrs, "laneIndex": lane.laneIndex,
"indexNumber": lane.localIndexId, "remoteIndexNumber": lane.remoteIndexId},
)
}
return false
}
func (hm *HostMap) QueryIndex(index uint32) *HostInfo {
hm.RLock()
if h, ok := hm.Indexes[index]; ok {
@@ -671,6 +853,27 @@ func (hm *HostMap) unlockedAddHostInfo(hostinfo *HostInfo, f *Interface) {
}
}
// unlockedAddLane registers a lane tunnel in the index maps (RX demux and
// recv_error need it there) without touching Hosts: lanes are never primary,
// never dns-visible, and never subject to the MaxHostInfosPerVpnIp eviction.
// The connection manager still tracks it for keepalive/death.
func (hm *HostMap) unlockedAddLane(lane *HostInfo, f *Interface) {
hm.Indexes[lane.localIndexId] = lane
hm.RemoteIndexes[lane.remoteIndexId] = lane
lane.out.Store(true)
if f.connectionManager != nil { // f.connectionManager is only nil in some unit tests
f.connectionManager.trafficTimer.Add(lane.localIndexId, f.connectionManager.checkInterval)
}
if hm.l.Enabled(context.Background(), slog.LevelDebug) {
hm.l.Debug("Hostmap lane added",
"hostMap", m{"vpnAddrs": lane.vpnAddrs, "laneIndex": lane.laneIndex,
"indexNumber": lane.localIndexId, "remoteIndexNumber": lane.remoteIndexId},
)
}
}
func (hm *HostMap) unlockedInnerAddHostInfo(vpnAddr netip.Addr, hostinfo *HostInfo, f *Interface) {
existing, ok := hm.Hosts[vpnAddr]
if !ok {
+47 -12
View File
@@ -10,12 +10,11 @@ import (
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/iputil"
"github.com/slackhq/nebula/noiseutil"
"github.com/slackhq/nebula/overlay/batch"
"github.com/slackhq/nebula/overlay/tio"
"github.com/slackhq/nebula/routing"
)
func (f *Interface) consumeInsidePacket(pkt tio.Packet, fwPacket *firewall.Packet, nb []byte, sendBatch batch.TxBatcher, rejectBuf []byte, q int, localCache firewall.ConntrackCache) {
func (f *Interface) consumeInsidePacket(pkt tio.Packet, fwPacket *firewall.Packet, nb []byte, tx *txQueue, rejectBuf []byte, q int, localCache firewall.ConntrackCache) {
// borrowed: pkt.Bytes is owned by the originating tio.Queue and is
// only valid until the next Read on that queue. Every consumer below
// (parse, self-forward, handshake cache, sendInsideMessage) reads it
@@ -107,7 +106,7 @@ func (f *Interface) consumeInsidePacket(pkt tio.Packet, fwPacket *firewall.Packe
dropReason := f.firewall.Drop(*fwPacket, false, hostinfo, f.pki.GetCAPool(), localCache)
if dropReason == nil {
f.sendInsideMessage(hostinfo, pkt, nb, sendBatch, rejectBuf, q)
f.sendInsideMessage(hostinfo, pkt, nb, tx, q)
} else {
f.rejectInside(packet, rejectBuf, q)
if f.l.Enabled(context.Background(), slog.LevelDebug) {
@@ -153,12 +152,18 @@ func (f *Interface) sendInsideEncrypt(hostinfo *HostInfo, ci *ConnectionState, s
// scratch arena: SegmentSuperpacket builds each segment's plaintext in
// segScratch[:segLen] in turn, and we encrypt directly into a fresh
// SendBatch slot.
func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []byte, sendBatch batch.TxBatcher, rejectBuf []byte, q int) {
//
// hostinfo is always the base tunnel (the hostmap resolves by vpn address);
// when routine q has an established lane to this peer, the direct path swaps
// to the lane's session and socket below. Relay and base traffic stays on
// tx.base (socket 0).
func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []byte, tx *txQueue, q int) {
ci := hostinfo.ConnectionState
if ci.eKey == nil {
return
}
sendBatch := tx.base
remote := hostinfo.GetRemote()
ecnEnabled := f.ecnEnabled.Load()
if hostinfo.lastRebindCount != f.rebindCount {
@@ -224,6 +229,21 @@ func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []b
return
}
// Direct path: prefer this routine's lane tunnel when it is established.
// The pointer is only published once the lane's ConnectionState is fully
// populated, so a non-nil Load is always usable. On lane death the slot
// CAS-clears and traffic falls back to the base tunnel instantly.
if ls := hostinfo.lanes; ls != nil && q < len(ls.txLanes) {
if lane := ls.txLanes[q].Load(); lane != nil {
if lci := lane.ConnectionState; lci != nil && lci.eKey != nil {
hostinfo = lane
ci = lci
remote = lane.GetRemote()
sendBatch = tx.lane
}
}
}
err := tio.SegmentSuperpacket(pkt, func(seg []byte) error {
// header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305)
scratch := sendBatch.Reserve(header.Len + len(seg) + 16)
@@ -279,7 +299,7 @@ func (f *Interface) rejectInside(packet []byte, out []byte, q int) {
}
}
func (f *Interface) rejectOutside(packet []byte, ci *ConnectionState, hostinfo *HostInfo, nb, rejectBuf []byte, q int) {
func (f *Interface) rejectOutside(packet []byte, ci *ConnectionState, hostinfo *HostInfo, nb, rejectBuf []byte) {
if !f.firewall.InboundSendReject {
return
}
@@ -302,7 +322,7 @@ func (f *Interface) rejectOutside(packet []byte, ci *ConnectionState, hostinfo *
return
}
f.sendNoMetrics(header.Message, 0, ci, hostinfo, netip.AddrPort{}, out, nb, encryptBuf, q)
f.sendNoMetrics(header.Message, 0, ci, hostinfo, netip.AddrPort{}, out, nb, encryptBuf)
}
// Handshake will attempt to initiate a tunnel with the provided vpn address. This is a no-op if the tunnel is already established or being established
@@ -415,7 +435,7 @@ func (f *Interface) sendMessageNow(t header.MessageType, st header.MessageSubTyp
return
}
f.sendNoMetrics(header.Message, st, hostinfo.ConnectionState, hostinfo, netip.AddrPort{}, p, nb, out, 0)
f.sendNoMetrics(header.Message, st, hostinfo.ConnectionState, hostinfo, netip.AddrPort{}, p, nb, out)
}
// SendMessageToVpnAddr handles real addr:port lookup and sends to the current best known address for vpnAddr.
@@ -447,12 +467,12 @@ func (f *Interface) SendMessageToHostInfo(t header.MessageType, st header.Messag
func (f *Interface) send(t header.MessageType, st header.MessageSubType, ci *ConnectionState, hostinfo *HostInfo, p, nb, out []byte) {
f.messageMetrics.Tx(t, st, 1)
f.sendNoMetrics(t, st, ci, hostinfo, netip.AddrPort{}, p, nb, out, 0)
f.sendNoMetrics(t, st, ci, hostinfo, netip.AddrPort{}, p, nb, out)
}
func (f *Interface) sendTo(t header.MessageType, st header.MessageSubType, ci *ConnectionState, hostinfo *HostInfo, remote netip.AddrPort, p, nb, out []byte) {
f.messageMetrics.Tx(t, st, 1)
f.sendNoMetrics(t, st, ci, hostinfo, remote, p, nb, out, 0)
f.sendNoMetrics(t, st, ci, hostinfo, remote, p, nb, out)
}
func (f *Interface) prepareSendVia(via *HostInfo,
@@ -530,16 +550,23 @@ func (f *Interface) SendVia(via *HostInfo,
return
}
err = f.writers[0].WriteTo(toSend, via.GetRemote())
// Relay carriers are base tunnels (sockIdx 0); indexing through the
// carrier keeps the invariant explicit.
err = f.writers[via.sockIdx].WriteTo(toSend, via.GetRemote())
if err != nil {
via.logger(f.l).Info("Failed to WriteTo in sendVia", "error", err)
}
}
func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType, ci *ConnectionState, hostinfo *HostInfo, remote netip.AddrPort, p, nb, out []byte, q int) {
func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType, ci *ConnectionState, hostinfo *HostInfo, remote netip.AddrPort, p, nb, out []byte) {
if ci.eKey == nil {
return
}
// Every packet on a tunnel egresses the tunnel's own socket. For base and
// vanilla tunnels sockIdx is 0 (stock behavior); for lanes it keeps
// keepalives, close packets and rejects on the lane's 4-tuple so the
// peer's spoof/roam checks accept them and the NAT entry stays warm.
q := hostinfo.sockIdx
useRelay := !remote.IsValid() && !hostinfo.GetRemote().IsValid()
fullOut := out
@@ -565,7 +592,8 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
// Query our LH if we haven't since the last time we've been rebound, this will cause the remote to punch against
// all our addrs and enable a faster roaming.
if t != header.CloseTunnel && hostinfo.lastRebindCount != f.rebindCount {
// Lanes skip this: the base tunnel issues the one query for the peer.
if t != header.CloseTunnel && !hostinfo.isLane() && hostinfo.lastRebindCount != f.rebindCount {
//NOTE: there is an update hole if a tunnel isn't used and exactly 256 rebinds occur before the tunnel is
// finally used again. This tunnel would eventually be torn down and recreated if this action didn't help.
f.lightHouse.QueryServer(hostinfo.vpnAddrs[0])
@@ -608,6 +636,13 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
)
}
} else {
if hostinfo.isLane() {
// A lane always has a valid remote (set from its own handshake);
// reaching here means the lane is broken, and lane ciphertext must
// never ride a relay (relays are base-tunnel-only).
hostinfo.logger(f.l).Error("Dropping lane packet with no valid remote")
return
}
// Try to send via a relay
for _, relayIP := range hostinfo.relayState.CopyRelayIps() {
relayHostInfo, relay, err := f.hostMap.QueryVpnAddrsRelayFor(hostinfo.vpnAddrs, relayIP)
+60 -14
View File
@@ -42,10 +42,13 @@ type InterfaceConfig struct {
DropLocalBroadcast bool
DropMulticast bool
routines int
MessageMetrics *MessageMetrics
version string
relayManager *relayManager
punchy *Punchy
// Multiport means writers[i] is bound to listen.port+i (not a shared
// SO_REUSEPORT port) and lane tunnels are negotiated with capable peers.
Multiport bool
MessageMetrics *MessageMetrics
version string
relayManager *relayManager
punchy *Punchy
tryPromoteEvery uint32
reQueryEvery uint32
@@ -86,6 +89,7 @@ type Interface struct {
dropLocalBroadcast bool
dropMulticast bool
routines int
multiport bool
disconnectInvalid atomic.Bool
closed atomic.Bool
// cpuAffinity, when non-empty, names the CPUs each TUN reader goroutine
@@ -222,6 +226,7 @@ func NewInterface(ctx context.Context, c *InterfaceConfig) (*Interface, error) {
dropLocalBroadcast: c.DropLocalBroadcast,
dropMulticast: c.DropMulticast,
routines: c.routines,
multiport: c.Multiport,
version: c.version,
writers: make([]udp.Conn, c.routines),
batchers: make([]batch.RxBatcher, c.routines),
@@ -277,7 +282,10 @@ func (f *Interface) activate() error {
"boringcrypto", boringEnabled(),
)
if f.routines > 1 && !f.outside.SupportsMultipleReaders() {
// Under multiport each socket has exactly one reader on its own port, so
// the shared-port multi-reader capability is irrelevant (and main.go
// already hard-errored on unsupported platforms).
if f.routines > 1 && !f.multiport && !f.outside.SupportsMultipleReaders() {
f.routines = 1
f.l.Warn("multiple udp readers are not supported on this platform, falling back to a single routine")
}
@@ -290,6 +298,11 @@ func (f *Interface) activate() error {
return err
}
if len(queues) < f.routines {
if f.multiport {
// The lane sockets are already bound one-per-routine; shrinking
// the routine count would leave bound ports with no reader.
return fmt.Errorf("multiport requires %d tun queues, device provided %d", f.routines, len(queues))
}
f.l.Warn("tun multiqueue is not supported on this platform, falling back to fewer routines",
"requested", f.routines, "opened", len(queues))
f.routines = len(queues)
@@ -372,7 +385,7 @@ func (f *Interface) listenOut(i int) {
scratch := make([]byte, mtu)
listener := func(fromUdpAddr netip.AddrPort, payload []byte, meta udp.RxMeta) {
f.readOutsidePackets(ViaSender{UdpAddr: fromUdpAddr}, scratch, payload, h, fwPacket, lhh, nb, i, ctCache.Get(), meta)
f.readOutsidePackets(ViaSender{UdpAddr: fromUdpAddr, SockIdx: i}, scratch, payload, h, fwPacket, lhh, nb, i, ctCache.Get(), meta)
}
flusher := func() {
@@ -394,6 +407,39 @@ func (f *Interface) listenOut(i int) {
f.l.Debug("underlay reader is done", "reader", i)
}
// txQueue is the per-routine TX state owned by one listenIn goroutine. lane
// is bound to the routine's own socket and carries lane-tunnel data; base is
// bound to socket 0 and carries base-tunnel and relay data, which must keep
// the base source port (a vanilla peer would otherwise see per-routine source
// ports and roam-thrash). The two alias when multiport is off or on routine 0.
// Concurrent sendmmsg on the shared socket-0 fd is safe: a flow is pinned to
// one routine by tun steering, so per-flow wire order still holds.
type txQueue struct {
lane *batch.SendBatch
base *batch.SendBatch
}
func (tx *txQueue) full() bool {
if tx.lane.Len() >= batch.SendBatchCap {
return true
}
return tx.base != tx.lane && tx.base.Len() >= batch.SendBatchCap
}
// flush drains base before lane so that when a flow moves from the base
// tunnel onto a freshly established lane mid-window, its packets still leave
// this host in encryption order.
func (tx *txQueue) flush(l *slog.Logger, i int) {
if tx.base != tx.lane {
if err := tx.base.Flush(); err != nil {
l.Error("Failed to write outgoing batch", "error", err, "writer", 0)
}
}
if err := tx.lane.Flush(); err != nil {
l.Error("Failed to write outgoing batch", "error", err, "writer", i)
}
}
func (f *Interface) listenIn(queue tio.Queue, i int) {
// Pinning this thread (and goroutine) to a single CPU keeps every sendmmsg from this goroutine going through the
// same TX ring on the nic, so the wire sees per-flow order. Skip entirely when tun.pin_threads is false.
@@ -419,6 +465,10 @@ func (f *Interface) listenIn(queue tio.Queue, i int) {
rejectBuf := make([]byte, mtu)
arenaSize := batch.SendBatchCap * (udp.MTU + 32)
sb := batch.NewSendBatch(f.writers[i], batch.SendBatchCap, arenaSize)
tx := &txQueue{lane: sb, base: sb}
if f.multiport && i != 0 {
tx.base = batch.NewSendBatch(f.writers[0], batch.SendBatchCap, arenaSize)
}
fwPacket := &firewall.Packet{}
nb := make([]byte, 12, 12)
@@ -436,19 +486,15 @@ func (f *Interface) listenIn(queue tio.Queue, i int) {
}
for _, pkt := range pkts {
f.consumeInsidePacket(pkt, fwPacket, nb, sb, rejectBuf, i, conntrackCache.Get())
f.consumeInsidePacket(pkt, fwPacket, nb, tx, rejectBuf, i, conntrackCache.Get())
// Flush incrementally once a full sendmmsg batch has
// accumulated so the first packets of a deep read drain
// hit the wire while the rest are still being encrypted.
if sb.Len() >= batch.SendBatchCap {
if err := sb.Flush(); err != nil {
f.l.Error("Failed to write outgoing batch", "error", err, "writer", i)
}
if tx.full() {
tx.flush(f.l, i)
}
}
if err := sb.Flush(); err != nil {
f.l.Error("Failed to write outgoing batch", "error", err, "writer", i)
}
tx.flush(f.l, i)
}
f.l.Debug("overlay reader is done", "reader", i)
+409
View File
@@ -0,0 +1,409 @@
package nebula
import (
"net/netip"
"testing"
"time"
"github.com/gaissmai/bart"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/overlay/batch"
"github.com/slackhq/nebula/overlay/overlaytest"
"github.com/slackhq/nebula/overlay/tio"
"github.com/slackhq/nebula/test"
"github.com/slackhq/nebula/udp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestBaseHostInfo(vpnIp netip.Addr, localIdx, remoteIdx uint32, laneCount int) *HostInfo {
base := &HostInfo{
vpnAddrs: []netip.Addr{vpnIp},
localIndexId: localIdx,
remoteIndexId: remoteIdx,
remotes: NewRemoteList([]netip.Addr{vpnIp}, nil),
HandshakePacket: map[uint8][]byte{},
}
base.SetRemote(netip.MustParseAddrPort("192.0.2.1:4242"))
base.lanes = newLaneState(laneCount, uint16(laneCount), 4242)
return base
}
func newTestLaneHostInfo(base *HostInfo, laneIndex uint16, localIdx, remoteIdx uint32, owned bool) *HostInfo {
lane := &HostInfo{
vpnAddrs: base.vpnAddrs,
localIndexId: localIdx,
remoteIndexId: remoteIdx,
remotes: NewRemoteList(base.vpnAddrs, nil),
HandshakePacket: map[uint8][]byte{},
sockIdx: int(laneIndex),
laneIndex: laneIndex,
laneOwned: owned,
parent: base,
}
lane.SetRemote(netip.MustParseAddrPort("192.0.2.1:4243"))
return lane
}
func TestLaneHostmapLifecycle(t *testing.T) {
l := test.NewLogger()
hostMap := newHostMap(l)
ifce := &Interface{l: l} // connectionManager nil is tolerated by unlockedAddLane
vpnIp := netip.MustParseAddr("172.1.1.2")
base := newTestBaseHostInfo(vpnIp, 100, 200, 4)
hostMap.Lock()
hostMap.unlockedAddHostInfo(base, ifce)
hostMap.Unlock()
lane := newTestLaneHostInfo(base, 1, 101, 201, true)
hostMap.Lock()
hostMap.unlockedAddLane(lane, ifce)
hostMap.Unlock()
base.lanes.txLanes[1].Store(lane)
// The lane is reachable by index (RX demux, recv_error) but never a Hosts primary.
assert.Equal(t, lane, hostMap.QueryIndex(101))
assert.Equal(t, lane, hostMap.QueryReverseIndex(201))
assert.Equal(t, base, hostMap.Hosts[vpnIp])
// A lane can never be promoted to primary.
hostMap.Lock()
assert.False(t, hostMap.unlockedMakePrimary(lane))
hostMap.Unlock()
assert.Equal(t, base, hostMap.Hosts[vpnIp])
// Deleting the lane clears only its slot, applies backoff, and never
// reports "no more tunnels to peer" (final).
final := hostMap.DeleteHostInfo(lane)
assert.False(t, final)
assert.Nil(t, hostMap.QueryIndex(101))
assert.Equal(t, base, hostMap.Hosts[vpnIp])
assert.Nil(t, base.lanes.txLanes[1].Load())
base.lanes.Lock()
assert.Equal(t, uint8(1), base.lanes.txFails[1])
assert.False(t, base.lanes.txPending[1])
assert.True(t, base.lanes.txRetryAt[1].After(time.Now()))
base.lanes.Unlock()
// Idempotent: deleting again must not bump the backoff further.
hostMap.DeleteHostInfo(lane)
base.lanes.Lock()
assert.Equal(t, uint8(2), base.lanes.txFails[1]) // noteLaneFailure still runs, but slot CAS is a no-op
base.lanes.Unlock()
}
func TestLaneHostmapCascadeDelete(t *testing.T) {
l := test.NewLogger()
hostMap := newHostMap(l)
ifce := &Interface{l: l}
vpnIp := netip.MustParseAddr("172.1.1.3")
base := newTestBaseHostInfo(vpnIp, 300, 400, 4)
hostMap.Lock()
hostMap.unlockedAddHostInfo(base, ifce)
hostMap.Unlock()
owned := newTestLaneHostInfo(base, 1, 301, 401, true)
peer := newTestLaneHostInfo(base, 2, 302, 402, false)
hostMap.Lock()
hostMap.unlockedAddLane(owned, ifce)
hostMap.unlockedAddLane(peer, ifce)
hostMap.Unlock()
base.lanes.txLanes[1].Store(owned)
base.lanes.Lock()
base.lanes.peerLanes = append(base.lanes.peerLanes, peer)
base.lanes.Unlock()
// Deleting the base takes the whole lane family with it.
final := hostMap.DeleteHostInfo(base)
assert.True(t, final)
assert.Nil(t, hostMap.QueryIndex(300))
assert.Nil(t, hostMap.QueryIndex(301))
assert.Nil(t, hostMap.QueryIndex(302))
assert.Nil(t, hostMap.Hosts[vpnIp])
}
// Regression: deleting a hostinfo whose pending entry is NOT the one recorded
// in vpnIps (e.g. a lane, whose vpnAddrs alias the base's) must not evict a
// concurrently pending base handshake for the same address.
func TestHandshakeManagerVpnIpsIdentityDelete(t *testing.T) {
l := test.NewLogger()
hostMap := newHostMap(l)
lh := newTestLighthouse()
hm := NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig)
vpnIp := netip.MustParseAddr("172.1.1.4")
pendingBase := hm.StartHandshake(vpnIp, nil)
require.NotNil(t, pendingBase)
other := &HostInfo{vpnAddrs: []netip.Addr{vpnIp}, localIndexId: 999}
hm.DeleteHostInfo(other)
// The pending base handshake must still be tracked.
assert.Equal(t, pendingBase, hm.QueryVpnAddr(vpnIp))
// And deleting the actual owner still works.
hm.DeleteHostInfo(pendingBase)
assert.Nil(t, hm.QueryVpnAddr(vpnIp))
}
func newLaneTestConnectionManager(hostMap *HostMap) (*connectionManager, *Interface) {
l := test.NewLogger()
lh := newTestLighthouse()
cs := &CertState{
initiatingVersion: cert.Version1,
privateKey: []byte{},
v1Cert: &dummyCert{version: cert.Version1},
v1Credential: nil,
}
ifce := &Interface{
hostMap: hostMap,
inside: &overlaytest.NoopTun{},
outside: &udp.NoopConn{},
firewall: &Firewall{},
lightHouse: lh,
pki: &PKI{},
handshakeManager: NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig),
myVpnNetworksTable: new(bart.Lite),
l: l,
}
ifce.pki.cs.Store(cs)
conf := config.NewC(test.NewLogger())
punchy := NewPunchyFromConfig(test.NewLogger(), conf, nil)
cm := newConnectionManagerFromConfig(test.NewLogger(), conf, hostMap, punchy)
cm.intf = ifce
ifce.connectionManager = cm
ifce.handshakeManager.f = ifce
return cm, ifce
}
func TestLaneTrafficDecision(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
cm, ifce := newLaneTestConnectionManager(hostMap)
vpnIp := netip.MustParseAddr("172.1.1.5")
base := newTestBaseHostInfo(vpnIp, 500, 600, 4)
base.ConnectionState = &ConnectionState{}
hostMap.Lock()
hostMap.unlockedAddHostInfo(base, ifce)
hostMap.Unlock()
lane := newTestLaneHostInfo(base, 1, 501, 601, true)
lane.ConnectionState = &ConnectionState{}
hostMap.Lock()
hostMap.unlockedAddLane(lane, ifce)
hostMap.Unlock()
base.lanes.txLanes[1].Store(lane)
now := time.Now()
// A lane with inbound traffic is alive and never swaps primary or
// migrates relays.
lane.in.Store(true)
decision, resolved, _ := cm.makeTrafficDecision(lane.localIndexId, now)
assert.Equal(t, doNothing, decision)
assert.Equal(t, lane, resolved)
assert.False(t, lane.pendingDeletion.Load())
// An idle lane gets an active keepalive test...
decision, _, _ = cm.makeTrafficDecision(lane.localIndexId, now)
assert.Equal(t, sendTestPacket, decision)
assert.True(t, lane.pendingDeletion.Load())
// ...and is declared dead when the test goes unanswered.
decision, _, _ = cm.makeTrafficDecision(lane.localIndexId, now)
assert.Equal(t, deleteTunnel, decision)
}
func TestBaseInactiveConsidersLanes(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
cm, _ := newLaneTestConnectionManager(hostMap)
cm.dropInactive.Store(true)
cm.inactivityTimeout.Store(int64(10 * time.Minute))
now := time.Now()
vpnIp := netip.MustParseAddr("172.1.1.6")
base := newTestBaseHostInfo(vpnIp, 700, 800, 4)
base.lastUsed = now.Add(-time.Hour)
// Base alone: inactive.
_, inactive := cm.isInactive(base, now)
assert.True(t, inactive)
// A recently used lane keeps the base alive.
lane := newTestLaneHostInfo(base, 1, 701, 801, true)
lane.lastUsed = now.Add(-time.Minute)
base.lanes.txLanes[1].Store(lane)
_, inactive = cm.isInactive(base, now)
assert.False(t, inactive)
// Peer-owned lanes count too.
base.lanes.txLanes[1].Store(nil)
base.lanes.Lock()
base.lanes.peerLanes = append(base.lanes.peerLanes, lane)
base.lanes.Unlock()
_, inactive = cm.isInactive(base, now)
assert.False(t, inactive)
}
// recordingBatchWriter satisfies batch's writer interface and records what
// was flushed to it.
type recordingBatchWriter struct {
bufs [][]byte
dsts []netip.AddrPort
}
func (w *recordingBatchWriter) WriteBatch(bufs [][]byte, addrs []netip.AddrPort, outerECNs []byte) error {
for i := range bufs {
w.bufs = append(w.bufs, append([]byte(nil), bufs[i]...))
w.dsts = append(w.dsts, addrs[i])
}
return nil
}
func TestSendInsideMessageLaneSwap(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
cm, ifce := newLaneTestConnectionManager(hostMap)
_ = cm
vpnIp := netip.MustParseAddr("172.1.1.7")
base := newTestBaseHostInfo(vpnIp, 900, 1000, 4)
lane := newTestLaneHostInfo(base, 1, 901, 1001, true)
// Real cipher states from a real handshake so encryption works.
baseInit, _ := runTestHandshake(t)
laneInit, _ := runTestHandshake(t)
base.ConnectionState = newConnectionStateFromResult(baseInit)
lane.ConnectionState = newConnectionStateFromResult(laneInit)
baseWriter := &recordingBatchWriter{}
laneWriter := &recordingBatchWriter{}
tx := &txQueue{
base: batch.NewSendBatch(baseWriter, batch.SendBatchCap, 1<<16),
lane: batch.NewSendBatch(laneWriter, batch.SendBatchCap, 1<<16),
}
pkt := tio.Packet{Bytes: []byte{0x45, 0, 0, 4, 1, 2, 3, 4}}
nb := make([]byte, 12)
// With the lane published, routine 1's traffic uses the lane session and
// the lane batch.
base.lanes.txLanes[1].Store(lane)
ifce.sendInsideMessage(base, pkt, nb, tx, 1)
tx.flush(ifce.l, 1)
require.Len(t, laneWriter.bufs, 1)
require.Empty(t, baseWriter.bufs)
assert.Equal(t, lane.GetRemote(), laneWriter.dsts[0])
h := &header.H{}
require.NoError(t, h.Parse(laneWriter.bufs[0]))
assert.Equal(t, lane.remoteIndexId, h.RemoteIndex)
// Routine 2 has no lane: base tunnel, base batch.
ifce.sendInsideMessage(base, pkt, nb, tx, 2)
tx.flush(ifce.l, 1)
require.Len(t, baseWriter.bufs, 1)
assert.Equal(t, base.GetRemote(), baseWriter.dsts[0])
require.NoError(t, h.Parse(baseWriter.bufs[0]))
assert.Equal(t, base.remoteIndexId, h.RemoteIndex)
// Lane death: slot cleared, instant fallback to base.
base.lanes.txLanes[1].Store(nil)
ifce.sendInsideMessage(base, pkt, nb, tx, 1)
tx.flush(ifce.l, 1)
require.Len(t, baseWriter.bufs, 2)
require.Len(t, laneWriter.bufs, 1)
}
func TestCompleteLaneResponder(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
_, ifce := newLaneTestConnectionManager(hostMap)
ifce.writers = []udp.Conn{&udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}, &udp.NoopConn{}}
ifce.messageMetrics = newMessageMetricsOnlyRecvError()
hm := ifce.handshakeManager
hm.config.laneCount = 4
hm.config.lanePortCount = 4
hm.config.laneBasePort = 4242
// A real handshake supplies usable keys and a peer cert.
_, respR := runTestHandshake(t)
respR.PeerLaneIndex = 2
respR.PeerPortCount = 4
respR.PeerBasePort = 5353
via := ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.9:5355"), SockIdx: 2}
packet := make([]byte, header.Len+8)
copy(packet[header.Len:], []byte("stage0!!"))
vpnAddrs := []netip.Addr{netip.MustParseAddr("172.1.1.9")}
// No base tunnel: the lane handshake is dropped, nothing registered.
hm.completeLaneResponder(via, packet, []byte("resp"), respR, vpnAddrs)
assert.Nil(t, hostMap.QueryIndex(respR.LocalIndex))
// With a live base the lane attaches to it.
base := newTestBaseHostInfo(vpnAddrs[0], 1300, 1400, 4)
base.ConnectionState = &ConnectionState{}
hostMap.Lock()
hostMap.unlockedAddHostInfo(base, ifce)
hostMap.Unlock()
hm.completeLaneResponder(via, packet, []byte("resp"), respR, vpnAddrs)
lane := hostMap.QueryIndex(respR.LocalIndex)
require.NotNil(t, lane)
assert.True(t, lane.isLane())
assert.False(t, lane.laneOwned)
assert.Equal(t, uint16(2), lane.laneIndex)
assert.Equal(t, 2, lane.sockIdx)
assert.Equal(t, via.UdpAddr, lane.GetRemote())
assert.Equal(t, base, hostMap.Hosts[vpnAddrs[0]], "lane must not displace the base as primary")
base.lanes.Lock()
assert.Len(t, base.lanes.peerLanes, 1)
base.lanes.Unlock()
// A byte-identical stage-0 retransmit resends the cached response and
// must not register a second lane.
hm.completeLaneResponder(via, packet, []byte("resp"), respR, vpnAddrs)
base.lanes.Lock()
assert.Len(t, base.lanes.peerLanes, 1)
base.lanes.Unlock()
// An out-of-range lane index is refused.
respR2 := *respR
respR2.PeerLaneIndex = 9
respR2.LocalIndex = respR.LocalIndex + 1
hm.completeLaneResponder(via, packet, []byte("resp"), &respR2, vpnAddrs)
assert.Nil(t, hostMap.QueryIndex(respR2.LocalIndex))
}
func TestEnsureLanesBackoffOnStage0Failure(t *testing.T) {
hostMap := newHostMap(test.NewLogger())
_, ifce := newLaneTestConnectionManager(hostMap)
// laneCount enables multiport in the manager; the dummy CertState has no
// credential, so stage-0 construction must fail and release the slot with
// backoff rather than leaving it claimed forever.
hm := ifce.handshakeManager
hm.config.laneCount = 4
hm.config.lanePortCount = 4
hm.config.laneBasePort = 4242
vpnIp := netip.MustParseAddr("172.1.1.8")
base := newTestBaseHostInfo(vpnIp, 1100, 1200, 4)
hm.EnsureLanes(base)
base.lanes.Lock()
defer base.lanes.Unlock()
for i := 1; i < 4; i++ {
assert.False(t, base.lanes.txPending[i], "slot %d still pending", i)
assert.Equal(t, uint8(1), base.lanes.txFails[i], "slot %d fails", i)
assert.True(t, base.lanes.txRetryAt[i].After(time.Now()), "slot %d retryAt", i)
}
}
+96 -16
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"math"
"net"
"net/netip"
"runtime/debug"
@@ -134,6 +135,22 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
udpConns := make([]udp.Conn, routines)
port := c.GetInt("listen.port", 0)
// Multiport lanes: bind `routines` consecutive UDP ports (listen.port+i)
// instead of SO_REUSEPORT sharing one, and negotiate one extra tunnel per
// routine with capable peers so each routine's traffic rides its own
// underlay 5-tuple. Defaults on, degrading gracefully when preconditions
// aren't met — managed deployments (dnclient) can't be hard-errored on
// config they don't control.
multiport := c.GetBool("multiport.enabled", true)
if multiport && routines < 2 {
l.Info("multiport disabled: requires routines > 1")
multiport = false
}
if multiport && port != 0 && port+routines-1 > math.MaxUint16 {
l.Warn("multiport disabled: would bind ports beyond 65535", "listen.port", port, "routines", routines)
multiport = false
}
// Callers get no handle to these until the Control is returned, release them on any error.
defer func() {
if reterr != nil {
@@ -163,25 +180,77 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
listenHost = ips[0].Unmap()
}
for i := 0; i < routines; i++ {
l.Info("listening", "addr", netip.AddrPortFrom(listenHost, uint16(port)))
udpServer, err := udp.NewListener(l, listenHost, port, routines > 1, c.GetInt("listen.batch", 64))
if err != nil {
return nil, util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
}
udpServer.ReloadConfig(c)
udpConns[i] = udpServer
// If port is dynamic, discover it before the next pass through the for loop
// This way all routines will use the same port correctly
if port == 0 {
uPort, err := udpServer.LocalAddr()
if err != nil {
return nil, util.NewContextualError("Failed to get listening port", nil, err)
// Every lane socket needs its own reader; a platform that can't run
// multiple readers would silently strand sockets 1..n-1 as blackholes.
// Probe capability before committing to per-port binds.
if multiport {
probe, err := udp.NewListener(l, listenHost, 0, false, 1)
if err == nil {
if !probe.SupportsMultipleReaders() {
l.Warn("multiport disabled: this platform does not support multiple udp readers")
multiport = false
}
port = int(uPort.Port())
_ = probe.Close()
}
}
// With a dynamic listen.port, multiport binds socket 0 dynamically and
// then claims the next routines-1 ports above it; if that range turns
// out to be partially occupied, re-roll with a fresh dynamic port.
dynamic := port == 0
var bindErr error
for attempt := 0; attempt < 6; attempt++ {
bindErr = nil
for i := 0; i < routines; i++ {
lPort := port
if multiport {
lPort = port + i
}
udpServer, err := udp.NewListener(l, listenHost, lPort, routines > 1 && !multiport, c.GetInt("listen.batch", 64))
if err != nil {
bindErr = util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
break
}
udpServer.ReloadConfig(c)
udpConns[i] = udpServer
// If port is dynamic, discover it before the next pass through the for loop
// This way all routines will use the same port correctly
if port == 0 {
uPort, err := udpServer.LocalAddr()
if err != nil {
return nil, util.NewContextualError("Failed to get listening port", nil, err)
}
port = int(uPort.Port())
if multiport && port+routines-1 > math.MaxUint16 {
bindErr = util.NewContextualError("multiport dynamic port too close to 65535", m{"port": port}, nil)
break
}
}
bound := port
if multiport {
bound = port + i
}
l.Info("listening", "addr", netip.AddrPortFrom(listenHost, uint16(bound)), "socket", i)
}
if bindErr == nil {
break
}
if !(multiport && dynamic) {
return nil, bindErr
}
for i := range udpConns {
if udpConns[i] != nil {
_ = udpConns[i].Close()
udpConns[i] = nil
}
}
port = 0
l.Debug("multiport dynamic port range collided, retrying", "attempt", attempt+1)
}
if bindErr != nil {
return nil, bindErr
}
}
hostMap := NewHostMapFromConfig(l, c)
@@ -206,6 +275,16 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
messageMetrics: messageMetrics,
}
if multiport {
lanes := c.GetInt("multiport.lanes", 0)
if lanes <= 0 || lanes > routines {
lanes = routines
}
handshakeConfig.laneCount = lanes
handshakeConfig.lanePortCount = uint16(routines)
handshakeConfig.laneBasePort = uint16(port)
}
handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)
lightHouse.handshakeTrigger = handshakeManager.trigger
@@ -230,6 +309,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
DropLocalBroadcast: c.GetBool("tun.drop_local_broadcast", false),
DropMulticast: c.GetBool("tun.drop_multicast", false),
routines: routines,
Multiport: multiport,
MessageMetrics: messageMetrics,
version: buildVersion,
relayManager: NewRelayManager(ctx, l, hostMap, c),
+10 -6
View File
@@ -101,7 +101,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, scratch []byte, packet []b
// recvError if necessary
if hostinfo == nil || hostinfo.ConnectionState == nil {
if !via.IsRelayed {
f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex)
f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex, q)
}
return
}
@@ -203,6 +203,7 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
relayHI: hostinfo,
relay: relay,
IsRelayed: true,
SockIdx: via.SockIdx,
}
f.readOutsidePackets(via, scratch, signedPayload, h, fwPacket, lhf, nb, q, localCache, meta)
case ForwardingType:
@@ -595,7 +596,7 @@ func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, out []byte, s
dropReason := f.firewall.Drop(*fwPacket, true, hostinfo, f.pki.GetCAPool(), localCache)
if dropReason != nil {
f.rejectOutside(out, hostinfo.ConnectionState, hostinfo, nb, scratch, q)
f.rejectOutside(out, hostinfo.ConnectionState, hostinfo, nb, scratch)
if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("dropping inbound packet",
"fwPacket", fwPacket,
@@ -611,17 +612,20 @@ func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, out []byte, s
}
}
func (f *Interface) maybeSendRecvError(endpoint netip.AddrPort, index uint32) {
func (f *Interface) maybeSendRecvError(endpoint netip.AddrPort, index uint32, q int) {
if f.sendRecvErrorConfig.ShouldRecvError(endpoint) {
f.sendRecvError(endpoint, index)
f.sendRecvError(endpoint, index, q)
}
}
func (f *Interface) sendRecvError(endpoint netip.AddrPort, index uint32) {
// sendRecvError replies from the socket the offending packet arrived on (q).
// A lane peer's spoof guard compares our source addr against the lane's
// remote, so a reply from the base port would be discarded.
func (f *Interface) sendRecvError(endpoint netip.AddrPort, index uint32, q int) {
f.messageMetrics.Tx(header.RecvError, 0, 1)
b := header.Encode(make([]byte, header.Len), header.Version, header.RecvError, 0, index, 0)
_ = f.outside.WriteTo(b, endpoint)
_ = f.writers[q].WriteTo(b, endpoint)
if f.l.Enabled(context.Background(), slog.LevelDebug) {
f.l.Debug("Recv error sent",
"index", index,