Files
netbird/client/iface/configurer/usp_allowedips_test.go
T
Zoltan Papp 01185ba353 [client] Configure the userspace device through its typed API (#7937)
* [client] Configure the userspace device through its typed API

The userspace configurer drove wireguard-go through UAPI text: every
write was rendered to the configuration protocol and parsed back, and
every read, including the per-second statistics poll, dumped the whole
device as text and parsed it again. With 10000 peers a statistics read
cost about 16 ms, and clearing one endpoint needed a peer remove and a
re-add because the protocol cannot clear an endpoint or drop a single
prefix in place. The allowed IP mirror in the configurer existed only
to feed those rewrites.

The wireguard-go fork now exposes a typed peer API, so the configurer
calls the device directly: ConfigurePeer for creates and updates,
single-prefix add and remove on the routing table, ClearEndpoint, and
accessors for statistics. The mirror, the UAPI rendering and the
parsers are gone from the userspace path; the UAPI socket for wg show
stays.

Clearing an endpoint keeps the peer object, its counters and its
allowed IPs, but resets the session and switches the keepalive off
until the next endpoint arrives. The watcher expects a fresh handshake
after an endpoint reset, and an endpoint-less peer with a keepalive
would retry handshakes it cannot send; this is what the remove and
re-add provided before. Handshake times now carry nanosecond precision
like the kernel path.

Measured with 10000 peers against the UAPI implementation: clearing an
endpoint drops from 713 ms to 3 ms for all peers, a statistics read
from 15 ms to 0.9 ms, a full dump from 18 ms to 4 ms, and single
allowed IP changes are 7 to 20 times faster.

* [client] Keep the allowed IP mirror kernel-only

The userspace configurer no longer uses the allowed IP mirror, so its
ensure method and the preshared key config helper had no caller left
on Windows and macOS, where the kernel configurer is not compiled, and
the unused linter failed there.

The mirror and its tests now carry the kernel build tag, the prefix
normalizers that both paths share move to common.go, and the preshared
key helper moves next to its only caller.

* [client] Trim historical references from configurer comments

* [client] Keep the endpoint zone in the userspace configurer

The UAPI path wrote the endpoint as text, so a link-local IPv6 endpoint
reached the device with its zone. The typed path built the address from
the IP bytes only and dropped the zone, leaving the device with an
unroutable fe80:: address. The activity recorder keeps the zone-less
address it always had.

* [client] Run the prefix conversion tests on every platform

prefixesToIPNets lives in the untagged common.go and the userspace stats
path uses it on every platform, but its tests stayed in the kernel-tagged
allowedips_test.go, so darwin and windows CI stopped running them. Move
them next to the normalizePrefix tests. The round-trip test stays tagged
because ipNetsToPrefixes is kernel-only.

* [client] Pin wireguard-go to the merged typed peer API

The typed peer configuration API landed on master as #23, so the
replace no longer needs to point at the PR branch commit.
2026-10-12 10:29:26 +02:00

253 lines
10 KiB
Go

package configurer
import (
"net"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
wgconn "golang.zx2c4.com/wireguard/conn"
wgdevice "golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/iface/bind"
)
// newTestUSPConfigurer builds a configurer over a real wireguard-go device backed by an
// in-memory TUN. The device stays down, so no socket is opened and no privileges are needed.
func newTestUSPConfigurer(t *testing.T) *WGUSPConfigurer {
t.Helper()
dev := wgdevice.NewDevice(newIdleTUN(), wgconn.NewDefaultBind(), wgdevice.NewLogger(wgdevice.LogLevelSilent, ""))
t.Cleanup(dev.Close)
c := NewUSPConfigurerNoUAPI(dev, "wgtest0", bind.NewActivityRecorder())
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err, "generate device private key")
require.NoError(t, c.ConfigureInterface(key.String(), 0), "configure test device")
return c
}
// seedPeers adds count peers, each with a /32 overlay address, and returns their public keys.
func seedPeers(t *testing.T, c *WGUSPConfigurer, count int) []string {
t.Helper()
keys := make([]string, 0, count)
for i := 0; i < count; i++ {
priv, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err, "generate peer private key")
pub := priv.PublicKey().String()
addr := netip.PrefixFrom(netip.AddrFrom4([4]byte{100, 64, byte(i >> 8), byte(i)}), 32)
require.NoError(t, c.UpdatePeer(pub, []netip.Prefix{addr}, 25*time.Second, nil, nil), "add peer")
keys = append(keys, pub)
}
return keys
}
func peerAllowedIPs(t *testing.T, c *WGUSPConfigurer, peerKey string) []string {
t.Helper()
stats, err := c.FullStats()
require.NoError(t, err, "read device stats")
for _, p := range stats.Peers {
if p.PublicKey != peerKey {
continue
}
got := make([]string, 0, len(p.AllowedIPs))
for _, ipNet := range p.AllowedIPs {
got = append(got, ipNet.String())
}
return got
}
t.Fatalf("peer %s not found on device", peerKey)
return nil
}
// TestRemoveEndpointAddressPreservesRoutedAllowedIPs covers the prefixes the route manager
// attaches to a routing peer through AddAllowedIP. Those are not known to the peer.Conn that
// triggers the endpoint removal, so dropping them here would silently blackhole every route
// behind that peer on each relay or ICE disconnect.
func TestRemoveEndpointAddressPreservesRoutedAllowedIPs(t *testing.T) {
c := newTestUSPConfigurer(t)
peerKey := seedPeers(t, c, 3)[1]
routed := []netip.Prefix{
netip.MustParsePrefix("10.20.0.0/16"),
netip.MustParsePrefix("192.168.7.0/24"),
}
for _, prefix := range routed {
require.NoError(t, c.AddAllowedIP(peerKey, prefix), "add routed prefix")
}
before := peerAllowedIPs(t, c, peerKey)
require.Len(t, before, 3, "peer should hold its overlay address plus both routed prefixes")
require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address")
assert.ElementsMatch(t, before, peerAllowedIPs(t, c, peerKey),
"allowed IPs must survive the endpoint removal unchanged")
}
// TestRemoveEndpointAddressDoesNotScaleWithPeerCount is the regression guard for the actual
// defect: clearing one peer's endpoint used to dump and parse the whole device, so its cost
// grew with the size of the network map. On a routing peer with thousands of peers that dump
// runs on every relay and ICE transition, under the interface lock.
func TestRemoveEndpointAddressDoesNotScaleWithPeerCount(t *testing.T) {
measure := func(peerCount int) float64 {
c := newTestUSPConfigurer(t)
peerKey := seedPeers(t, c, peerCount)[peerCount/2]
return testing.AllocsPerRun(5, func() {
require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address")
})
}
small := measure(64)
large := measure(1024)
assert.Less(t, large, small*2,
"clearing one endpoint allocated %.0f objects with 1024 peers against %.0f with 64: the cost still scales with the peer count",
large, small)
}
func TestRemoveAllowedIPKeepsTheOtherPrefixes(t *testing.T) {
c := newTestUSPConfigurer(t)
peerKey := seedPeers(t, c, 3)[0]
routed := netip.MustParsePrefix("10.20.0.0/16")
require.NoError(t, c.AddAllowedIP(peerKey, routed), "add routed prefix")
require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("192.168.7.0/24")), "add routed prefix")
require.NoError(t, c.RemoveAllowedIP(peerKey, routed), "remove routed prefix")
assert.ElementsMatch(t, []string{"100.64.0.0/32", "192.168.7.0/24"}, peerAllowedIPs(t, c, peerKey),
"only the removed prefix should be gone")
assert.ErrorIs(t, c.RemoveAllowedIP(peerKey, routed), ErrAllowedIPNotFound,
"removing a prefix that is no longer configured must be reported")
}
// TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt covers the lazy connection window documented
// in #6863: AddAllowedIP is update-only, a silent no-op when the peer is absent, and clearing
// the endpoint of an absent peer must not create one either.
func TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt(t *testing.T) {
c := newTestUSPConfigurer(t)
seedPeers(t, c, 2)
priv, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err, "generate peer private key")
absent := priv.PublicKey().String()
require.NoError(t, c.AddAllowedIP(absent, netip.MustParsePrefix("10.20.0.0/16")),
"update-only add on an absent peer is a silent no-op")
stats, err := c.FullStats()
require.NoError(t, err, "read device stats")
require.Len(t, stats.Peers, 2, "the absent peer must not have been created by AddAllowedIP")
assert.ErrorIs(t, c.RemoveEndpointAddress(absent), ErrPeerNotFound,
"clearing the endpoint of a peer the device does not have must fail")
stats, err = c.FullStats()
require.NoError(t, err, "read device stats")
assert.Len(t, stats.Peers, 2, "no peer may be created while clearing an endpoint")
}
// TestRemoveEndpointAddressDoesNotStealAPrefixFromAnotherPeer covers WireGuard's rule that an
// allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away from
// whichever peer held it before. UpdatePeer relies on that rule rather than removing the prefix
// from the previous holder itself, so a prefix handed over between peers must not come back.
func TestRemoveEndpointAddressDoesNotStealAPrefixFromAnotherPeer(t *testing.T) {
c := newTestUSPConfigurer(t)
keys := seedPeers(t, c, 2)
peerA, peerB := keys[0], keys[1]
routed := netip.MustParsePrefix("10.20.0.0/16")
require.NoError(t, c.AddAllowedIP(peerA, routed), "give the prefix to A")
require.Contains(t, peerAllowedIPs(t, c, peerA), routed.String(), "A must hold the prefix")
// The route moves to B. The device takes it away from A on its own.
require.NoError(t, c.AddAllowedIP(peerB, routed), "hand the prefix over to B")
require.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must hold the prefix")
require.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(), "the device must have taken it from A")
require.NoError(t, c.RemoveEndpointAddress(peerA), "clear A's endpoint")
assert.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(),
"clearing A's endpoint must not take the prefix back from B")
assert.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(),
"B must still hold the prefix")
}
// TestPresharedKeyCreatedPeerTakesPartInPrefixHandover covers a peer created by a preshared
// key write rather than by a peer update. Rosenpass applies a peer's first key without
// updateOnly, which creates the peer on the device, so a store that ignored that operation
// would treat the peer as unknown and would not account for a prefix later handed over to it.
func TestPresharedKeyCreatedPeerTakesPartInPrefixHandover(t *testing.T) {
c := newTestUSPConfigurer(t)
peerA := seedPeers(t, c, 1)[0]
routed := netip.MustParsePrefix("10.20.0.0/16")
require.NoError(t, c.AddAllowedIP(peerA, routed), "give the prefix to A")
priv, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err, "generate peer private key")
peerB := priv.PublicKey().String()
psk, err := wgtypes.GenerateKey()
require.NoError(t, err, "generate preshared key")
require.NoError(t, c.SetPresharedKey(peerB, psk, false), "a first key creates the peer")
require.NoError(t, c.AddAllowedIP(peerB, routed), "hand the prefix over to B")
require.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must hold the prefix")
require.NoError(t, c.RemoveEndpointAddress(peerA), "clear A's endpoint")
assert.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(),
"clearing A's endpoint must not take the prefix back from B")
assert.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must still hold the prefix")
}
// TestUpdatePeerDoesNotWidenAMappedPrefixOnTheDevice is the end to end form of the
// conversion: a v4-mapped prefix must not reach the device as a zero length allowed IP,
// which would route every v4 address to that peer.
func TestUpdatePeerDoesNotWidenAMappedPrefixOnTheDevice(t *testing.T) {
c := newTestUSPConfigurer(t)
priv, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err, "generate peer private key")
peerKey := priv.PublicKey().String()
mapped := netip.MustParsePrefix("::ffff:10.1.2.3/112")
require.NoError(t, c.UpdatePeer(peerKey, []netip.Prefix{mapped}, 25*time.Second, nil, nil), "add peer")
onDevice := peerAllowedIPs(t, c, peerKey)
assert.NotContains(t, onDevice, "0.0.0.0/0", "the device must not be given a catch-all allowed IP")
assert.Equal(t, []string{"10.1.0.0/16"}, onDevice, "the device holds the normalized prefix")
}
// TestUpdatePeerWithAnUnusableEndpointTouchesNothing pins the ordering: the endpoint is
// parsed before the device is configured, so a failure cannot leave a half-configured peer
// on the device.
func TestUpdatePeerWithAnUnusableEndpointTouchesNothing(t *testing.T) {
c := newTestUSPConfigurer(t)
seedPeers(t, c, 2)
priv, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err, "generate peer private key")
peerKey := priv.PublicKey().String()
// A three byte address has no textual form netip can parse back.
endpoint := &net.UDPAddr{IP: net.IP{1, 2, 3}, Port: 51820}
require.Error(t, c.UpdatePeer(peerKey, []netip.Prefix{netip.MustParsePrefix("10.30.0.0/16")},
25*time.Second, endpoint, nil), "an unusable endpoint must fail the update")
stats, err := c.FullStats()
require.NoError(t, err, "read device stats")
assert.Len(t, stats.Peers, 2, "the peer must not have reached the device")
}