mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-09 23:19:11 +02:00
* [client] Take a WireGuard detector through the interface filter The interface filter answers whether an interface is a WireGuard device by opening a wgctrl client and asking for it, and it does that for every interface it is given. Nothing about that call is tied to the caller, so it can be answered by a shared object instead of being repeated, but the filter has no way to receive one. InterfaceFilter and the constructors that build one now take a detector, and the ICE config carries it so that every agent can be handed the same one. Nobody supplies a detector yet: a nil one probes on every call, which is what the filter did before, so this changes no behaviour. * [client] Share one WireGuard detector across every ICE agent Creating an ICE agent builds two interface filters, one for the agent and one for the transport net it sits on, and each is asked about every host interface. For an interface the disallow list does not settle, answering means opening a wgctrl client, which builds a kernel and a userspace client and resolves the netlink family, and then a round trip that usually just reports the device does not exist. An agent is created per peer connection attempt, so on a large network that runs constantly: on a routing peer with ~16000 peers it measured 2.40s of a 66.59s CPU profile, 3.6%, split evenly between opening the client and the round trip. The engine now owns a detector and passes it to every agent through the ICE config, so the answer for an interface is reused instead of being asked again for each agent. It is kept for a second, short enough that a WireGuard interface appearing is picked up before ICE settles on candidates over it. The callers that build one filter and keep it, the relay and the UDP mux, keep passing nil and so keep probing, which costs them nothing at their rate. * [client] Recheck the WireGuard cache inside the singleflight group A caller that saw an expired entry could enter the singleflight group after another caller had already refreshed the entry and left it, and probe the interface a second time. Read the cache again inside the group before probing. This also makes the concurrent probe test independent of scheduling: a late caller finds the fresh entry instead of starting a new probe. * [client] Drop expired WireGuard detector entries The detector lives as long as the engine and kept an entry for every interface name it was ever asked about. On hosts that churn interfaces, such as container veths, the map only grew. Remove expired entries when a new answer is stored; the map holds a few dozen names at most, so the sweep is cheap and runs at most once per interface per TTL. * [client] Skip the disallow-list filter test on iOS InterfaceFilter does not apply the disallow list on iOS, so the subtest reaches the probe there and its no-probe assertion cannot hold.
421 lines
12 KiB
Go
421 lines
12 KiB
Go
package peer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/client/internal/metrics"
|
|
"github.com/netbirdio/netbird/client/internal/peer/conntype"
|
|
"github.com/netbirdio/netbird/client/internal/peer/dispatcher"
|
|
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
|
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
|
"github.com/netbirdio/netbird/util"
|
|
)
|
|
|
|
var testDispatcher = dispatcher.NewConnectionDispatcher()
|
|
|
|
var connConf = ConnConfig{
|
|
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
Timeout: time.Second,
|
|
LocalWgPort: 51820,
|
|
ICEConfig: ice.Config{
|
|
InterfaceBlackList: nil,
|
|
},
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
_ = util.InitLog("trace", util.LogConsole)
|
|
code := m.Run()
|
|
os.Exit(code)
|
|
}
|
|
|
|
func TestNewConn_interfaceFilter(t *testing.T) {
|
|
ignore := []string{iface.WgInterfaceDefault, "tun0", "zt", "ZeroTier", "utun", "wg", "ts",
|
|
"Tailscale", "tailscale"}
|
|
|
|
filter := stdnet.InterfaceFilter(ignore, nil)
|
|
|
|
for _, s := range ignore {
|
|
assert.Equal(t, filter(s), false)
|
|
}
|
|
|
|
}
|
|
|
|
func TestConn_GetKey(t *testing.T) {
|
|
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
|
|
|
|
sd := ServiceDependencies{
|
|
SrWatcher: swWatcher,
|
|
PeerConnDispatcher: testDispatcher,
|
|
}
|
|
conn, err := NewConn(connConf, sd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
got := conn.GetKey()
|
|
|
|
assert.Equal(t, got, connConf.Key, "they should be equal")
|
|
}
|
|
|
|
func TestConn_OnRemoteOffer(t *testing.T) {
|
|
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
|
|
sd := ServiceDependencies{
|
|
StatusRecorder: NewRecorder("https://mgm"),
|
|
SrWatcher: swWatcher,
|
|
PeerConnDispatcher: testDispatcher,
|
|
}
|
|
conn, err := NewConn(connConf, sd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
onNewOfferChan := make(chan struct{})
|
|
|
|
conn.handshaker.AddRelayListener(func(remoteOfferAnswer *OfferAnswer) {
|
|
onNewOfferChan <- struct{}{}
|
|
})
|
|
|
|
conn.OnRemoteOffer(OfferAnswer{
|
|
IceCredentials: IceCredentials{
|
|
UFrag: "test",
|
|
Pwd: "test",
|
|
},
|
|
WgListenPort: 0,
|
|
Version: "",
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
select {
|
|
case <-onNewOfferChan:
|
|
// success
|
|
case <-ctx.Done():
|
|
t.Error("expected to receive a new offer notification, but timed out")
|
|
}
|
|
}
|
|
|
|
func TestConn_OnRemoteAnswer(t *testing.T) {
|
|
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
|
|
sd := ServiceDependencies{
|
|
StatusRecorder: NewRecorder("https://mgm"),
|
|
SrWatcher: swWatcher,
|
|
PeerConnDispatcher: testDispatcher,
|
|
}
|
|
conn, err := NewConn(connConf, sd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
onNewOfferChan := make(chan struct{})
|
|
|
|
conn.handshaker.AddRelayListener(func(remoteOfferAnswer *OfferAnswer) {
|
|
onNewOfferChan <- struct{}{}
|
|
})
|
|
|
|
conn.OnRemoteAnswer(OfferAnswer{
|
|
IceCredentials: IceCredentials{
|
|
UFrag: "test",
|
|
Pwd: "test",
|
|
},
|
|
WgListenPort: 0,
|
|
Version: "",
|
|
})
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
select {
|
|
case <-onNewOfferChan:
|
|
// success
|
|
case <-ctx.Done():
|
|
t.Error("expected to receive a new offer notification, but timed out")
|
|
}
|
|
}
|
|
|
|
func TestConn_presharedKey(t *testing.T) {
|
|
conn1 := Conn{
|
|
config: ConnConfig{
|
|
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
RosenpassConfig: RosenpassConfig{},
|
|
},
|
|
}
|
|
conn2 := Conn{
|
|
config: ConnConfig{
|
|
Key: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
LocalKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
RosenpassConfig: RosenpassConfig{},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
conn1Permissive bool
|
|
conn1RosenpassEnabled bool
|
|
conn2Permissive bool
|
|
conn2RosenpassEnabled bool
|
|
conn1ExpectedInitialKey bool
|
|
conn2ExpectedInitialKey bool
|
|
}{
|
|
{
|
|
conn1Permissive: false,
|
|
conn1RosenpassEnabled: false,
|
|
conn2Permissive: false,
|
|
conn2RosenpassEnabled: false,
|
|
conn1ExpectedInitialKey: false,
|
|
conn2ExpectedInitialKey: false,
|
|
},
|
|
{
|
|
conn1Permissive: false,
|
|
conn1RosenpassEnabled: true,
|
|
conn2Permissive: false,
|
|
conn2RosenpassEnabled: true,
|
|
conn1ExpectedInitialKey: true,
|
|
conn2ExpectedInitialKey: true,
|
|
},
|
|
{
|
|
conn1Permissive: false,
|
|
conn1RosenpassEnabled: true,
|
|
conn2Permissive: false,
|
|
conn2RosenpassEnabled: false,
|
|
conn1ExpectedInitialKey: true,
|
|
conn2ExpectedInitialKey: false,
|
|
},
|
|
{
|
|
conn1Permissive: false,
|
|
conn1RosenpassEnabled: false,
|
|
conn2Permissive: false,
|
|
conn2RosenpassEnabled: true,
|
|
conn1ExpectedInitialKey: false,
|
|
conn2ExpectedInitialKey: true,
|
|
},
|
|
{
|
|
conn1Permissive: true,
|
|
conn1RosenpassEnabled: true,
|
|
conn2Permissive: false,
|
|
conn2RosenpassEnabled: false,
|
|
conn1ExpectedInitialKey: false,
|
|
conn2ExpectedInitialKey: false,
|
|
},
|
|
{
|
|
conn1Permissive: false,
|
|
conn1RosenpassEnabled: false,
|
|
conn2Permissive: true,
|
|
conn2RosenpassEnabled: true,
|
|
conn1ExpectedInitialKey: false,
|
|
conn2ExpectedInitialKey: false,
|
|
},
|
|
{
|
|
conn1Permissive: true,
|
|
conn1RosenpassEnabled: true,
|
|
conn2Permissive: true,
|
|
conn2RosenpassEnabled: true,
|
|
conn1ExpectedInitialKey: true,
|
|
conn2ExpectedInitialKey: true,
|
|
},
|
|
{
|
|
conn1Permissive: false,
|
|
conn1RosenpassEnabled: false,
|
|
conn2Permissive: false,
|
|
conn2RosenpassEnabled: true,
|
|
conn1ExpectedInitialKey: false,
|
|
conn2ExpectedInitialKey: true,
|
|
},
|
|
{
|
|
conn1Permissive: false,
|
|
conn1RosenpassEnabled: true,
|
|
conn2Permissive: true,
|
|
conn2RosenpassEnabled: true,
|
|
conn1ExpectedInitialKey: true,
|
|
conn2ExpectedInitialKey: true,
|
|
},
|
|
}
|
|
|
|
conn1.config.RosenpassConfig.PermissiveMode = true
|
|
for i, test := range tests {
|
|
tcase := i + 1
|
|
t.Run(fmt.Sprintf("Rosenpass test case %d", tcase), func(t *testing.T) {
|
|
conn1.config.RosenpassConfig = RosenpassConfig{}
|
|
conn2.config.RosenpassConfig = RosenpassConfig{}
|
|
|
|
if test.conn1RosenpassEnabled {
|
|
conn1.config.RosenpassConfig.PubKey = []byte("dummykey")
|
|
}
|
|
conn1.config.RosenpassConfig.PermissiveMode = test.conn1Permissive
|
|
|
|
if test.conn2RosenpassEnabled {
|
|
conn2.config.RosenpassConfig.PubKey = []byte("dummykey")
|
|
}
|
|
conn2.config.RosenpassConfig.PermissiveMode = test.conn2Permissive
|
|
|
|
conn1PresharedKey := conn1.presharedKey(conn2.config.RosenpassConfig.PubKey)
|
|
conn2PresharedKey := conn2.presharedKey(conn1.config.RosenpassConfig.PubKey)
|
|
|
|
if test.conn1ExpectedInitialKey {
|
|
if conn1PresharedKey == nil {
|
|
t.Errorf("Case %d: Expected conn1 to have a non-nil key, but got nil", tcase)
|
|
}
|
|
} else {
|
|
if conn1PresharedKey != nil {
|
|
t.Errorf("Case %d: Expected conn1 to have a nil key, but got %v", tcase, conn1PresharedKey)
|
|
}
|
|
}
|
|
|
|
// Assert conn2's key expectation
|
|
if test.conn2ExpectedInitialKey {
|
|
if conn2PresharedKey == nil {
|
|
t.Errorf("Case %d: Expected conn2 to have a non-nil key, but got nil", tcase)
|
|
}
|
|
} else {
|
|
if conn2PresharedKey != nil {
|
|
t.Errorf("Case %d: Expected conn2 to have a nil key, but got %v", tcase, conn2PresharedKey)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConn_presharedKey_RosenpassManaged(t *testing.T) {
|
|
conn := Conn{
|
|
config: ConnConfig{
|
|
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
RosenpassConfig: RosenpassConfig{PubKey: []byte("dummykey")},
|
|
},
|
|
}
|
|
|
|
// When Rosenpass has already initialized the PSK for this peer,
|
|
// presharedKey must return nil to avoid UpdatePeer overwriting it.
|
|
conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return true }
|
|
if k := conn.presharedKey([]byte("remote")); k != nil {
|
|
t.Fatalf("expected nil presharedKey when Rosenpass manages PSK, got %v", k)
|
|
}
|
|
|
|
// When Rosenpass hasn't taken over yet, presharedKey should provide
|
|
// a non-nil initial key (deterministic or from NetBird PSK).
|
|
conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return false }
|
|
if k := conn.presharedKey([]byte("remote")); k == nil {
|
|
t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
|
|
}
|
|
}
|
|
|
|
func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
|
|
cfg := ConnConfig{
|
|
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
|
WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="},
|
|
}
|
|
if rosenpassEnabled {
|
|
cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")}
|
|
}
|
|
|
|
conn := &Conn{
|
|
ctx: context.Background(),
|
|
config: cfg,
|
|
Log: log.WithField("peer", cfg.Key),
|
|
metricsStages: &MetricsStages{},
|
|
}
|
|
conn.SetOnDisconnected(func(remotePeer string) {
|
|
*disconnected = append(*disconnected, remotePeer)
|
|
})
|
|
return conn
|
|
}
|
|
|
|
// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake
|
|
// timeouts with rosenpass enabled mean the preshared keys have desynced. The
|
|
// renewal exchange runs over the dead tunnel and cannot resync them, so after
|
|
// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the
|
|
// peer disconnected, dropping its rosenpass state so the next configuration
|
|
// programs the rendezvous key.
|
|
func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
|
|
var disconnected []string
|
|
conn := newWGTimeoutTestConn(true, &disconnected)
|
|
|
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
|
conn.onWGDisconnected(conn.ctx)
|
|
}
|
|
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
|
|
|
|
conn.onWGDisconnected(conn.ctx)
|
|
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
|
|
"reaching the threshold must report the peer disconnected once")
|
|
|
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
|
conn.onWGDisconnected(conn.ctx)
|
|
}
|
|
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
|
|
|
|
conn.onWGDisconnected(conn.ctx)
|
|
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
|
|
}
|
|
|
|
// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful
|
|
// handshake between timeouts means the tunnel recovered; the counter must
|
|
// start over.
|
|
func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
|
|
var disconnected []string
|
|
conn := newWGTimeoutTestConn(true, &disconnected)
|
|
|
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
|
conn.onWGDisconnected(conn.ctx)
|
|
}
|
|
conn.onWGCheckSuccess()
|
|
|
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
|
conn.onWGDisconnected(conn.ctx)
|
|
}
|
|
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
|
|
}
|
|
|
|
// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass
|
|
// there is no per-peer key state to reset; repeated timeouts must not report
|
|
// disconnects.
|
|
func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
|
|
var disconnected []string
|
|
conn := newWGTimeoutTestConn(false, &disconnected)
|
|
|
|
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
|
|
conn.onWGDisconnected(conn.ctx)
|
|
}
|
|
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
|
|
}
|
|
|
|
func TestMetricsConnType(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
priority conntype.ConnPriority
|
|
expected metrics.ConnectionType
|
|
}{
|
|
{"relay", conntype.Relay, metrics.ConnectionTypeRelay},
|
|
{"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn},
|
|
{"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P},
|
|
{"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown},
|
|
{"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.expected, metricsConnType(tc.priority))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) {
|
|
for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} {
|
|
conn := &Conn{currentConnPriority: priority}
|
|
tag := metricsConnType(priority)
|
|
relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn
|
|
assert.Equal(t, conn.isRelayed(), relayedTag,
|
|
"priority %s: isRelayed and the %q metric tag must agree", priority, tag)
|
|
}
|
|
}
|