mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 12:36:52 +01:00
Probe for another path when a keepalive punch finds a tunnel's remote unreachable
This commit is contained in:
1 parent
2fd9966fef
commit
0f07faab0e
6 files changed
+288
-28
No files matched your search
+28
-12
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/slackhq/nebula/cert"
|
"github.com/slackhq/nebula/cert"
|
||||||
"github.com/slackhq/nebula/config"
|
"github.com/slackhq/nebula/config"
|
||||||
"github.com/slackhq/nebula/header"
|
"github.com/slackhq/nebula/header"
|
||||||
|
"github.com/slackhq/nebula/udp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type trafficDecision int
|
type trafficDecision int
|
||||||
@@ -171,7 +172,7 @@ func (cm *connectionManager) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte, now time.Time) {
|
func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte, now time.Time) {
|
||||||
decision, hostinfo, primary := cm.makeTrafficDecision(localIndex, now)
|
decision, hostinfo, primary, punch := cm.makeTrafficDecision(localIndex, now)
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
case deleteTunnel:
|
case deleteTunnel:
|
||||||
@@ -196,6 +197,20 @@ func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte
|
|||||||
}
|
}
|
||||||
|
|
||||||
cm.resetRelayTrafficCheck(hostinfo)
|
cm.resetRelayTrafficCheck(hostinfo)
|
||||||
|
|
||||||
|
if punch != nil {
|
||||||
|
cm.sendPunch(punch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPunch keeps a tunnel's NAT state alive when we are not sending on it. When we have no route to its remote any
|
||||||
|
// more, typically after moving to a network without that address family, the tunnel probes its other remotes so the
|
||||||
|
// peer roams over to us. Nothing else would notice, a tunnel we are not sending on has nothing else that could fail.
|
||||||
|
func (cm *connectionManager) sendPunch(hostinfo *HostInfo) {
|
||||||
|
remote, err := cm.punchy.SendPunch(hostinfo)
|
||||||
|
if udp.IsUnreachable(err) {
|
||||||
|
hostinfo.remoteUnreachable(cm.intf, remote)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *connectionManager) resetRelayTrafficCheck(hostinfo *HostInfo) {
|
func (cm *connectionManager) resetRelayTrafficCheck(hostinfo *HostInfo) {
|
||||||
@@ -313,7 +328,9 @@ func (cm *connectionManager) migrateRelayUsed(oldhostinfo, newhostinfo *HostInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Time) (trafficDecision, *HostInfo, *HostInfo) {
|
// makeTrafficDecision decides what to do with a tunnel under the hostmap lock. It also returns the tunnel to punch when
|
||||||
|
// we are not sending on it, for the caller to send once the lock is released.
|
||||||
|
func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Time) (trafficDecision, *HostInfo, *HostInfo, *HostInfo) {
|
||||||
// Read lock the main hostmap to order decisions based on tunnels being the primary tunnel
|
// Read lock the main hostmap to order decisions based on tunnels being the primary tunnel
|
||||||
cm.hostMap.RLock()
|
cm.hostMap.RLock()
|
||||||
defer cm.hostMap.RUnlock()
|
defer cm.hostMap.RUnlock()
|
||||||
@@ -321,17 +338,17 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
|
|||||||
hostinfo := cm.hostMap.Indexes[localIndex]
|
hostinfo := cm.hostMap.Indexes[localIndex]
|
||||||
if hostinfo == nil {
|
if hostinfo == nil {
|
||||||
cm.l.Debug("Not found in hostmap", "localIndex", localIndex)
|
cm.l.Debug("Not found in hostmap", "localIndex", localIndex)
|
||||||
return doNothing, nil, nil
|
return doNothing, nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if cm.isInvalidCertificate(now, hostinfo) {
|
if cm.isInvalidCertificate(now, hostinfo) {
|
||||||
return closeTunnel, hostinfo, nil
|
return closeTunnel, hostinfo, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostinfo.ConnectionState != nil && hostinfo.ConnectionState.messageCounter.Load() >= RejectAfterMessages {
|
if hostinfo.ConnectionState != nil && hostinfo.ConnectionState.messageCounter.Load() >= RejectAfterMessages {
|
||||||
// Send path can't encrypt a CloseTunnel notify, so just delete locally; the peer recovers via recv_error.
|
// Send path can't encrypt a CloseTunnel notify, so just delete locally; the peer recovers via recv_error.
|
||||||
hostinfo.logger(cm.l).Error("Dropping tunnel, message counter is exhausted")
|
hostinfo.logger(cm.l).Error("Dropping tunnel, message counter is exhausted")
|
||||||
return deleteTunnel, hostinfo, nil
|
return deleteTunnel, hostinfo, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]]
|
primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]]
|
||||||
@@ -368,10 +385,10 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
|
|||||||
|
|
||||||
if !outTraffic {
|
if !outTraffic {
|
||||||
// Send a punch packet to keep the NAT state alive
|
// Send a punch packet to keep the NAT state alive
|
||||||
cm.punchy.SendPunch(hostinfo)
|
return decision, hostinfo, primary, hostinfo
|
||||||
}
|
}
|
||||||
|
|
||||||
return decision, hostinfo, primary
|
return decision, hostinfo, primary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostinfo.isPendingDeletion() {
|
if hostinfo.isPendingDeletion() {
|
||||||
@@ -380,7 +397,7 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
|
|||||||
"tunnelCheck", m{"state": "dead", "method": "active"},
|
"tunnelCheck", m{"state": "dead", "method": "active"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return deleteTunnel, hostinfo, nil
|
return deleteTunnel, hostinfo, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
decision := doNothing
|
decision := doNothing
|
||||||
@@ -394,14 +411,13 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
|
|||||||
"primary", mainHostInfo,
|
"primary", mainHostInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
return closeTunnel, hostinfo, primary
|
return closeTunnel, hostinfo, primary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we aren't sending or receiving traffic then its an unused tunnel and we don't to test the tunnel.
|
// If we aren't sending or receiving traffic then its an unused tunnel and we don't to test the tunnel.
|
||||||
// Just maintain NAT state if configured to do so.
|
// Just maintain NAT state if configured to do so.
|
||||||
cm.punchy.SendPunch(hostinfo)
|
|
||||||
cm.trafficTimer.Add(hostinfo.localIndexId, cm.checkInterval)
|
cm.trafficTimer.Add(hostinfo.localIndexId, cm.checkInterval)
|
||||||
return doNothing, nil, nil
|
return doNothing, nil, nil, hostinfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// We aren't receiving traffic but we are sending it. The outbound
|
// We aren't receiving traffic but we are sending it. The outbound
|
||||||
@@ -427,7 +443,7 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
|
|||||||
|
|
||||||
hostinfo.setPendingDeletion(true)
|
hostinfo.setPendingDeletion(true)
|
||||||
cm.trafficTimer.Add(hostinfo.localIndexId, cm.pendingDeletionInterval)
|
cm.trafficTimer.Add(hostinfo.localIndexId, cm.pendingDeletionInterval)
|
||||||
return decision, hostinfo, nil
|
return decision, hostinfo, nil, 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) {
|
||||||
|
|||||||
+104
-6
@@ -3,6 +3,7 @@ package nebula
|
|||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -266,12 +267,12 @@ func Test_NewConnectionManager_CounterLimits(t *testing.T) {
|
|||||||
|
|
||||||
// Still below the reject limit, the tunnel stays up
|
// Still below the reject limit, the tunnel stays up
|
||||||
nc.In(hostinfo)
|
nc.In(hostinfo)
|
||||||
decision, _, _ := nc.makeTrafficDecision(hostinfo.localIndexId, time.Now())
|
decision, _, _, _ := nc.makeTrafficDecision(hostinfo.localIndexId, time.Now())
|
||||||
assert.Equal(t, tryRehandshake, decision)
|
assert.Equal(t, tryRehandshake, decision)
|
||||||
|
|
||||||
// At the reject limit, the tunnel is deleted locally without a doomed CloseTunnel notify
|
// At the reject limit, the tunnel is deleted locally without a doomed CloseTunnel notify
|
||||||
hostinfo.ConnectionState.messageCounter.Store(RejectAfterMessages)
|
hostinfo.ConnectionState.messageCounter.Store(RejectAfterMessages)
|
||||||
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, time.Now())
|
decision, _, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, time.Now())
|
||||||
assert.Equal(t, deleteTunnel, decision)
|
assert.Equal(t, deleteTunnel, decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,14 +334,14 @@ func Test_NewConnectionManager_DisconnectInactive(t *testing.T) {
|
|||||||
assert.True(t, (hostinfo.state.Load()&stateIn != 0))
|
assert.True(t, (hostinfo.state.Load()&stateIn != 0))
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
decision, _, _ := nc.makeTrafficDecision(hostinfo.localIndexId, now)
|
decision, _, _, _ := nc.makeTrafficDecision(hostinfo.localIndexId, now)
|
||||||
assert.Equal(t, tryRehandshake, decision)
|
assert.Equal(t, tryRehandshake, decision)
|
||||||
assert.Equal(t, now, hostinfo.lastUsed)
|
assert.Equal(t, now, hostinfo.lastUsed)
|
||||||
assert.False(t, hostinfo.isPendingDeletion())
|
assert.False(t, hostinfo.isPendingDeletion())
|
||||||
assert.False(t, hostinfo.sentSinceCheck())
|
assert.False(t, hostinfo.sentSinceCheck())
|
||||||
assert.False(t, (hostinfo.state.Load()&stateIn != 0))
|
assert.False(t, (hostinfo.state.Load()&stateIn != 0))
|
||||||
|
|
||||||
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*5))
|
decision, _, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*5))
|
||||||
assert.Equal(t, doNothing, decision)
|
assert.Equal(t, doNothing, decision)
|
||||||
assert.Equal(t, now, hostinfo.lastUsed)
|
assert.Equal(t, now, hostinfo.lastUsed)
|
||||||
assert.False(t, hostinfo.isPendingDeletion())
|
assert.False(t, hostinfo.isPendingDeletion())
|
||||||
@@ -348,7 +349,7 @@ func Test_NewConnectionManager_DisconnectInactive(t *testing.T) {
|
|||||||
assert.False(t, (hostinfo.state.Load()&stateIn != 0))
|
assert.False(t, (hostinfo.state.Load()&stateIn != 0))
|
||||||
|
|
||||||
// Do another traffic check tick, should still not be pending deletion
|
// Do another traffic check tick, should still not be pending deletion
|
||||||
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*10))
|
decision, _, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*10))
|
||||||
assert.Equal(t, doNothing, decision)
|
assert.Equal(t, doNothing, decision)
|
||||||
assert.Equal(t, now, hostinfo.lastUsed)
|
assert.Equal(t, now, hostinfo.lastUsed)
|
||||||
assert.False(t, hostinfo.isPendingDeletion())
|
assert.False(t, hostinfo.isPendingDeletion())
|
||||||
@@ -358,7 +359,7 @@ func Test_NewConnectionManager_DisconnectInactive(t *testing.T) {
|
|||||||
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
|
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
|
||||||
|
|
||||||
// Finally advance beyond the inactivity timeout
|
// Finally advance beyond the inactivity timeout
|
||||||
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Minute*10))
|
decision, _, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Minute*10))
|
||||||
assert.Equal(t, closeTunnel, decision)
|
assert.Equal(t, closeTunnel, decision)
|
||||||
assert.Equal(t, now, hostinfo.lastUsed)
|
assert.Equal(t, now, hostinfo.lastUsed)
|
||||||
assert.False(t, hostinfo.isPendingDeletion())
|
assert.False(t, hostinfo.isPendingDeletion())
|
||||||
@@ -578,3 +579,100 @@ func (d *dummyCert) MarshalJSON() ([]byte, error) {
|
|||||||
func (d *dummyCert) Copy() cert.Certificate {
|
func (d *dummyCert) Copy() cert.Certificate {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newPunchTestConnectionManager is a connection manager punching through conn, holding the tunnel from newPunchTarget
|
||||||
|
func newPunchTestConnectionManager(t *testing.T, conn udp.Conn) (*connectionManager, *HostInfo) {
|
||||||
|
l := test.NewLogger()
|
||||||
|
hostMap := newHostMap(l)
|
||||||
|
hostMap.preferredRanges.Store(&[]netip.Prefix{})
|
||||||
|
|
||||||
|
lh := newTestLighthouse()
|
||||||
|
ifce := &Interface{
|
||||||
|
hostMap: hostMap,
|
||||||
|
inside: &overlaytest.NoopTun{},
|
||||||
|
outside: &udp.NoopConn{},
|
||||||
|
firewall: &Firewall{},
|
||||||
|
lightHouse: lh,
|
||||||
|
pki: &PKI{},
|
||||||
|
handshakeManager: NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig),
|
||||||
|
l: l,
|
||||||
|
}
|
||||||
|
ifce.pki.cs.Store(&CertState{
|
||||||
|
initiatingVersion: cert.Version1,
|
||||||
|
privateKey: []byte{},
|
||||||
|
v1Cert: &dummyCert{version: cert.Version1},
|
||||||
|
})
|
||||||
|
|
||||||
|
punchy := newPunchyForTest(t, map[string]any{"punch": true}, conn)
|
||||||
|
nc := newConnectionManagerFromConfig(l, config.NewC(l), hostMap, punchy)
|
||||||
|
nc.intf = ifce
|
||||||
|
ifce.connectionManager = nc
|
||||||
|
|
||||||
|
hostinfo := newPunchTarget()
|
||||||
|
hostinfo.localIndexId = 1099
|
||||||
|
hostinfo.remoteIndexId = 9901
|
||||||
|
hostinfo.ConnectionState = &ConnectionState{myCert: &dummyCert{version: cert.Version1}}
|
||||||
|
nc.hostMap.unlockedAddHostInfo(hostinfo, ifce)
|
||||||
|
return nc, hostinfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tunnel we are not sending on gets punched whether or not it is receiving, the punch is the only thing that
|
||||||
|
// would notice its remote has become unreachable.
|
||||||
|
func Test_makeTrafficDecision_punchesWhenNotSending(t *testing.T) {
|
||||||
|
nc, hostinfo := newPunchTestConnectionManager(t, &recordingConn{})
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Receiving but not sending
|
||||||
|
hostinfo.takeTraffic()
|
||||||
|
nc.In(hostinfo)
|
||||||
|
decision, _, _, punch := nc.makeTrafficDecision(hostinfo.localIndexId, now)
|
||||||
|
assert.Equal(t, tryRehandshake, decision)
|
||||||
|
assert.Same(t, hostinfo, punch)
|
||||||
|
|
||||||
|
// Sending and receiving, our own traffic keeps the NAT state alive
|
||||||
|
nc.Out(hostinfo)
|
||||||
|
nc.In(hostinfo)
|
||||||
|
decision, _, _, punch = nc.makeTrafficDecision(hostinfo.localIndexId, now)
|
||||||
|
assert.Equal(t, tryRehandshake, decision)
|
||||||
|
assert.Nil(t, punch)
|
||||||
|
|
||||||
|
// Unused
|
||||||
|
decision, decided, _, punch := nc.makeTrafficDecision(hostinfo.localIndexId, now)
|
||||||
|
assert.Equal(t, doNothing, decision)
|
||||||
|
assert.Nil(t, decided, "an unused tunnel has no relay usage to reset")
|
||||||
|
assert.Same(t, hostinfo, punch)
|
||||||
|
|
||||||
|
// Sending but not receiving gets a test packet instead, which finds an unreachable remote on its own
|
||||||
|
nc.Out(hostinfo)
|
||||||
|
decision, _, _, punch = nc.makeTrafficDecision(hostinfo.localIndexId, now)
|
||||||
|
assert.Equal(t, sendTestPacket, decision)
|
||||||
|
assert.Nil(t, punch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_sendPunch_probesWhenRemoteUnreachable(t *testing.T) {
|
||||||
|
t.Run("unreachable", func(t *testing.T) {
|
||||||
|
conn := &recordingConn{fail: map[netip.AddrPort]error{punchTargetV6: errNoRoute}}
|
||||||
|
nc, hostinfo := newPunchTestConnectionManager(t, conn)
|
||||||
|
|
||||||
|
nc.sendPunch(hostinfo)
|
||||||
|
assert.Equal(t, []netip.AddrPort{punchTargetV6}, conn.wrote)
|
||||||
|
assert.NotZero(t, hostinfo.nextUnreachableProbe.Load(), "the tunnel should have probed its other remotes")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delivered", func(t *testing.T) {
|
||||||
|
conn := &recordingConn{}
|
||||||
|
nc, hostinfo := newPunchTestConnectionManager(t, conn)
|
||||||
|
|
||||||
|
nc.sendPunch(hostinfo)
|
||||||
|
assert.Equal(t, []netip.AddrPort{punchTargetV6}, conn.wrote)
|
||||||
|
assert.Zero(t, hostinfo.nextUnreachableProbe.Load())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("other failure", func(t *testing.T) {
|
||||||
|
conn := &recordingConn{fail: map[netip.AddrPort]error{punchTargetV6: io.ErrShortWrite}}
|
||||||
|
nc, hostinfo := newPunchTestConnectionManager(t, conn)
|
||||||
|
|
||||||
|
nc.sendPunch(hostinfo)
|
||||||
|
assert.Zero(t, hostinfo.nextUnreachableProbe.Load(), "only a missing route says to look elsewhere")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ func TestDualStackRoamToV4Only(t *testing.T) {
|
|||||||
{name: "peer sends", themSends: true},
|
{name: "peer sends", themSends: true},
|
||||||
{name: "peer sends, rebind", themSends: true, rebind: true},
|
{name: "peer sends, rebind", themSends: true, rebind: true},
|
||||||
{name: "peer sends, lh over v4", themSends: true, lhOverV4: true},
|
{name: "peer sends, lh over v4", themSends: true, lhOverV4: true},
|
||||||
|
{name: "peer sends, punchy", themSends: true, punchy: true},
|
||||||
|
{name: "peer sends, punchy all", themSends: true, punchy: true, punchAll: true},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -51,6 +53,8 @@ type dualStackRoamCase struct {
|
|||||||
// simultaneous has both sides initiate at once at the office, which can leave each side a different primary
|
// simultaneous has both sides initiate at once at the office, which can leave each side a different primary
|
||||||
// tunnel for the other
|
// tunnel for the other
|
||||||
simultaneous bool
|
simultaneous bool
|
||||||
|
// punchy turns on punchy.punch everywhere, punchAll adds punchy.target_all_remotes
|
||||||
|
punchy, punchAll bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtRecovery(d time.Duration) string {
|
func fmtRecovery(d time.Duration) string {
|
||||||
@@ -115,6 +119,10 @@ func measureDualStackRoam(t *testing.T, tc dualStackRoamCase) (time.Duration, ti
|
|||||||
"connection_alive_interval": 5,
|
"connection_alive_interval": 5,
|
||||||
"pending_deletion_interval": 10,
|
"pending_deletion_interval": 10,
|
||||||
},
|
},
|
||||||
|
"punchy": m{
|
||||||
|
"punch": tc.punchy,
|
||||||
|
"target_all_remotes": tc.punchAll,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
me := newDualStackNode(ca, caKey, "me", "10.128.0.2/24", ap("10.0.0.2:4242"), ap("[fd00::2]:4242"), peerCfg)
|
me := newDualStackNode(ca, caKey, "me", "10.128.0.2/24", ap("10.0.0.2:4242"), ap("[fd00::2]:4242"), peerCfg)
|
||||||
|
|||||||
@@ -165,20 +165,24 @@ func (p *Punchy) scheduleJob(job holepunchJob, delay time.Duration) {
|
|||||||
// SendPunch sends an immediate keepalive punch for an idle hostinfo.
|
// SendPunch sends an immediate keepalive punch for an idle hostinfo.
|
||||||
// The configured punchy.target_all_remotes mode picks the targets. Gated on punchy.punch and the lighthouse-skip rule
|
// The configured punchy.target_all_remotes mode picks the targets. Gated on punchy.punch and the lighthouse-skip rule
|
||||||
// (lighthouses don't get keepalive punches because the regular update interval keeps their NAT state warm).
|
// (lighthouses don't get keepalive punches because the regular update interval keeps their NAT state warm).
|
||||||
func (p *Punchy) SendPunch(hostinfo *HostInfo) {
|
// Returns the hostinfo's current remote and the error from punching it, an invalid remote if it was not punched.
|
||||||
|
func (p *Punchy) SendPunch(hostinfo *HostInfo) (netip.AddrPort, error) {
|
||||||
if !p.punch.Load() {
|
if !p.punch.Load() {
|
||||||
return
|
return netip.AddrPort{}, nil
|
||||||
}
|
}
|
||||||
if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
|
if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
|
||||||
return
|
return netip.AddrPort{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr := hostinfo.GetRemote()
|
||||||
if p.punchEverything.Load() {
|
if p.punchEverything.Load() {
|
||||||
p.sendPunchToAllRemotes(hostinfo)
|
return hr, p.sendPunchToAllRemotes(hostinfo, hr)
|
||||||
} else if hr := hostinfo.GetRemote(); hr.IsValid() {
|
|
||||||
p.metricPunchyTx.Inc(1)
|
|
||||||
p.punchConn.WriteTo([]byte{1}, hr)
|
|
||||||
}
|
}
|
||||||
|
if !hr.IsValid() {
|
||||||
|
return hr, nil
|
||||||
|
}
|
||||||
|
p.metricPunchyTx.Inc(1)
|
||||||
|
return hr, p.punchConn.WriteTo([]byte{1}, hr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendPunchToAll punches every known remote for hostinfo, but only when punchy.target_all_remotes is enabled.
|
// SendPunchToAll punches every known remote for hostinfo, but only when punchy.target_all_remotes is enabled.
|
||||||
@@ -195,14 +199,19 @@ func (p *Punchy) SendPunchToAll(hostinfo *HostInfo) {
|
|||||||
if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
|
if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.sendPunchToAllRemotes(hostinfo)
|
p.sendPunchToAllRemotes(hostinfo, netip.AddrPort{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Punchy) sendPunchToAllRemotes(hostinfo *HostInfo) {
|
// sendPunchToAllRemotes punches every known remote for hostinfo and returns the error from punching current
|
||||||
|
func (p *Punchy) sendPunchToAllRemotes(hostinfo *HostInfo, current netip.AddrPort) error {
|
||||||
|
var currentErr error
|
||||||
hostinfo.remotes.ForEach(p.hm.GetPreferredRanges(), func(addr netip.AddrPort, preferred bool) {
|
hostinfo.remotes.ForEach(p.hm.GetPreferredRanges(), func(addr netip.AddrPort, preferred bool) {
|
||||||
p.metricPunchyTx.Inc(1)
|
p.metricPunchyTx.Inc(1)
|
||||||
p.punchConn.WriteTo([]byte{1}, addr)
|
if err := p.punchConn.WriteTo([]byte{1}, addr); addr == current {
|
||||||
|
currentErr = err
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
return currentErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start wires the runtime dependencies and spawns the scheduler worker.
|
// Start wires the runtime dependencies and spawns the scheduler worker.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package nebula
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/slackhq/nebula/config"
|
"github.com/slackhq/nebula/config"
|
||||||
"github.com/slackhq/nebula/test"
|
"github.com/slackhq/nebula/test"
|
||||||
|
"github.com/slackhq/nebula/udp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -242,3 +244,93 @@ func TestPunchyReloadRestartRequired(t *testing.T) {
|
|||||||
require.NoError(t, res.Err)
|
require.NoError(t, res.Err)
|
||||||
assert.True(t, res.RestartRequired)
|
assert.True(t, res.RestartRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errNoRoute is the unreachable error every platform classifies the same way
|
||||||
|
var errNoRoute = udp.ErrInvalidIPv6RemoteForSocket
|
||||||
|
|
||||||
|
// recordingConn records where every write went and fails the ones to an address in fail
|
||||||
|
type recordingConn struct {
|
||||||
|
udp.NoopConn
|
||||||
|
fail map[netip.AddrPort]error
|
||||||
|
wrote []netip.AddrPort
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingConn) WriteTo(_ []byte, addr netip.AddrPort) error {
|
||||||
|
c.wrote = append(c.wrote, addr)
|
||||||
|
return c.fail[addr]
|
||||||
|
}
|
||||||
|
|
||||||
|
type noLighthouses struct{}
|
||||||
|
|
||||||
|
func (noLighthouses) IsAnyLighthouseAddr([]netip.Addr) bool { return false }
|
||||||
|
|
||||||
|
var (
|
||||||
|
punchTargetV4 = netip.MustParseAddrPort("10.0.0.3:4242")
|
||||||
|
punchTargetV6 = netip.MustParseAddrPort("[fd00::3]:4242")
|
||||||
|
)
|
||||||
|
|
||||||
|
// newPunchTarget is a tunnel on its IPv6 remote that also knows the peer's IPv4 one
|
||||||
|
func newPunchTarget() *HostInfo {
|
||||||
|
vpnAddr := netip.MustParseAddr("10.128.0.3")
|
||||||
|
hostinfo := &HostInfo{
|
||||||
|
vpnAddrs: []netip.Addr{vpnAddr},
|
||||||
|
remotes: NewRemoteList([]netip.Addr{vpnAddr}, nil),
|
||||||
|
}
|
||||||
|
hostinfo.remotes.LearnRemote(vpnAddr, punchTargetV4)
|
||||||
|
hostinfo.SetRemote(punchTargetV6)
|
||||||
|
return hostinfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPunchyForTest(t *testing.T, settings map[string]any, conn udp.Conn) *Punchy {
|
||||||
|
c := config.NewC(test.NewLogger())
|
||||||
|
c.Settings["punchy"] = settings
|
||||||
|
p := NewPunchyFromConfig(test.NewLogger(), c, conn)
|
||||||
|
|
||||||
|
hm := newHostMap(test.NewLogger())
|
||||||
|
hm.preferredRanges.Store(&[]netip.Prefix{})
|
||||||
|
p.hm = hm
|
||||||
|
p.lh = noLighthouses{}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPunchy_SendPunchReportsCurrentRemote(t *testing.T) {
|
||||||
|
t.Run("disabled", func(t *testing.T) {
|
||||||
|
conn := &recordingConn{fail: map[netip.AddrPort]error{punchTargetV6: errNoRoute}}
|
||||||
|
p := newPunchyForTest(t, map[string]any{"punch": false}, conn)
|
||||||
|
|
||||||
|
remote, err := p.SendPunch(newPunchTarget())
|
||||||
|
assert.False(t, remote.IsValid())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, conn.wrote)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("current remote", func(t *testing.T) {
|
||||||
|
conn := &recordingConn{fail: map[netip.AddrPort]error{punchTargetV6: errNoRoute}}
|
||||||
|
p := newPunchyForTest(t, map[string]any{"punch": true}, conn)
|
||||||
|
|
||||||
|
remote, err := p.SendPunch(newPunchTarget())
|
||||||
|
assert.Equal(t, punchTargetV6, remote)
|
||||||
|
require.ErrorIs(t, err, errNoRoute)
|
||||||
|
assert.Equal(t, []netip.AddrPort{punchTargetV6}, conn.wrote)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all remotes, current fails", func(t *testing.T) {
|
||||||
|
conn := &recordingConn{fail: map[netip.AddrPort]error{punchTargetV6: errNoRoute}}
|
||||||
|
p := newPunchyForTest(t, map[string]any{"punch": true, "target_all_remotes": true}, conn)
|
||||||
|
|
||||||
|
remote, err := p.SendPunch(newPunchTarget())
|
||||||
|
assert.Equal(t, punchTargetV6, remote)
|
||||||
|
require.ErrorIs(t, err, errNoRoute)
|
||||||
|
assert.ElementsMatch(t, []netip.AddrPort{punchTargetV4, punchTargetV6}, conn.wrote)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all remotes, only another fails", func(t *testing.T) {
|
||||||
|
conn := &recordingConn{fail: map[netip.AddrPort]error{punchTargetV4: errNoRoute}}
|
||||||
|
p := newPunchyForTest(t, map[string]any{"punch": true, "target_all_remotes": true}, conn)
|
||||||
|
|
||||||
|
remote, err := p.SendPunch(newPunchTarget())
|
||||||
|
assert.Equal(t, punchTargetV6, remote)
|
||||||
|
require.NoError(t, err, "a failure on a remote we are not using says nothing about the current one")
|
||||||
|
assert.ElementsMatch(t, []netip.AddrPort{punchTargetV4, punchTargetV6}, conn.wrote)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsUnreachable(t *testing.T) {
|
||||||
|
wrap := func(err error) error { return &net.OpError{Op: "write", Net: "udp", Err: err} }
|
||||||
|
|
||||||
|
for _, err := range []error{
|
||||||
|
wrap(syscall.ENETUNREACH),
|
||||||
|
wrap(syscall.EHOSTUNREACH),
|
||||||
|
wrap(syscall.EADDRNOTAVAIL),
|
||||||
|
wrap(syscall.EAFNOSUPPORT),
|
||||||
|
ErrInvalidIPv6RemoteForSocket,
|
||||||
|
} {
|
||||||
|
if !IsUnreachable(err) {
|
||||||
|
t.Errorf("IsUnreachable(%v) = false, want true", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, err := range []error{
|
||||||
|
nil,
|
||||||
|
wrap(syscall.EPERM),
|
||||||
|
wrap(syscall.ENOBUFS),
|
||||||
|
wrap(syscall.EMSGSIZE),
|
||||||
|
net.ErrClosed,
|
||||||
|
} {
|
||||||
|
if IsUnreachable(err) {
|
||||||
|
t.Errorf("IsUnreachable(%v) = true, want false", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user