mirror of
https://github.com/slackhq/nebula.git
synced 2026-08-16 07:57:03 +02:00
crazy multiport stuff
This commit is contained in:
+86
-1
@@ -191,6 +191,28 @@ func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte
|
|||||||
}
|
}
|
||||||
|
|
||||||
cm.resetRelayTrafficCheck(hostinfo)
|
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) {
|
func (cm *connectionManager) resetRelayTrafficCheck(hostinfo *HostInfo) {
|
||||||
@@ -323,6 +345,10 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
|
|||||||
return closeTunnel, hostinfo, nil
|
return closeTunnel, 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 {
|
||||||
@@ -419,13 +445,72 @@ 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.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) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
if inactiveDuration < cm.getInactivityTimeout() {
|
||||||
// It's not considered inactive
|
// It's not considered inactive
|
||||||
return inactiveDuration, false
|
return inactiveDuration, false
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func runTestHandshake(t *testing.T) (initR, respR *handshake.Result) {
|
|||||||
cert.Version2, initCreds, verifier,
|
cert.Version2, initCreds, verifier,
|
||||||
func() (uint32, error) { return 1000, nil },
|
func() (uint32, error) { return 1000, nil },
|
||||||
true, header.HandshakeIXPSK0,
|
true, header.HandshakeIXPSK0,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ func runTestHandshake(t *testing.T) (initR, respR *handshake.Result) {
|
|||||||
cert.Version2, respCreds, verifier,
|
cert.Version2, respCreds, verifier,
|
||||||
func() (uint32, error) { return 2000, nil },
|
func() (uint32, error) { return 2000, nil },
|
||||||
false, header.HandshakeIXPSK0,
|
false, header.HandshakeIXPSK0,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -66,6 +66,9 @@ 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"`
|
||||||
|
LaneIndex uint16 `json:"laneIndex,omitempty"`
|
||||||
|
SockIdx int `json:"sockIdx,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start actually runs nebula, this is a nonblocking call.
|
// Start actually runs nebula, this is a nonblocking call.
|
||||||
@@ -198,7 +201,9 @@ func (c *Control) RebindUDPServer() {
|
|||||||
return
|
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
|
// Trigger a lighthouse update, useful for mobile clients that should have an update interval of 0
|
||||||
c.f.lightHouse.SendUpdate()
|
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
|
// 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)
|
||||||
}
|
}
|
||||||
@@ -377,6 +387,9 @@ 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(),
|
||||||
|
LaneIndex: h.laneIndex,
|
||||||
|
SockIdx: h.sockIdx,
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, a := range h.vpnAddrs {
|
for i, a := range h.vpnAddrs {
|
||||||
|
|||||||
+1
-1
@@ -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"}, thi)
|
assertFields(t, []string{"VpnAddrs", "LocalIndex", "RemoteIndex", "RemoteAddrs", "Cert", "MessageCounter", "CurrentRemote", "CurrentRelaysToMe", "CurrentRelaysThroughMe", "IsLane", "LaneIndex", "SockIdx"}, thi)
|
||||||
assert.Equal(t, &expectedInfo, thi)
|
assert.Equal(t, &expectedInfo, thi)
|
||||||
test.AssertDeepCopyEqual(t, &expectedInfo, thi)
|
test.AssertDeepCopyEqual(t, &expectedInfo, thi)
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,36 @@ listen:
|
|||||||
# This option is only supported on Linux.
|
# This option is only supported on Linux.
|
||||||
#routines: 1
|
#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:
|
punchy:
|
||||||
# Continues to punch inbound/outbound at a regular interval to avoid expiration of firewall nat mappings
|
# Continues to punch inbound/outbound at a regular interval to avoid expiration of firewall nat mappings
|
||||||
# This setting is reloadable.
|
# This setting is reloadable.
|
||||||
|
|||||||
@@ -23,7 +23,19 @@ message NebulaHandshakeDetails {
|
|||||||
// hand-written parser silently skips it on read.
|
// hand-written parser silently skips it on read.
|
||||||
uint64 Cookie = 4 [deprecated = true];
|
uint64 Cookie = 4 [deprecated = true];
|
||||||
uint64 Time = 5;
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func newTestMachine(
|
|||||||
cs.version, cs.getCredential,
|
cs.version, cs.getCredential,
|
||||||
verifier, func() (uint32, error) { return localIndex, nil },
|
verifier, func() (uint32, error) { return localIndex, nil },
|
||||||
initiator, header.HandshakeIXPSK0,
|
initiator, header.HandshakeIXPSK0,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return m
|
return m
|
||||||
|
|||||||
+33
-1
@@ -39,6 +39,13 @@ type Result struct {
|
|||||||
HandshakeTime uint64
|
HandshakeTime uint64
|
||||||
MessageIndex uint64 // number of messages exchanged during the handshake
|
MessageIndex uint64 // number of messages exchanged during the handshake
|
||||||
Initiator bool
|
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
|
// Machine drives a Noise handshake through N messages. It handles Noise
|
||||||
@@ -61,6 +68,7 @@ type Machine struct {
|
|||||||
verifier CertVerifier
|
verifier CertVerifier
|
||||||
result *Result
|
result *Result
|
||||||
msgs []msgFlags
|
msgs []msgFlags
|
||||||
|
lanes *LaneDetails // our multiport advert; nil emits a vanilla payload
|
||||||
myVersion cert.Version
|
myVersion cert.Version
|
||||||
subtype header.MessageSubType
|
subtype header.MessageSubType
|
||||||
indexAllocated bool
|
indexAllocated bool
|
||||||
@@ -73,6 +81,8 @@ type Machine struct {
|
|||||||
// the noise pattern and the per-message content layout. The credential for
|
// the noise pattern and the per-message content layout. The credential for
|
||||||
// `version` is fetched via getCred and used to seed the noise.HandshakeState.
|
// `version` is fetched via getCred and used to seed the noise.HandshakeState.
|
||||||
// IndexAllocator is called lazily when the first outgoing payload is built.
|
// 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(
|
func NewMachine(
|
||||||
version cert.Version,
|
version cert.Version,
|
||||||
getCred GetCredentialFunc,
|
getCred GetCredentialFunc,
|
||||||
@@ -80,6 +90,7 @@ func NewMachine(
|
|||||||
allocIndex IndexAllocator,
|
allocIndex IndexAllocator,
|
||||||
initiator bool,
|
initiator bool,
|
||||||
subtype header.MessageSubType,
|
subtype header.MessageSubType,
|
||||||
|
lanes *LaneDetails,
|
||||||
) (*Machine, error) {
|
) (*Machine, error) {
|
||||||
info, err := subtypeInfoFor(subtype)
|
info, err := subtypeInfoFor(subtype)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,6 +114,7 @@ func NewMachine(
|
|||||||
getCred: getCred,
|
getCred: getCred,
|
||||||
allocIndex: allocIndex,
|
allocIndex: allocIndex,
|
||||||
verifier: verifier,
|
verifier: verifier,
|
||||||
|
lanes: lanes,
|
||||||
myVersion: version,
|
myVersion: version,
|
||||||
result: &Result{
|
result: &Result{
|
||||||
Initiator: initiator,
|
Initiator: initiator,
|
||||||
@@ -298,7 +310,8 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assert the payload contains exactly what we expect
|
// 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 {
|
if hasPayloadData != flags.expectsPayload {
|
||||||
m.failed = true
|
m.failed = true
|
||||||
return ErrUnexpectedContent
|
return ErrUnexpectedContent
|
||||||
@@ -327,6 +340,23 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
|
|||||||
m.result.RemoteIndex = remoteIndex
|
m.result.RemoteIndex = remoteIndex
|
||||||
m.result.HandshakeTime = payload.Time
|
m.result.HandshakeTime = payload.Time
|
||||||
m.payloadSet = true
|
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
|
// Process certificate
|
||||||
@@ -397,9 +427,11 @@ func (m *Machine) marshalOutgoing(flags msgFlags) ([]byte, error) {
|
|||||||
|
|
||||||
if m.result.Initiator {
|
if m.result.Initiator {
|
||||||
p.InitiatorIndex = m.result.LocalIndex
|
p.InitiatorIndex = m.result.LocalIndex
|
||||||
|
p.InitiatorLanes = m.lanes
|
||||||
} else {
|
} else {
|
||||||
p.ResponderIndex = m.result.LocalIndex
|
p.ResponderIndex = m.result.LocalIndex
|
||||||
p.InitiatorIndex = m.result.RemoteIndex
|
p.InitiatorIndex = m.result.RemoteIndex
|
||||||
|
p.ResponderLanes = m.lanes
|
||||||
}
|
}
|
||||||
p.Time = uint64(time.Now().UnixNano())
|
p.Time = uint64(time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -444,6 +444,7 @@ func TestMachineThreeMessagePattern(t *testing.T) {
|
|||||||
initCS.getCredential, v,
|
initCS.getCredential, v,
|
||||||
func() (uint32, error) { return 1000, nil },
|
func() (uint32, error) { return 1000, nil },
|
||||||
true, header.HandshakeXXPSK0,
|
true, header.HandshakeXXPSK0,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -452,6 +453,7 @@ func TestMachineThreeMessagePattern(t *testing.T) {
|
|||||||
respCS.getCredential, v,
|
respCS.getCredential, v,
|
||||||
func() (uint32, error) { return 2000, nil },
|
func() (uint32, error) { return 2000, nil },
|
||||||
false, header.HandshakeXXPSK0,
|
false, header.HandshakeXXPSK0,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ type Payload struct {
|
|||||||
ResponderIndex uint32
|
ResponderIndex uint32
|
||||||
Time uint64
|
Time uint64
|
||||||
CertVersion uint32
|
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
|
// Proto field numbers for NebulaHandshakeDetails
|
||||||
@@ -28,9 +41,18 @@ const (
|
|||||||
fieldInitiatorIndex = 2 // uint32
|
fieldInitiatorIndex = 2 // uint32
|
||||||
fieldResponderIndex = 3 // uint32
|
fieldResponderIndex = 3 // uint32
|
||||||
fieldTime = 5 // uint64
|
fieldTime = 5 // uint64
|
||||||
|
fieldInitiatorLanes = 6 // LaneDetails
|
||||||
|
fieldResponderLanes = 7 // LaneDetails
|
||||||
fieldCertVersion = 8 // uint32
|
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
|
// MarshalPayload encodes a handshake payload in protobuf wire format compatible
|
||||||
// with NebulaHandshake{Details: NebulaHandshakeDetails{...}}.
|
// with NebulaHandshake{Details: NebulaHandshakeDetails{...}}.
|
||||||
// Returns out (which may be nil), with the marshalled Payload appended to it.
|
// 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.AppendTag(details, fieldTime, protowire.VarintType)
|
||||||
details = protowire.AppendVarint(details, p.Time)
|
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 {
|
if p.CertVersion != 0 {
|
||||||
details = protowire.AppendTag(details, fieldCertVersion, protowire.VarintType)
|
details = protowire.AppendTag(details, fieldCertVersion, protowire.VarintType)
|
||||||
details = protowire.AppendVarint(details, uint64(p.CertVersion))
|
details = protowire.AppendVarint(details, uint64(p.CertVersion))
|
||||||
@@ -64,6 +94,20 @@ func MarshalPayload(out []byte, p Payload) []byte {
|
|||||||
return out
|
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.
|
// UnmarshalPayload decodes a protobuf-encoded NebulaHandshake message.
|
||||||
func UnmarshalPayload(b []byte) (Payload, error) {
|
func UnmarshalPayload(b []byte) (Payload, error) {
|
||||||
var p Payload
|
var p Payload
|
||||||
@@ -161,6 +205,72 @@ func unmarshalPayloadDetails(p *Payload, b []byte) error {
|
|||||||
}
|
}
|
||||||
p.CertVersion = uint32(v)
|
p.CertVersion = uint32(v)
|
||||||
b = b[n:]
|
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:
|
default:
|
||||||
n := protowire.ConsumeFieldValue(num, typ, b)
|
n := protowire.ConsumeFieldValue(num, typ, b)
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
|
|||||||
+137
-11
@@ -117,23 +117,134 @@ func TestPayloadUnknownFields(t *testing.T) {
|
|||||||
assert.Equal(t, uint32(88), got.ResponderIndex)
|
assert.Equal(t, uint32(88), got.ResponderIndex)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("reserved fields 6 and 7 are skipped", func(t *testing.T) {
|
t.Run("unknown field inside LaneDetails is skipped", func(t *testing.T) {
|
||||||
// Fields 6 and 7 are reserved in the proto definition
|
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
|
var details []byte
|
||||||
details = protowire.AppendTag(details, fieldInitiatorIndex, protowire.VarintType)
|
details = protowire.AppendTag(details, fieldInitiatorIndex, protowire.VarintType)
|
||||||
details = protowire.AppendVarint(details, 100)
|
details = protowire.AppendVarint(details, 100)
|
||||||
details = protowire.AppendTag(details, 6, protowire.VarintType)
|
details = protowire.AppendTag(details, fieldInitiatorLanes, protowire.BytesType)
|
||||||
details = protowire.AppendVarint(details, 1)
|
details = protowire.AppendBytes(details, lane)
|
||||||
details = protowire.AppendTag(details, 7, protowire.VarintType)
|
|
||||||
details = protowire.AppendVarint(details, 2)
|
|
||||||
|
|
||||||
var data []byte
|
got, err := UnmarshalPayload(wrapDetails(details))
|
||||||
data = protowire.AppendTag(data, 1, protowire.BytesType)
|
require.NoError(t, err)
|
||||||
data = protowire.AppendBytes(data, details)
|
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)
|
got, err := UnmarshalPayload(data)
|
||||||
require.NoError(t, err)
|
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,
|
Time: 3,
|
||||||
CertVersion: 2,
|
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{})
|
||||||
f.Add([]byte{0xff})
|
f.Add([]byte{0xff})
|
||||||
|
|
||||||
@@ -357,5 +474,14 @@ func payloadsEqual(a, b Payload) bool {
|
|||||||
a.InitiatorIndex == b.InitiatorIndex &&
|
a.InitiatorIndex == b.InitiatorIndex &&
|
||||||
a.ResponderIndex == b.ResponderIndex &&
|
a.ResponderIndex == b.ResponderIndex &&
|
||||||
a.Time == b.Time &&
|
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
@@ -50,6 +50,14 @@ type HandshakeConfig struct {
|
|||||||
retries int64
|
retries int64
|
||||||
triggerBuffer int
|
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
|
messageMetrics *MessageMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +73,15 @@ type HandshakeManager struct {
|
|||||||
outside udp.Conn
|
outside udp.Conn
|
||||||
config HandshakeConfig
|
config HandshakeConfig
|
||||||
OutboundHandshakeTimer *LockingTimerWheel[netip.Addr]
|
OutboundHandshakeTimer *LockingTimerWheel[netip.Addr]
|
||||||
messageMetrics *MessageMetrics
|
// OutboundLaneTimer drives lane handshake retries. Lanes never enter
|
||||||
metricInitiated metrics.Counter
|
// vpnIps (they would collide with base handshakes for the same address),
|
||||||
metricTimedOut metrics.Counter
|
// so their wheel is keyed by pending localIndexId instead.
|
||||||
f *Interface
|
OutboundLaneTimer *LockingTimerWheel[uint32]
|
||||||
l *slog.Logger
|
messageMetrics *MessageMetrics
|
||||||
|
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
|
||||||
@@ -88,6 +100,11 @@ 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) {
|
||||||
@@ -125,6 +142,7 @@ 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),
|
||||||
@@ -144,6 +162,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,7 +548,13 @@ 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 {
|
||||||
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 {
|
if len(hm.vpnIps) == 0 {
|
||||||
@@ -664,6 +689,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)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hm.f.l.Error("Failed to create handshake machine",
|
hm.f.l.Error("Failed to create handshake machine",
|
||||||
@@ -687,6 +713,215 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
|
|||||||
return true
|
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
|
// beginHandshake handles an incoming handshake packet that doesn't match any
|
||||||
// existing pending handshake. It creates a new responder Machine and processes
|
// existing pending handshake. It creates a new responder Machine and processes
|
||||||
// the first message.
|
// the first message.
|
||||||
@@ -705,6 +940,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),
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -741,6 +977,13 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
hostinfo := &HostInfo{
|
hostinfo := &HostInfo{
|
||||||
ConnectionState: newConnectionStateFromResult(result),
|
ConnectionState: newConnectionStateFromResult(result),
|
||||||
localIndexId: result.LocalIndex,
|
localIndexId: result.LocalIndex,
|
||||||
@@ -785,6 +1028,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)
|
||||||
|
|
||||||
existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
|
existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -794,6 +1038,7 @@ 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) {
|
||||||
@@ -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.
|
// 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
|
||||||
@@ -821,6 +1199,14 @@ 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",
|
||||||
@@ -833,7 +1219,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.DeleteHostInfo(hostinfo)
|
hm.deletePendingHostInfo(hostinfo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +1229,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.DeleteHostInfo(hostinfo)
|
hm.deletePendingHostInfo(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)
|
||||||
@@ -866,7 +1252,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.DeleteHostInfo(hostinfo)
|
hm.deletePendingHostInfo(hostinfo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,7 +1286,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.DeleteHostInfo(hostinfo)
|
hm.deletePendingHostInfo(hostinfo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vpnAddrs[i] = network.Addr()
|
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())},
|
"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
|
||||||
@@ -958,7 +1353,31 @@ 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.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) {
|
||||||
@@ -1064,7 +1483,10 @@ func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hos
|
|||||||
|
|
||||||
if !via.IsRelayed {
|
if !via.IsRelayed {
|
||||||
fields := append(logFields, "from", via)
|
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 {
|
if err != nil {
|
||||||
f.l.Error("Failed to send handshake message", append(fields, "error", err)...)
|
f.l.Error("Failed to send handshake message", append(fields, "error", err)...)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+203
@@ -282,6 +282,135 @@ type HostInfo struct {
|
|||||||
// This value will be behind against actual tunnel utilization in the hot path.
|
// This value will be behind against actual tunnel utilization in the hot path.
|
||||||
// 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
|
||||||
|
// 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 {
|
type ViaSender struct {
|
||||||
@@ -289,6 +418,11 @@ type ViaSender struct {
|
|||||||
relayHI *HostInfo // relayHI is the host info object of the relay
|
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.
|
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
|
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 {
|
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,
|
// 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
|
||||||
@@ -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
|
// 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() {
|
||||||
|
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
|
// 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.
|
||||||
final := true
|
final := true
|
||||||
@@ -543,6 +696,35 @@ func (hm *HostMap) unlockedDeleteHostInfo(hostinfo *HostInfo) bool {
|
|||||||
return final
|
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 {
|
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 {
|
||||||
@@ -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) {
|
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 {
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ import (
|
|||||||
"github.com/slackhq/nebula/header"
|
"github.com/slackhq/nebula/header"
|
||||||
"github.com/slackhq/nebula/iputil"
|
"github.com/slackhq/nebula/iputil"
|
||||||
"github.com/slackhq/nebula/noiseutil"
|
"github.com/slackhq/nebula/noiseutil"
|
||||||
"github.com/slackhq/nebula/overlay/batch"
|
|
||||||
"github.com/slackhq/nebula/overlay/tio"
|
"github.com/slackhq/nebula/overlay/tio"
|
||||||
"github.com/slackhq/nebula/routing"
|
"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
|
// borrowed: pkt.Bytes is owned by the originating tio.Queue and is
|
||||||
// only valid until the next Read on that queue. Every consumer below
|
// only valid until the next Read on that queue. Every consumer below
|
||||||
// (parse, self-forward, handshake cache, sendInsideMessage) reads it
|
// (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)
|
dropReason := f.firewall.Drop(*fwPacket, false, hostinfo, f.pki.GetCAPool(), localCache)
|
||||||
if dropReason == nil {
|
if dropReason == nil {
|
||||||
f.sendInsideMessage(hostinfo, pkt, nb, sendBatch, rejectBuf, q)
|
f.sendInsideMessage(hostinfo, pkt, nb, tx, q)
|
||||||
} else {
|
} else {
|
||||||
f.rejectInside(packet, rejectBuf, q)
|
f.rejectInside(packet, rejectBuf, q)
|
||||||
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
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
|
// scratch arena: SegmentSuperpacket builds each segment's plaintext in
|
||||||
// segScratch[:segLen] in turn, and we encrypt directly into a fresh
|
// segScratch[:segLen] in turn, and we encrypt directly into a fresh
|
||||||
// SendBatch slot.
|
// 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
|
ci := hostinfo.ConnectionState
|
||||||
if ci.eKey == nil {
|
if ci.eKey == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendBatch := tx.base
|
||||||
remote := hostinfo.GetRemote()
|
remote := hostinfo.GetRemote()
|
||||||
ecnEnabled := f.ecnEnabled.Load()
|
ecnEnabled := f.ecnEnabled.Load()
|
||||||
if hostinfo.lastRebindCount != f.rebindCount {
|
if hostinfo.lastRebindCount != f.rebindCount {
|
||||||
@@ -224,6 +229,21 @@ 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.
|
||||||
|
// 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 {
|
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)
|
||||||
@@ -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 {
|
if !f.firewall.InboundSendReject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -302,7 +322,7 @@ func (f *Interface) rejectOutside(packet []byte, ci *ConnectionState, hostinfo *
|
|||||||
return
|
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
|
// 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
|
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.
|
// 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) {
|
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.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) {
|
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.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,
|
func (f *Interface) prepareSendVia(via *HostInfo,
|
||||||
@@ -530,16 +550,23 @@ func (f *Interface) SendVia(via *HostInfo,
|
|||||||
return
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if ci.eKey == nil {
|
||||||
return
|
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()
|
useRelay := !remote.IsValid() && !hostinfo.GetRemote().IsValid()
|
||||||
fullOut := out
|
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
|
// 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.
|
// 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
|
//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.
|
// finally used again. This tunnel would eventually be torn down and recreated if this action didn't help.
|
||||||
f.lightHouse.QueryServer(hostinfo.vpnAddrs[0])
|
f.lightHouse.QueryServer(hostinfo.vpnAddrs[0])
|
||||||
@@ -608,6 +636,13 @@ 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)
|
||||||
|
|||||||
+60
-14
@@ -42,10 +42,13 @@ type InterfaceConfig struct {
|
|||||||
DropLocalBroadcast bool
|
DropLocalBroadcast bool
|
||||||
DropMulticast bool
|
DropMulticast bool
|
||||||
routines int
|
routines int
|
||||||
MessageMetrics *MessageMetrics
|
// Multiport means writers[i] is bound to listen.port+i (not a shared
|
||||||
version string
|
// SO_REUSEPORT port) and lane tunnels are negotiated with capable peers.
|
||||||
relayManager *relayManager
|
Multiport bool
|
||||||
punchy *Punchy
|
MessageMetrics *MessageMetrics
|
||||||
|
version string
|
||||||
|
relayManager *relayManager
|
||||||
|
punchy *Punchy
|
||||||
|
|
||||||
tryPromoteEvery uint32
|
tryPromoteEvery uint32
|
||||||
reQueryEvery uint32
|
reQueryEvery uint32
|
||||||
@@ -86,6 +89,7 @@ type Interface struct {
|
|||||||
dropLocalBroadcast bool
|
dropLocalBroadcast bool
|
||||||
dropMulticast bool
|
dropMulticast bool
|
||||||
routines int
|
routines int
|
||||||
|
multiport bool
|
||||||
disconnectInvalid atomic.Bool
|
disconnectInvalid atomic.Bool
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
// cpuAffinity, when non-empty, names the CPUs each TUN reader goroutine
|
// 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,
|
dropLocalBroadcast: c.DropLocalBroadcast,
|
||||||
dropMulticast: c.DropMulticast,
|
dropMulticast: c.DropMulticast,
|
||||||
routines: c.routines,
|
routines: c.routines,
|
||||||
|
multiport: c.Multiport,
|
||||||
version: c.version,
|
version: c.version,
|
||||||
writers: make([]udp.Conn, c.routines),
|
writers: make([]udp.Conn, c.routines),
|
||||||
batchers: make([]batch.RxBatcher, c.routines),
|
batchers: make([]batch.RxBatcher, c.routines),
|
||||||
@@ -277,7 +282,10 @@ func (f *Interface) activate() error {
|
|||||||
"boringcrypto", boringEnabled(),
|
"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.routines = 1
|
||||||
f.l.Warn("multiple udp readers are not supported on this platform, falling back to a single routine")
|
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
|
return err
|
||||||
}
|
}
|
||||||
if len(queues) < f.routines {
|
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",
|
f.l.Warn("tun multiqueue is not supported on this platform, falling back to fewer routines",
|
||||||
"requested", f.routines, "opened", len(queues))
|
"requested", f.routines, "opened", len(queues))
|
||||||
f.routines = len(queues)
|
f.routines = len(queues)
|
||||||
@@ -372,7 +385,7 @@ func (f *Interface) listenOut(i int) {
|
|||||||
scratch := make([]byte, mtu)
|
scratch := make([]byte, mtu)
|
||||||
|
|
||||||
listener := func(fromUdpAddr netip.AddrPort, payload []byte, meta udp.RxMeta) {
|
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() {
|
flusher := func() {
|
||||||
@@ -394,6 +407,39 @@ func (f *Interface) listenOut(i int) {
|
|||||||
f.l.Debug("underlay reader is done", "reader", i)
|
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) {
|
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
|
// 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.
|
// 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)
|
rejectBuf := make([]byte, mtu)
|
||||||
arenaSize := batch.SendBatchCap * (udp.MTU + 32)
|
arenaSize := batch.SendBatchCap * (udp.MTU + 32)
|
||||||
sb := batch.NewSendBatch(f.writers[i], batch.SendBatchCap, arenaSize)
|
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{}
|
fwPacket := &firewall.Packet{}
|
||||||
nb := make([]byte, 12, 12)
|
nb := make([]byte, 12, 12)
|
||||||
|
|
||||||
@@ -436,19 +486,15 @@ func (f *Interface) listenIn(queue tio.Queue, i int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, pkt := range pkts {
|
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
|
// Flush incrementally once a full sendmmsg batch has
|
||||||
// accumulated so the first packets of a deep read drain
|
// accumulated so the first packets of a deep read drain
|
||||||
// hit the wire while the rest are still being encrypted.
|
// hit the wire while the rest are still being encrypted.
|
||||||
if sb.Len() >= batch.SendBatchCap {
|
if tx.full() {
|
||||||
if err := sb.Flush(); err != nil {
|
tx.flush(f.l, i)
|
||||||
f.l.Error("Failed to write outgoing batch", "error", err, "writer", i)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := sb.Flush(); err != nil {
|
tx.flush(f.l, i)
|
||||||
f.l.Error("Failed to write outgoing batch", "error", err, "writer", i)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
f.l.Debug("overlay reader is done", "reader", i)
|
f.l.Debug("overlay reader is done", "reader", i)
|
||||||
|
|||||||
+409
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
@@ -134,6 +135,22 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
|||||||
udpConns := make([]udp.Conn, routines)
|
udpConns := make([]udp.Conn, routines)
|
||||||
port := c.GetInt("listen.port", 0)
|
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.
|
// Callers get no handle to these until the Control is returned, release them on any error.
|
||||||
defer func() {
|
defer func() {
|
||||||
if reterr != nil {
|
if reterr != nil {
|
||||||
@@ -163,25 +180,77 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
|||||||
listenHost = ips[0].Unmap()
|
listenHost = ips[0].Unmap()
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < routines; i++ {
|
// Every lane socket needs its own reader; a platform that can't run
|
||||||
l.Info("listening", "addr", netip.AddrPortFrom(listenHost, uint16(port)))
|
// multiple readers would silently strand sockets 1..n-1 as blackholes.
|
||||||
udpServer, err := udp.NewListener(l, listenHost, port, routines > 1, c.GetInt("listen.batch", 64))
|
// Probe capability before committing to per-port binds.
|
||||||
if err != nil {
|
if multiport {
|
||||||
return nil, util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
|
probe, err := udp.NewListener(l, listenHost, 0, false, 1)
|
||||||
}
|
if err == nil {
|
||||||
udpServer.ReloadConfig(c)
|
if !probe.SupportsMultipleReaders() {
|
||||||
udpConns[i] = udpServer
|
l.Warn("multiport disabled: this platform does not support multiple udp readers")
|
||||||
|
multiport = false
|
||||||
// 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())
|
_ = 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)
|
hostMap := NewHostMapFromConfig(l, c)
|
||||||
@@ -206,6 +275,16 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
|||||||
messageMetrics: messageMetrics,
|
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)
|
handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)
|
||||||
lightHouse.handshakeTrigger = handshakeManager.trigger
|
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),
|
DropLocalBroadcast: c.GetBool("tun.drop_local_broadcast", false),
|
||||||
DropMulticast: c.GetBool("tun.drop_multicast", false),
|
DropMulticast: c.GetBool("tun.drop_multicast", false),
|
||||||
routines: routines,
|
routines: routines,
|
||||||
|
Multiport: multiport,
|
||||||
MessageMetrics: messageMetrics,
|
MessageMetrics: messageMetrics,
|
||||||
version: buildVersion,
|
version: buildVersion,
|
||||||
relayManager: NewRelayManager(ctx, l, hostMap, c),
|
relayManager: NewRelayManager(ctx, l, hostMap, c),
|
||||||
|
|||||||
+10
-6
@@ -101,7 +101,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, scratch []byte, packet []b
|
|||||||
// recvError if necessary
|
// recvError if necessary
|
||||||
if hostinfo == nil || hostinfo.ConnectionState == nil {
|
if hostinfo == nil || hostinfo.ConnectionState == nil {
|
||||||
if !via.IsRelayed {
|
if !via.IsRelayed {
|
||||||
f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex)
|
f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex, q)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -203,6 +203,7 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
|
|||||||
relayHI: hostinfo,
|
relayHI: hostinfo,
|
||||||
relay: relay,
|
relay: relay,
|
||||||
IsRelayed: true,
|
IsRelayed: true,
|
||||||
|
SockIdx: via.SockIdx,
|
||||||
}
|
}
|
||||||
f.readOutsidePackets(via, scratch, signedPayload, h, fwPacket, lhf, nb, q, localCache, meta)
|
f.readOutsidePackets(via, scratch, signedPayload, h, fwPacket, lhf, nb, q, localCache, meta)
|
||||||
case ForwardingType:
|
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)
|
dropReason := f.firewall.Drop(*fwPacket, true, hostinfo, f.pki.GetCAPool(), localCache)
|
||||||
if dropReason != nil {
|
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) {
|
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||||
hostinfo.logger(f.l).Debug("dropping inbound packet",
|
hostinfo.logger(f.l).Debug("dropping inbound packet",
|
||||||
"fwPacket", fwPacket,
|
"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) {
|
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)
|
f.messageMetrics.Tx(header.RecvError, 0, 1)
|
||||||
|
|
||||||
b := header.Encode(make([]byte, header.Len), header.Version, header.RecvError, 0, index, 0)
|
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) {
|
if f.l.Enabled(context.Background(), slog.LevelDebug) {
|
||||||
f.l.Debug("Recv error sent",
|
f.l.Debug("Recv error sent",
|
||||||
"index", index,
|
"index", index,
|
||||||
|
|||||||
Reference in New Issue
Block a user