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.
513 lines
14 KiB
Go
513 lines
14 KiB
Go
//go:build !android && !ios && privileged
|
|
|
|
package systemops
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
|
|
nbnet "github.com/netbirdio/netbird/client/net"
|
|
)
|
|
|
|
func TestAddVPNRoute(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
prefix netip.Prefix
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "IPv4 - Private network route",
|
|
prefix: netip.MustParsePrefix("10.10.100.0/24"),
|
|
},
|
|
{
|
|
name: "IPv4 Single host",
|
|
prefix: netip.MustParsePrefix("10.111.111.111/32"),
|
|
},
|
|
{
|
|
name: "IPv4 RFC3927 test range",
|
|
prefix: netip.MustParsePrefix("198.51.100.0/24"),
|
|
},
|
|
{
|
|
name: "IPv4 Default route",
|
|
prefix: netip.MustParsePrefix("0.0.0.0/0"),
|
|
},
|
|
|
|
{
|
|
name: "IPv6 Subnet",
|
|
prefix: netip.MustParsePrefix("fdb1:848a:7e16::/48"),
|
|
},
|
|
{
|
|
name: "IPv6 Single host",
|
|
prefix: netip.MustParsePrefix("fdb1:848a:7e16:a::b/128"),
|
|
},
|
|
{
|
|
name: "IPv6 Default route",
|
|
prefix: netip.MustParsePrefix("::/0"),
|
|
},
|
|
|
|
// IPv4 addresses that should be rejected (matches validateRoute logic)
|
|
{
|
|
name: "IPv4 Loopback",
|
|
prefix: netip.MustParsePrefix("127.0.0.1/32"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv4 Link-local unicast",
|
|
prefix: netip.MustParsePrefix("169.254.1.1/32"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv4 Link-local multicast",
|
|
prefix: netip.MustParsePrefix("224.0.0.251/32"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv4 Multicast",
|
|
prefix: netip.MustParsePrefix("239.255.255.250/32"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv4 Unspecified with prefix",
|
|
prefix: netip.MustParsePrefix("0.0.0.0/32"),
|
|
expectError: true,
|
|
},
|
|
|
|
// IPv6 addresses that should be rejected (matches validateRoute logic)
|
|
{
|
|
name: "IPv6 Loopback",
|
|
prefix: netip.MustParsePrefix("::1/128"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv6 Link-local unicast",
|
|
prefix: netip.MustParsePrefix("fe80::1/128"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv6 Link-local multicast",
|
|
prefix: netip.MustParsePrefix("ff02::1/128"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv6 Interface-local multicast",
|
|
prefix: netip.MustParsePrefix("ff01::1/128"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv6 Multicast",
|
|
prefix: netip.MustParsePrefix("ff00::1/128"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv6 Unspecified with prefix",
|
|
prefix: netip.MustParsePrefix("::/128"),
|
|
expectError: true,
|
|
},
|
|
|
|
{
|
|
name: "IPv4 WireGuard interface network overlap",
|
|
prefix: netip.MustParsePrefix("100.65.75.0/24"),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "IPv4 WireGuard interface network subnet",
|
|
prefix: netip.MustParsePrefix("100.65.75.0/32"),
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for n, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
t.Setenv("NB_DISABLE_ROUTE_CACHE", "true")
|
|
|
|
wgInterface := createWGInterface(t, fmt.Sprintf("utun53%d", n), "100.65.75.2/24", 33100+n)
|
|
|
|
r := New(wgInterface, nil)
|
|
advancedRouting := nbnet.AdvancedRouting()
|
|
err := r.SetupRouting(nil, nil, advancedRouting)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, r.CleanupRouting(nil, advancedRouting))
|
|
})
|
|
|
|
intf, err := net.InterfaceByName(wgInterface.Name())
|
|
require.NoError(t, err)
|
|
|
|
// add the route
|
|
err = r.AddVPNRoute(testCase.prefix, intf)
|
|
if testCase.expectError {
|
|
assert.ErrorIs(t, err, vars.ErrRouteNotAllowed)
|
|
return
|
|
}
|
|
|
|
// validate it's pointing to the WireGuard interface
|
|
require.NoError(t, err)
|
|
|
|
nextHop := getNextHop(t, testCase.prefix.Addr())
|
|
assert.Equal(t, wgInterface.Name(), nextHop.Intf.Name, "next hop interface should be WireGuard interface")
|
|
|
|
// remove route again
|
|
err = r.RemoveVPNRoute(testCase.prefix, intf)
|
|
require.NoError(t, err)
|
|
|
|
// validate it's gone
|
|
nextHop, err = GetNextHop(testCase.prefix.Addr())
|
|
require.True(t,
|
|
errors.Is(err, vars.ErrRouteNotFound) || err == nil && nextHop.Intf != nil && nextHop.Intf.Name != wgInterface.Name(),
|
|
"err: %v, next hop: %v", err, nextHop)
|
|
})
|
|
}
|
|
}
|
|
|
|
func getNextHop(t *testing.T, addr netip.Addr) Nexthop {
|
|
t.Helper()
|
|
|
|
if runtime.GOOS == "windows" || runtime.GOOS == "linux" {
|
|
nextHop, err := GetNextHop(addr)
|
|
|
|
if runtime.GOOS == "windows" && errors.Is(err, vars.ErrRouteNotFound) && addr.Is6() {
|
|
// TODO: Fix this test. It doesn't return the route when running in a windows github runner, but it is
|
|
// present in the route table.
|
|
t.Skip("Skipping windows test")
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, nextHop.Intf, "next hop interface should not be nil for %s", addr)
|
|
|
|
return nextHop
|
|
}
|
|
// GetNextHop for bsd is buggy and returns the wrong interface for the default route.
|
|
|
|
if addr.IsUnspecified() {
|
|
// On macOS, querying 0.0.0.0 returns the wrong interface
|
|
if addr.Is4() {
|
|
addr = netip.MustParseAddr("1.2.3.4")
|
|
} else {
|
|
addr = netip.MustParseAddr("2001:db8::1")
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command("route", "-n", "get", addr.String())
|
|
if addr.Is6() {
|
|
cmd = exec.Command("route", "-n", "get", "-inet6", addr.String())
|
|
}
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
t.Logf("route output: %s", output)
|
|
require.NoError(t, err, "%s failed")
|
|
|
|
lines := strings.Split(string(output), "\n")
|
|
var intf string
|
|
var gateway string
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "interface:") {
|
|
intf = strings.TrimSpace(strings.TrimPrefix(line, "interface:"))
|
|
} else if strings.HasPrefix(line, "gateway:") {
|
|
gateway = strings.TrimSpace(strings.TrimPrefix(line, "gateway:"))
|
|
}
|
|
}
|
|
|
|
require.NotEmpty(t, intf, "interface should be found in route output")
|
|
|
|
iface, err := net.InterfaceByName(intf)
|
|
require.NoError(t, err, "interface %s should exist", intf)
|
|
|
|
nexthop := Nexthop{Intf: iface}
|
|
|
|
if gateway != "" && gateway != "link#"+strconv.Itoa(iface.Index) {
|
|
addr, err := netip.ParseAddr(gateway)
|
|
if err == nil {
|
|
nexthop.IP = addr
|
|
}
|
|
}
|
|
|
|
return nexthop
|
|
}
|
|
|
|
func TestAddRouteToNonVPNIntf(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
prefix netip.Prefix
|
|
expectError bool
|
|
errorType error
|
|
}{
|
|
{
|
|
name: "IPv4 RFC3927 test range",
|
|
prefix: netip.MustParsePrefix("198.51.100.0/24"),
|
|
},
|
|
{
|
|
name: "IPv4 Single host",
|
|
prefix: netip.MustParsePrefix("8.8.8.8/32"),
|
|
},
|
|
{
|
|
name: "IPv6 External network route",
|
|
prefix: netip.MustParsePrefix("2001:db8:1000::/48"),
|
|
},
|
|
{
|
|
name: "IPv6 Single host",
|
|
prefix: netip.MustParsePrefix("2001:db8::1/128"),
|
|
},
|
|
{
|
|
name: "IPv6 Subnet",
|
|
prefix: netip.MustParsePrefix("2a05:d014:1f8d::/48"),
|
|
},
|
|
{
|
|
name: "IPv6 Single host",
|
|
prefix: netip.MustParsePrefix("2a05:d014:1f8d:7302:ebca:ec15:b24d:d07e/128"),
|
|
},
|
|
|
|
// Addresses that should be rejected
|
|
{
|
|
name: "IPv4 Loopback",
|
|
prefix: netip.MustParsePrefix("127.0.0.1/32"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv4 Link-local unicast",
|
|
prefix: netip.MustParsePrefix("169.254.1.1/32"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv4 Multicast",
|
|
prefix: netip.MustParsePrefix("239.255.255.250/32"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv4 Unspecified",
|
|
prefix: netip.MustParsePrefix("0.0.0.0/0"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv6 Loopback",
|
|
prefix: netip.MustParsePrefix("::1/128"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv6 Link-local unicast",
|
|
prefix: netip.MustParsePrefix("fe80::1/128"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv6 Multicast",
|
|
prefix: netip.MustParsePrefix("ff00::1/128"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv6 Unspecified",
|
|
prefix: netip.MustParsePrefix("::/0"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
{
|
|
name: "IPv4 WireGuard interface network overlap",
|
|
prefix: netip.MustParsePrefix("100.65.75.0/24"),
|
|
expectError: true,
|
|
errorType: vars.ErrRouteNotAllowed,
|
|
},
|
|
}
|
|
|
|
for n, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
t.Setenv("NB_DISABLE_ROUTE_CACHE", "true")
|
|
|
|
wgInterface := createWGInterface(t, fmt.Sprintf("utun54%d", n), "100.65.75.2/24", 33200+n)
|
|
|
|
r := New(wgInterface, nil)
|
|
advancedRouting := nbnet.AdvancedRouting()
|
|
err := r.SetupRouting(nil, nil, advancedRouting)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, r.CleanupRouting(nil, advancedRouting))
|
|
})
|
|
|
|
initialNextHopV4, err := GetNextHop(netip.IPv4Unspecified())
|
|
require.NoError(t, err, "Should be able to get IPv4 default route")
|
|
t.Logf("Initial IPv4 next hop: %s", initialNextHopV4)
|
|
|
|
if testCase.prefix.Addr().Is6() && !testCase.expectError {
|
|
ensureIPv6DefaultRoute(t)
|
|
}
|
|
|
|
initialNextHopV6, err := GetNextHop(netip.IPv6Unspecified())
|
|
if testCase.prefix.Addr().Is6() &&
|
|
initialNextHopV6.Intf != nil && strings.HasPrefix(initialNextHopV6.Intf.Name, "utun") {
|
|
t.Skip("Skipping test as no ipv6 default route is available")
|
|
}
|
|
if err != nil && !errors.Is(err, vars.ErrRouteNotFound) {
|
|
t.Fatalf("Failed to get IPv6 default route: %v", err)
|
|
}
|
|
|
|
var initialNextHop Nexthop
|
|
if testCase.prefix.Addr().Is6() {
|
|
initialNextHop = initialNextHopV6
|
|
} else {
|
|
initialNextHop = initialNextHopV4
|
|
}
|
|
|
|
nexthop, err := r.addRouteToNonVPNIntf(testCase.prefix, wgInterface, initialNextHop)
|
|
|
|
if testCase.expectError {
|
|
require.ErrorIs(t, err, vars.ErrRouteNotAllowed)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
t.Logf("Next hop for %s: %s", testCase.prefix, nexthop)
|
|
|
|
// Verify the route was added and points to non-VPN interface
|
|
currentNextHop, err := GetNextHop(testCase.prefix.Addr())
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, wgInterface.Name(), currentNextHop.Intf.Name, "Route should not point to VPN interface")
|
|
|
|
err = r.removeFromRouteTable(testCase.prefix, nexthop)
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetNextHop(t *testing.T) {
|
|
defaultNh, err := GetNextHop(netip.MustParseAddr("0.0.0.0"))
|
|
if err != nil {
|
|
t.Fatal("shouldn't return error when fetching the gateway: ", err)
|
|
}
|
|
if !defaultNh.IP.IsValid() {
|
|
t.Fatal("should return a gateway")
|
|
}
|
|
addresses, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
t.Fatal("shouldn't return error when fetching interface addresses: ", err)
|
|
}
|
|
|
|
var testingPrefix netip.Prefix
|
|
for _, address := range addresses {
|
|
if address.Network() != "ip+net" {
|
|
continue
|
|
}
|
|
prefix := netip.MustParsePrefix(address.String())
|
|
if !prefix.Addr().IsLoopback() && prefix.Addr().Is4() {
|
|
testingPrefix = prefix.Masked()
|
|
break
|
|
}
|
|
}
|
|
|
|
nh, err := GetNextHop(testingPrefix.Addr())
|
|
if err != nil {
|
|
t.Fatal("shouldn't return error: ", err)
|
|
}
|
|
if nh.Intf == nil {
|
|
t.Fatal("should return a gateway for local network")
|
|
}
|
|
if nh.IP.String() == defaultNh.IP.String() {
|
|
t.Fatal("next hop IP should not match with default gateway IP")
|
|
}
|
|
if nh.Intf.Name != defaultNh.Intf.Name {
|
|
t.Fatalf("next hop interface name should match with default gateway interface name, got: %s, want: %s", nh.Intf.Name, defaultNh.Intf.Name)
|
|
}
|
|
}
|
|
|
|
func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listenPort int) *iface.WGIface {
|
|
t.Helper()
|
|
|
|
peerPrivateKey, err := wgtypes.GeneratePrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist, nil)
|
|
|
|
opts := iface.WGIFaceOpts{
|
|
IFaceName: interfaceName,
|
|
Address: wgaddr.MustParseWGAddress(ipAddressCIDR),
|
|
WGPrivKey: peerPrivateKey.String(),
|
|
WGPort: listenPort,
|
|
MTU: iface.DefaultMTU,
|
|
TransportNet: newNet,
|
|
}
|
|
wgInterface, err := iface.NewWGIFace(opts)
|
|
require.NoError(t, err, "should create testing WireGuard interface")
|
|
|
|
err = wgInterface.Create()
|
|
require.NoError(t, err, "should create testing WireGuard interface")
|
|
|
|
t.Cleanup(func() {
|
|
wgInterface.Close()
|
|
})
|
|
|
|
return wgInterface
|
|
}
|
|
|
|
func setupRouteAndCleanup(t *testing.T, r *SysOps, prefix netip.Prefix, intf *net.Interface) {
|
|
t.Helper()
|
|
|
|
if err := r.AddVPNRoute(prefix, intf); err != nil {
|
|
if !errors.Is(err, syscall.EEXIST) && !errors.Is(err, vars.ErrRouteNotAllowed) {
|
|
t.Fatalf("addVPNRoute should not return err: %v", err)
|
|
}
|
|
t.Logf("addVPNRoute %v returned: %v", prefix, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := r.RemoveVPNRoute(prefix, intf); err != nil && !errors.Is(err, vars.ErrRouteNotAllowed) {
|
|
t.Fatalf("removeVPNRoute should not return err: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func setupTestEnv(t *testing.T) {
|
|
t.Helper()
|
|
|
|
setupDummyInterfacesAndRoutes(t)
|
|
|
|
wgInterface := createWGInterface(t, expectedVPNint, "100.64.0.1/24", 51820)
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, wgInterface.Close())
|
|
})
|
|
|
|
r := New(wgInterface, nil)
|
|
advancedRouting := nbnet.AdvancedRouting()
|
|
err := r.SetupRouting(nil, nil, advancedRouting)
|
|
require.NoError(t, err, "setupRouting should not return err")
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, r.CleanupRouting(nil, advancedRouting))
|
|
})
|
|
|
|
index, err := net.InterfaceByName(wgInterface.Name())
|
|
require.NoError(t, err, "InterfaceByName should not return err")
|
|
intf := &net.Interface{Index: index.Index, Name: wgInterface.Name()}
|
|
|
|
// default route exists in main table and vpn table
|
|
setupRouteAndCleanup(t, r, netip.MustParsePrefix("0.0.0.0/0"), intf)
|
|
|
|
// 10.0.0.0/8 route exists in main table and vpn table
|
|
setupRouteAndCleanup(t, r, netip.MustParsePrefix("10.0.0.0/8"), intf)
|
|
|
|
// 10.10.0.0/24 more specific route exists in vpn table
|
|
setupRouteAndCleanup(t, r, netip.MustParsePrefix("10.10.0.0/24"), intf)
|
|
|
|
// unique route in vpn table
|
|
setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf)
|
|
}
|