mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-19 11:59:09 +02:00
* [client] Keep interface discovery out of the bind open path Since 0.81.0 a userspace-mode agent whose interface name is not covered by the interface blacklist deadlocks while bringing the device up, and the daemon, the GUI and the service stop all hang. wireguard-go holds the device net lock across bind.Open, our bind builds the UDP mux there, the mux enumerated interfaces in its constructor, and the interface filter probed every unlisted name through wgctrl. For the agent's own interface that probe connected to the daemon's own UAPI endpoint and waited, with no deadline, for a reply the handler could only give after taking the same lock. #7346 moved the first enumeration from the stdnet constructor, which ran before the device existed, to this call site. The mux no longer discovers interfaces when it is constructed. It records whether its socket is bound to an unspecified address and GetConn uses that instead of the length of the discovered list, while GetListenAddresses keeps refreshing on demand, which pion calls during candidate gathering outside the device locks. The WireGuard detector also answers for the agent's own interface without probing, so no filter asks the daemon about itself. * [client] Take the agent's own interface name in the detector constructor Every device implementation reports the configured name, so there is no later name to follow and the setter was state without a reader that needed it. The detector takes the name once and compares without a lock. * [client] Create the default mux Net in the constructor With discovery moved to GetListenAddresses, the nil Net fallback ran there as well, and the first concurrent callers raced on writing it. stdnet.NewNet does no discovery, so the constructor can create it before the mux is shared and the address refresh only reads it. * [client] Follow the created interface name in the detector Android ignores the configured interface name and keeps the TUN name returned by CreateUnmonitoredTUNFromFD, so the detector built from the configuration would still probe the agent's real interface. The engine updates the detector with wgInterface.Name() once the interface exists. * [client] Keep the detector's own interface name immutable On Android the kernel names the TUN and the name can change again on renew, while the UAPI socket cannot be opened there at all, so neither the probe nor a name set after creation can identify the agent's own interface reliably. Excluding that interface on Android belongs to the interface discovery the app provides. Everywhere else the configured name is the created name, so the constructor argument is enough and the detector needs no setter and no lock on the hot path.
189 lines
6.4 KiB
Go
189 lines
6.4 KiB
Go
package stdnet
|
|
|
|
import (
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newCountingDetector returns a detector whose probe records how often it ran, so a test
|
|
// can assert on the thing this type exists for rather than on its return value alone.
|
|
func newCountingDetector(t *testing.T, ttl time.Duration, answer bool) (*WGDetector, *atomic.Int64) {
|
|
t.Helper()
|
|
|
|
var calls atomic.Int64
|
|
d := &WGDetector{
|
|
ttl: ttl,
|
|
cache: make(map[string]wgDetectorEntry),
|
|
probe: func(string) bool {
|
|
calls.Add(1)
|
|
return answer
|
|
},
|
|
}
|
|
return d, &calls
|
|
}
|
|
|
|
func TestWGDetectorAsksOncePerInterfaceWithinTheTTL(t *testing.T) {
|
|
d, calls := newCountingDetector(t, time.Minute, true)
|
|
|
|
for i := 0; i < 20; i++ {
|
|
assert.True(t, d.IsWireGuard("wt0"), "cached answer must not change")
|
|
}
|
|
assert.Equal(t, int64(1), calls.Load(), "the interface must be probed once within the TTL")
|
|
|
|
d.IsWireGuard("eth0")
|
|
assert.Equal(t, int64(2), calls.Load(), "a different interface is a different question and is probed on its own")
|
|
}
|
|
|
|
func TestWGDetectorReprobesAfterTheTTL(t *testing.T) {
|
|
d, calls := newCountingDetector(t, time.Millisecond, true)
|
|
|
|
require.True(t, d.IsWireGuard("wt0"), "first answer")
|
|
require.Equal(t, int64(1), calls.Load(), "first call probes")
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
require.True(t, d.IsWireGuard("wt0"), "answer after expiry")
|
|
assert.Equal(t, int64(2), calls.Load(), "an expired entry must be probed again")
|
|
}
|
|
|
|
func TestWGDetectorCollapsesConcurrentProbes(t *testing.T) {
|
|
var calls atomic.Int64
|
|
release := make(chan struct{})
|
|
d := &WGDetector{
|
|
ttl: time.Minute,
|
|
cache: make(map[string]wgDetectorEntry),
|
|
probe: func(string) bool {
|
|
calls.Add(1)
|
|
<-release
|
|
return true
|
|
},
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
d.IsWireGuard("wt0")
|
|
}()
|
|
}
|
|
|
|
// The sleep only lets the callers pile up on the blocked probe so the collapse is
|
|
// exercised. The count does not depend on it: a caller that arrives after the probe
|
|
// finished finds the fresh entry, either before or inside the singleflight group.
|
|
time.Sleep(20 * time.Millisecond)
|
|
close(release)
|
|
wg.Wait()
|
|
|
|
assert.Equal(t, int64(1), calls.Load(), "concurrent callers must share one probe")
|
|
}
|
|
|
|
func TestWGDetectorNilProbesEveryTime(t *testing.T) {
|
|
var d *WGDetector
|
|
// A nil detector keeps the uncached behaviour, which is what the callers that build one
|
|
// filter for their whole lifetime rely on. It must not panic.
|
|
assert.NotPanics(t, func() { d.IsWireGuard("definitely-not-an-interface-0") },
|
|
"a nil detector must fall back to probing")
|
|
}
|
|
|
|
func TestInterfaceFilter(t *testing.T) {
|
|
wgDetector, calls := newCountingDetector(t, time.Minute, true)
|
|
plainDetector, _ := newCountingDetector(t, time.Minute, false)
|
|
|
|
t.Run("loopback is rejected without probing", func(t *testing.T) {
|
|
filter := InterfaceFilter(nil, wgDetector)
|
|
assert.False(t, filter("lo"), "loopback must never be offered to ICE")
|
|
assert.Equal(t, int64(0), calls.Load(), "a name settled by prefix must not reach the probe")
|
|
})
|
|
|
|
t.Run("a disallowed interface is rejected without probing", func(t *testing.T) {
|
|
if runtime.GOOS == "ios" {
|
|
t.Skip("the disallow list is not applied on iOS")
|
|
}
|
|
filter := InterfaceFilter([]string{"wt"}, wgDetector)
|
|
assert.False(t, filter("wt0"), "a blacklisted interface must be rejected")
|
|
assert.Equal(t, int64(0), calls.Load(), "a name settled by the disallow list must not reach the probe")
|
|
})
|
|
|
|
t.Run("an unlisted WireGuard interface is rejected", func(t *testing.T) {
|
|
filter := InterfaceFilter(nil, wgDetector)
|
|
assert.False(t, filter("somewg0"), "a WireGuard interface must not be used to build a tunnel")
|
|
})
|
|
|
|
t.Run("an ordinary interface is allowed", func(t *testing.T) {
|
|
filter := InterfaceFilter(nil, plainDetector)
|
|
assert.True(t, filter("eth0"), "a plain interface must remain available to ICE")
|
|
})
|
|
}
|
|
|
|
func TestInterfaceFilterSharesOneProbeAcrossFilters(t *testing.T) {
|
|
d, calls := newCountingDetector(t, time.Minute, false)
|
|
|
|
// Every ICE agent builds its own filter, twice, and each one is asked about every
|
|
// interface. Sharing the detector is what keeps that from repeating the probe.
|
|
for i := 0; i < 10; i++ {
|
|
filter := InterfaceFilter(nil, d)
|
|
require.True(t, filter("eth0"), "a plain interface stays allowed")
|
|
require.True(t, filter("eth1"), "a plain interface stays allowed")
|
|
}
|
|
|
|
assert.Equal(t, int64(2), calls.Load(), "one probe per interface, not per filter")
|
|
}
|
|
|
|
func TestWGDetectorDropsExpiredEntries(t *testing.T) {
|
|
d, _ := newCountingDetector(t, time.Millisecond, false)
|
|
|
|
for _, name := range []string{"veth1", "veth2", "veth3"} {
|
|
d.IsWireGuard(name)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
d.IsWireGuard("eth0")
|
|
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
assert.Len(t, d.cache, 1, "expired entries of vanished interfaces must be dropped")
|
|
assert.Contains(t, d.cache, "eth0", "the fresh answer must be kept")
|
|
}
|
|
|
|
func TestWGDetectorOwnInterfaceIsWireGuardWithoutProbing(t *testing.T) {
|
|
d, calls := newCountingDetector(t, time.Minute, false)
|
|
d.ownIface = "NetBird"
|
|
|
|
assert.True(t, d.IsWireGuard("NetBird"), "the agent's own interface is a WireGuard device")
|
|
assert.Equal(t, int64(0), calls.Load(), "the own interface must never reach the probe")
|
|
|
|
assert.False(t, d.IsWireGuard("NetBird2"), "only the exact own name is answered without probing")
|
|
assert.Equal(t, int64(1), calls.Load(), "a different interface is probed")
|
|
}
|
|
|
|
func TestWGDetectorWithoutOwnInterfaceProbesEmptyName(t *testing.T) {
|
|
d, calls := newCountingDetector(t, time.Minute, false)
|
|
|
|
assert.False(t, d.IsWireGuard(""), "an unnamed interface is not mistaken for the own one")
|
|
assert.Equal(t, int64(1), calls.Load(), "with no own interface set every name is probed")
|
|
}
|
|
|
|
func TestInterfaceFilterRejectsOwnInterfaceRegardlessOfDisallowList(t *testing.T) {
|
|
d, calls := newCountingDetector(t, time.Minute, false)
|
|
d.ownIface = "NetBird"
|
|
filter := InterfaceFilter([]string{"wt"}, d)
|
|
|
|
assert.False(t, filter("NetBird"), "the own interface is rejected even when the disallow list does not cover it")
|
|
assert.Equal(t, int64(0), calls.Load(), "the own interface is not probed")
|
|
|
|
assert.True(t, filter("eth0"), "an ordinary interface stays available")
|
|
assert.Equal(t, int64(1), calls.Load(), "an ordinary interface is still probed")
|
|
}
|
|
|
|
func TestNewWGDetectorKeepsTheOwnInterface(t *testing.T) {
|
|
d := NewWGDetector("NetbirdWT")
|
|
|
|
assert.True(t, d.IsWireGuard("NetbirdWT"), "the constructor records the own interface")
|
|
}
|