Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	client/internal/ebpf/ebpf/manager_linux.go
#	client/internal/ebpf/ebpf/manager_linux_test.go
#	client/internal/ebpf/manager/manager.go
This commit is contained in:
Viktor Liu
2026-09-23 07:43:31 +02:00
339 changed files with 17843 additions and 3440 deletions
+84 -14
View File
@@ -124,19 +124,9 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
return nil, err
}
var useGPO bool
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
if err != nil {
log.Debugf("failed to open GPO DNS policy root: %v", err)
} else {
closer(k)
useGPO = true
log.Infof("detected GPO DNS policy configuration, using policy store")
}
configurator := &registryConfigurator{
guid: guid,
gpo: useGPO,
gpo: useGPOPolicyStore(),
}
origNameservers, err := configurator.captureOriginalNameservers()
@@ -576,14 +566,22 @@ func (r *registryConfigurator) setInterfaceRegistryKeyStringValue(key, value str
return nil
}
// deleteInterfaceRegistryKeyProperty removes a value from the interface key.
// A value that is already gone, or an interface key that is, is not an error:
// the caller asked for the value not to be there, and a cleanup that runs twice
// has to reach its later steps on the second run as well.
func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey string) error {
regKey, err := r.getInterfaceRegistryKey()
if err != nil {
switch {
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
log.Debugf("interface key of %s does not exist, nothing to delete %s from", r.guid, propertyKey)
return nil
case err != nil:
return fmt.Errorf("get interface registry key: %w", err)
}
defer closer(regKey)
if err := regKey.DeleteValue(propertyKey); err != nil {
if err := regKey.DeleteValue(propertyKey); err != nil && !errors.Is(err, registry.ErrNotExist) {
return fmt.Errorf("delete registry key %s: %w", propertyKey, err)
}
return nil
@@ -612,7 +610,12 @@ func (r *registryConfigurator) restoreHostDNS() error {
go r.flushDNSCache()
return nil
// Last, and only on the way out, once no rule of ours is left: during a
// session the store is where the rules of this run live, and emptying it
// mid-session would have the next rule recreate it anyway. Propagated so a
// failure keeps the shutdown state for the next run to retry, rather than
// leaving the store to hold up every rule change from here on.
return removeEmptyGPOPolicyStore()
}
// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
@@ -651,6 +654,73 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
return r.restoreHostDNS()
}
// useGPOPolicyStore reports whether NRPT rules have to go into the group policy
// store, and clears an empty one out of the way first.
//
// The order is the point. A store left empty by an earlier run would otherwise
// decide this run too, sending its rules somewhere the resolver only reads when
// the policy engine next applies DNS client policy. Removing it before the
// choice is made leaves the local store authoritative for the whole session,
// including the first one after an upgrade.
func useGPOPolicyStore() bool {
if err := removeEmptyGPOPolicyStore(); err != nil {
// Nothing to retry against here: the worst case is the run going
// through the group policy store, which is where it would have gone
// before this check existed.
log.Warnf("%v", err)
}
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
if err != nil {
log.Debugf("failed to open GPO DNS policy root: %v", err)
return false
}
closer(k)
log.Infof("detected GPO DNS policy configuration, using policy store")
return true
}
// removeEmptyGPOPolicyStore deletes the group policy DnsPolicyConfig key once
// nothing is left in it. The key survives the deletion of the last rule it
// held, and the client treats its presence as "group policy configures the
// NRPT", so an empty one left behind keeps every later run writing rules there.
// Rules in that store reach the resolver only when the policy engine next
// applies DNS client policy, and a rule this client writes belongs to no GPO,
// so nothing schedules that application: both adding and removing a rule are
// held up by a minute or more, and for a removal that is a catch-all rule
// resolving every name over an interface that no longer exists. With the store
// absent the local one is authoritative and a change applies at once.
//
// A store that still holds rules, values or subkeys of somebody else's is left
// alone.
func removeEmptyGPOPolicyStore() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
switch {
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
return nil
case err != nil:
return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
}
info, err := k.Stat()
closer(k)
if err != nil {
return fmt.Errorf("stat HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
}
if info.SubKeyCount != 0 || info.ValueCount != 0 {
return nil
}
if err := registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot); err != nil {
return fmt.Errorf("delete empty HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
}
log.Infof("removed the empty GPO DNS policy store, leaving the local one authoritative")
return nil
}
// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
// root. An absent root holds nothing to clean up, which is the normal state of
// the GPO store on a machine without DNS Client policy.
+129
View File
@@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows/registry"
"github.com/netbirdio/netbird/client/internal/winregistry"
)
// TestNRPTEntriesCleanupOnConfigChange tests that old NRPT entries are properly cleaned up
@@ -405,3 +407,130 @@ func TestNRPTDomainBatching(t *testing.T) {
})
}
}
// TestRemoveEmptyGPOPolicyStore verifies that cleanup takes the GPO policy
// store itself with it once our rules are gone, since the store existing keeps
// the local one from being applied, and that a store with somebody else's rule
// in it is left alone.
func TestRemoveEmptyGPOPolicyStore(t *testing.T) {
if testing.Short() {
t.Skip("skipping registry integration test in short mode")
}
t.Cleanup(func() { cleanupRegistryKeys(t) })
cleanupRegistryKeys(t)
testIP := netip.MustParseAddr("100.64.0.1")
cfg := &registryConfigurator{gpo: true}
// a store holding a rule of ours is kept, because the rule is still applied
require.NoError(t, cfg.addDNSMatchPolicy([]string{".example.com"}, testIP))
exists, err := registryKeyExists(gpoDnsPolicyConfigMatchPath + "-0")
require.NoError(t, err)
require.True(t, exists, "Should write the rule to the GPO policy store")
require.NoError(t, removeEmptyGPOPolicyStore())
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.True(t, exists, "Should keep a policy store that still holds a rule")
// once the rules are gone the store goes with them
require.NoError(t, cfg.removeDNSMatchPolicies())
require.NoError(t, removeEmptyGPOPolicyStore())
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.False(t, exists, "Should remove the GPO policy store once it is empty")
// A store is not ours to remove while somebody else has a rule in it. The
// rule is written volatile like our own: the rules above created the parent
// chain volatile, and Windows refuses a stable subkey under a volatile
// parent.
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
require.NoError(t, err, "Should create a foreign GPO rule")
foreignKey.Close()
t.Cleanup(func() {
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
})
require.NoError(t, cfg.removeDNSMatchPolicies())
require.NoError(t, removeEmptyGPOPolicyStore())
exists, err = registryKeyExists(foreignRule)
require.NoError(t, err)
assert.True(t, exists, "Should not remove a foreign rule")
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.True(t, exists, "Should keep a policy store that still holds a foreign rule")
}
// TestDeleteInterfaceRegistryKeyPropertyTwice verifies that removing a value
// that is already gone, or one on an interface key that is, reports success.
// Teardown runs again after a failed cleanup, and the steps that follow this
// one have to be reached on that second run.
func TestDeleteInterfaceRegistryKeyPropertyTwice(t *testing.T) {
if testing.Short() {
t.Skip("skipping registry integration test in short mode")
}
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
interfacePath := InterfaceConfigPath + `\` + testGUID
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
require.NoError(t, err, "Should create test interface registry key")
testKey.Close()
t.Cleanup(func() {
_ = registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath)
})
cfg := &registryConfigurator{guid: testGUID}
require.NoError(t, cfg.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, "example.com"))
require.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey))
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
"Should report success for a value that is already gone")
// and with the interface key itself gone, as it is once the adapter is
require.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath))
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
"Should report success when the interface key does not exist")
}
// TestUseGPOPolicyStoreClearsEmptyStore verifies that the store is cleared
// before it is consulted, so an empty one left by an earlier run does not send
// this run's rules to the group policy store. A store somebody else has a rule
// in still decides where the rules go.
func TestUseGPOPolicyStoreClearsEmptyStore(t *testing.T) {
if testing.Short() {
t.Skip("skipping registry integration test in short mode")
}
t.Cleanup(func() { cleanupRegistryKeys(t) })
cleanupRegistryKeys(t)
// the leftover an earlier run used to keep, which the client read as
// "group policy configures the NRPT" for every run after it
emptyStore, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.SET_VALUE)
require.NoError(t, err, "Should create the GPO policy store")
emptyStore.Close()
assert.False(t, useGPOPolicyStore(), "An empty store should not decide where the rules go")
exists, err := registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.False(t, exists, "Should clear the empty store before consulting it")
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
require.NoError(t, err, "Should create a foreign GPO rule")
foreignKey.Close()
t.Cleanup(func() {
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
})
assert.True(t, useGPOPolicyStore(), "A store holding a rule should decide where the rules go")
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.True(t, exists, "Should keep a store that holds a rule")
}
+136 -87
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/netip"
"runtime"
"slices"
"strconv"
"sync"
"time"
@@ -17,17 +18,20 @@ import (
nberrors "github.com/netbirdio/netbird/client/errors"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/internal/ebpf"
ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
customPort = 5053
// randomPortAttempts bounds the search for a port free on both protocols.
randomPortAttempts = 5
)
var (
defaultIP = netip.MustParseAddr("127.0.0.1")
customIP = netip.MustParseAddr("127.0.0.153")
// dnatProtocols are the protocols the port 53 redirect covers.
dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP}
)
type serviceViaListener struct {
@@ -40,9 +44,20 @@ type serviceViaListener struct {
listenPort uint16
listenerIsRunning bool
listenerFlagLock sync.Mutex
ebpfService ebpfMgr.Manager
firewall Firewall
tcpDNATConfigured bool
// dnatRules holds the port 53 redirects that are installed and not yet
// removed, so a removal that fails can be retried.
dnatRules []dnatRule
}
// dnatRule is a port 53 redirect as it was installed. The target is kept with
// the rule because the listener can come back on a different address or port,
// and a retried removal has to name the address and port the rule was added
// with, not the ones in use now.
type dnatRule struct {
protocol firewall.Protocol
ip netip.Addr
port uint16
}
func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener {
@@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error {
}
}()
// When eBPF redirects UDP port 53 to our listen port, TCP still needs
// a DNAT rule because eBPF only handles UDP.
if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort {
if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err)
} else {
s.tcpDNATConfigured = true
log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
if s.listenPort != DefaultPort {
s.setupDNAT()
}
return nil
}
// setupDNAT redirects port 53 to the port the DNS server actually listens on.
// Both protocols must be redirected or none: RuntimePort reports port 53 only
// while the full redirect is in place, so a half-configured redirect would
// advertise a resolver that answers over one protocol.
func (s *serviceViaListener) setupDNAT() {
if s.firewall == nil {
log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+
"clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP)
return
}
// Clear whatever an earlier removal left behind first. Those rules can point
// at an address or port this listener no longer uses, and they are matched
// before anything added now, so adding a redirect on top of one would keep
// sending port 53 traffic to the previous listener while reporting the
// redirect as complete. The rules stay recorded for a later attempt.
if err := s.removeDNAT(); err != nil {
log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v",
DefaultPort, err)
return
}
for _, proto := range dnatProtocols {
if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil {
log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v",
proto, DefaultPort, err)
if err := s.removeDNAT(); err != nil {
log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err)
}
return
}
s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort})
}
log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
// removeDNAT removes every installed port 53 redirect. A rule whose removal
// fails stays recorded so a later setup or Stop retries it, rather than leaving
// port 53 pointing at a resolver that is no longer listening.
func (s *serviceViaListener) removeDNAT() error {
if s.firewall == nil {
return nil
}
var merr *multierror.Error
var remaining []dnatRule
for _, rule := range s.dnatRules {
if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w",
rule.protocol, rule.ip, rule.port, err))
remaining = append(remaining, rule)
}
}
s.dnatRules = remaining
return nberrors.FormatErrorOrNil(merr)
}
func (s *serviceViaListener) Stop() error {
s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock()
var merr *multierror.Error
// Redirects are removed even when the listener is already stopped, so that
// a removal which failed earlier is retried instead of leaving port 53
// pointing at a resolver that no longer listens.
if err := s.removeDNAT(); err != nil {
merr = multierror.Append(merr, err)
}
if !s.listenerIsRunning {
return nil
return nberrors.FormatErrorOrNil(merr)
}
s.listenerIsRunning = false
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var merr *multierror.Error
if err := s.server.ShutdownContext(ctx); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err))
}
@@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error {
merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err))
}
if s.tcpDNATConfigured && s.firewall != nil {
if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err))
}
s.tcpDNATConfigured = false
}
if s.ebpfService != nil {
if err := s.ebpfService.FreeDNSFwd(); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err))
}
}
return nberrors.FormatErrorOrNil(merr)
}
@@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int {
s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock()
if s.ebpfService != nil {
if s.redirectInstalled() {
return DefaultPort
} else {
return int(s.listenPort)
}
return int(s.listenPort)
}
// redirectInstalled reports whether every protocol is redirected from port 53
// to the address and port the listener currently serves. Rules left over from
// an earlier listener do not count.
func (s *serviceViaListener) redirectInstalled() bool {
for _, proto := range dnatProtocols {
current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}
if !slices.Contains(s.dnatRules, current) {
return false
}
}
return true
}
func (s *serviceViaListener) RuntimeIP() netip.Addr {
@@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr {
// evalListenAddress figures out the listen address for the DNS server.
// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4.
// First checks port 53 on WG interface or lo, then tries eBPF on a random port,
// then falls back to port 5053.
// Prefers port 53 on the overlay interface or lo, so no redirect is needed at
// all; when it is taken it falls back to port 5053 and then to a random free
// port, both of which need the port 53 redirect set up by setupDNAT.
func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) {
if s.customAddr != nil {
return s.customAddr.Addr(), s.customAddr.Port(), nil
}
ip, ok := s.testFreePort(DefaultPort)
if ok {
if ip, ok := s.testFreePort(DefaultPort); ok {
return ip, DefaultPort, nil
}
ebpfSrv, port, ok := s.tryToUseeBPF()
if ok {
s.ebpfService = ebpfSrv
return s.wgInterface.Address().IP, port, nil
}
ip, ok = s.testFreePort(customPort)
if ok {
if ip, ok := s.testFreePort(customPort); ok {
return ip, customPort, nil
}
return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server")
ip := s.wgInterface.Address().IP
port, err := s.randomFreePort(ip)
if err != nil {
return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err)
}
return ip, port, nil
}
func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
@@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
return true
}
// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53.
// This is needed because on some operating systems if we start a DNS server not on a default port 53,
// the domain name resolution won't work. So, in case we are running on Linux and picked a free
// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward
// it to a local DNS server running on the chosen port.
func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) {
if runtime.GOOS != "linux" {
return nil, 0, false
// randomFreePort returns a port that is free on ip for both UDP and TCP, since
// the DNS server binds both. The probe listeners are closed again, so the port
// is only likely, not guaranteed, to still be free when the server binds it.
func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) {
for range randomPortAttempts {
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
return 0, fmt.Errorf("bind random port: %w", err)
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err := probeListener.Close(); err != nil {
return 0, fmt.Errorf("free up probed port: %w", err)
}
if s.tryToBind(ip, int(port)) {
return port, nil
}
}
port, err := s.generateFreePort() //nolint:staticcheck,unused
if err != nil {
log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err)
return nil, 0, false
}
ebpfSrv := ebpf.GetEbpfManagerInstance()
err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port))
if err != nil {
log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err)
return nil, 0, false
}
return ebpfSrv, port, true
}
func (s *serviceViaListener) generateFreePort() (uint16, error) {
ok := s.tryToBind(s.wgInterface.Address().IP, customPort)
if ok {
return customPort, nil
}
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
log.Debugf("failed to bind random port for DNS: %s", err)
return 0, err
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err = probeListener.Close(); err != nil {
log.Debugf("failed to free up DNS port: %s", err)
return 0, err
}
return port, nil
return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts)
}
@@ -1,6 +1,7 @@
package dns
import (
"errors"
"fmt"
"net"
"net/netip"
@@ -10,6 +11,8 @@ import (
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
)
func TestServiceViaListener_TCPAndUDP(t *testing.T) {
@@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) {
require.NotEmpty(t, tcpResp.Answer)
assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP")
}
type dnatCall struct {
rule dnatRule
added bool
}
// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs.
type fakeFirewall struct {
calls []dnatCall
addErrs map[firewall.Protocol]error
removeErrs map[firewall.Protocol]error
}
func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.addErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true})
return nil
}
func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.removeErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}})
return nil
}
func newDNATTestService(fw Firewall) *serviceViaListener {
return &serviceViaListener{
listenIP: netip.MustParseAddr("100.64.0.1"),
listenPort: customPort,
firewall: fw,
}
}
func TestSetupDNAT_BothProtocols(t *testing.T) {
svc := newDNATTestService(&fakeFirewall{})
svc.setupDNAT()
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed")
}
func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) {
fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
svc.setupDNAT()
assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back")
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53")
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted")
}
// A rollback that fails must keep the rule recorded, so port 53 is not left
// redirected to a resolver that no longer listens.
func TestStop_RetriesFailedDNATRemoval(t *testing.T) {
fw := &fakeFirewall{
addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")},
removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")},
}
svc := newDNATTestService(fw)
svc.setupDNAT()
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry")
require.Error(t, svc.Stop(), "the failing removal should be reported")
require.Equal(t, []dnatRule{udp}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
require.NoError(t, svc.Stop(), "a later stop retries the removal")
assert.Empty(t, svc.dnatRules)
}
// A stale rule that cannot be removed is matched before anything added now, so
// no new redirect may be installed on top of it and port 53 must not be
// advertised as reaching this listener.
func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt")
for _, call := range fw.calls {
assert.False(t, call.added, "no redirect may be installed while a stale one is still in place")
}
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised")
}
// A rule left behind by a failed removal must be removed with the address and
// port it was installed with, even when the listener has since moved to another
// port, and it must not count towards the redirect the new listener advertises.
func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port")
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected")
for _, rule := range svc.dnatRules {
assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain")
}
}
+111 -14
View File
@@ -54,12 +54,20 @@ type DNSForwarder struct {
ttl uint32
statusRecorder *peer.Status
dnsServer *dns.Server
mux *dns.ServeMux
tcpServer *dns.Server
tcpMux *dns.ServeMux
mux *dns.ServeMux
tcpMux *dns.ServeMux
mutex sync.RWMutex
mutex sync.RWMutex
// closed records that Close has run, so a Listen still in flight does not
// go on to serve sockets nobody will shut down.
closed bool
// The sockets are kept alongside the servers because closing them is the
// only stop that always works: a server whose ActivateAndServe has not run
// yet refuses to shut down, and would otherwise start serving afterwards.
udpConn net.PacketConn
tcpLn net.Listener
dnsServer *dns.Server
tcpServer *dns.Server
fwdEntries []*ForwarderEntry
firewall firewaller
resolver resolver
@@ -106,7 +114,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
mux := dns.NewServeMux()
f.mux = mux
mux.HandleFunc(".", f.handleDNSQueryUDP)
f.dnsServer = &dns.Server{
dnsServer := &dns.Server{
PacketConn: udpLn,
Handler: mux,
}
@@ -114,22 +122,32 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
tcpMux := dns.NewServeMux()
f.tcpMux = tcpMux
tcpMux.HandleFunc(".", f.handleDNSQueryTCP)
f.tcpServer = &dns.Server{
tcpServer := &dns.Server{
Listener: tcpLn,
Handler: tcpMux,
}
f.UpdateDomains(entries)
if !f.publish(udpLn, tcpLn, dnsServer, tcpServer, entries) {
log.Infof("DNS forwarder on %s was closed before it started serving", addrDesc)
if err := udpLn.Close(); err != nil {
log.Debugf("close UDP listener of a closed forwarder: %v", err)
}
if err := tcpLn.Close(); err != nil {
log.Debugf("close TCP listener of a closed forwarder: %v", err)
}
return nil
}
log.Debugf("DNS forwarder serving %d domains", len(entries))
errCh := make(chan error, 2)
go func() {
log.Infof("DNS UDP listener running on %s", addrDesc)
errCh <- f.dnsServer.ActivateAndServe()
errCh <- dnsServer.ActivateAndServe()
}()
go func() {
log.Infof("DNS TCP listener running on %s", addrDesc)
errCh <- f.tcpServer.ActivateAndServe()
errCh <- tcpServer.ActivateAndServe()
}()
return <-errCh
@@ -151,6 +169,46 @@ func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listene
return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress))
}
// publish hands the sockets, servers and entries to the forwarder so Close can
// reach them and Domains can report them, and says whether serving may begin.
// Listen runs on its own goroutine, so a Close can arrive before it gets this
// far; false means the caller must close what it created instead of serving on
// it.
//
// The entries go in under the same lock rather than afterwards. Anything that
// reads them in between would otherwise see a forwarder that is listening and
// serves no domain, which for a caller rebuilding one means it comes back
// refusing every routed query.
func (f *DNSForwarder) publish(
udpConn net.PacketConn,
tcpLn net.Listener,
dnsServer, tcpServer *dns.Server,
entries []*ForwarderEntry,
) bool {
f.mutex.Lock()
defer f.mutex.Unlock()
if f.closed {
return false
}
f.udpConn = udpConn
f.tcpLn = tcpLn
f.dnsServer = dnsServer
f.tcpServer = tcpServer
f.fwdEntries = entries
return true
}
// Domains returns the entries currently being served. The slice is replaced
// wholesale by UpdateDomains rather than mutated, so the caller may read it but
// must not write to it.
func (f *DNSForwarder) Domains() []*ForwarderEntry {
f.mutex.RLock()
defer f.mutex.RUnlock()
return f.fwdEntries
}
func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) {
f.mutex.Lock()
defer f.mutex.Unlock()
@@ -189,19 +247,45 @@ func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*Forward
}
func (f *DNSForwarder) Close(ctx context.Context) error {
// Marked closed under the lock so a Listen that has not published its
// servers yet gives up instead of racing this shutdown. The shutdowns
// themselves block, so they run outside it.
f.mutex.Lock()
f.closed = true
dnsServer, tcpServer := f.dnsServer, f.tcpServer
udpConn, tcpLn := f.udpConn, f.tcpLn
f.mutex.Unlock()
var result *multierror.Error
if f.dnsServer != nil {
if err := f.dnsServer.ShutdownContext(ctx); err != nil {
if dnsServer != nil {
if err := shutdownServer(ctx, dnsServer); err != nil {
result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err))
}
}
if f.tcpServer != nil {
if err := f.tcpServer.ShutdownContext(ctx); err != nil {
if tcpServer != nil {
if err := shutdownServer(ctx, tcpServer); err != nil {
result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err))
}
}
// The sockets are closed even when the shutdowns above reported nothing to
// do. A server that has been published but has not reached
// ActivateAndServe refuses to shut down, and closing what it was about to
// serve on is what stops it: the alternative is a listener still answering
// on an interface that has gone away. A shutdown that did run has already
// closed these, so the second close is expected to fail.
if udpConn != nil {
if err := udpConn.Close(); err != nil {
log.Debugf("close UDP socket of the DNS forwarder: %v", err)
}
}
if tcpLn != nil {
if err := tcpLn.Close(); err != nil {
log.Debugf("close TCP socket of the DNS forwarder: %v", err)
}
}
return nberrors.FormatErrorOrNil(result)
}
@@ -514,3 +598,16 @@ func attachEDE(resp *dns.Msg, code uint16, text string) {
}
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
}
// shutdownServer shuts a server down gracefully, treating "never started" as
// success. A server that was published but has not reached ActivateAndServe
// has nothing to wind down, and the caller closes its socket regardless, which
// is what actually stops it. dns exports no sentinel for this, so the message
// is all there is to match on.
func shutdownServer(ctx context.Context, server *dns.Server) error {
err := server.ShutdownContext(ctx)
if err == nil || strings.Contains(err.Error(), "server not started") {
return nil
}
return err
}
+52
View File
@@ -1238,3 +1238,55 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) {
assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query")
}
// TestDNSForwarder_ClosedBeforeItServes covers Listen reaching the point of
// serving after the forwarder has already been closed. Listen runs on its own
// goroutine, so it can get there late, and a socket it starts serving then is
// one nothing will ever close: on Android it keeps answering on an interface
// that has been replaced. The close is sequenced first here rather than raced,
// which pins the same state deterministically.
func TestDNSForwarder_ClosedBeforeItServes(t *testing.T) {
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
require.NoError(t, f.Close(context.Background()), "closing a forwarder that never started")
done := make(chan error, 1)
go func() { done <- f.Listen(nil) }()
select {
case err := <-done:
assert.NoError(t, err, "a closed forwarder should give up quietly, not serve")
case <-time.After(5 * time.Second):
t.Fatal("Listen went on to serve after the forwarder was closed")
}
}
// TestDNSForwarder_CloseStopsUnactivatedServers covers the window between
// Listen publishing its servers and reaching ActivateAndServe. A server that
// has not been activated refuses to shut down, so Close has to close the
// sockets itself or they are left serving.
func TestDNSForwarder_CloseStopsUnactivatedServers(t *testing.T) {
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
udpConn, err := f.createUDPListener(nil)
require.NoError(t, err, "create UDP listener")
tcpLn, err := f.createTCPListener(nil)
require.NoError(t, err, "create TCP listener")
// Published but deliberately never activated, which is the state Listen is
// in for the moment before it starts serving.
require.True(t, f.publish(udpConn, tcpLn, &dns.Server{PacketConn: udpConn}, &dns.Server{Listener: tcpLn}, nil),
"publishing to an open forwarder")
tcpAddr := tcpLn.Addr().String()
require.NoError(t, f.Close(context.Background()), "close should report no error for servers it could not shut down")
_, err = tcpLn.Accept()
assert.Error(t, err, "the TCP socket should be closed after Close")
conn, err := net.DialTimeout("tcp", tcpAddr, time.Second)
if err == nil {
_ = conn.Close()
t.Fatal("the forwarder is still accepting connections after Close")
}
}
+10
View File
@@ -117,6 +117,16 @@ func (m *Manager) UpdateDomains(entries []*ForwarderEntry) {
m.dnsForwarder.UpdateDomains(entries)
}
// Domains returns the entries currently being served, or nil when the
// forwarder is not running.
func (m *Manager) Domains() []*ForwarderEntry {
if m.dnsForwarder == nil {
return nil
}
return m.dnsForwarder.Domains()
}
func (m *Manager) Stop(ctx context.Context) error {
if m.dnsForwarder == nil {
return nil
-128
View File
@@ -1,128 +0,0 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64
package ebpf
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}
return spec, err
}
// loadBpfObjects loads bpf and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *bpfObjects
// *bpfPrograms
// *bpfMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// bpfSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
}
// bpfSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
}
// bpfMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct {
bpfPrograms
bpfMaps
}
func (o *bpfObjects) Close() error {
return _BpfClose(
&o.bpfPrograms,
&o.bpfMaps,
)
}
// bpfMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.NbFeatures,
m.NbMapDnsIp,
m.NbMapDnsPort,
m.NbWgProxySettingsMap,
)
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfPrograms struct {
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
}
func (p *bpfPrograms) Close() error {
return _BpfClose(
p.NbXdpProg,
)
}
func _BpfClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed bpf_bpfeb.o
var _BpfBytes []byte
Binary file not shown.
-128
View File
@@ -1,128 +0,0 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64
package ebpf
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}
return spec, err
}
// loadBpfObjects loads bpf and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *bpfObjects
// *bpfPrograms
// *bpfMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// bpfSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
}
// bpfSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
}
// bpfMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct {
bpfPrograms
bpfMaps
}
func (o *bpfObjects) Close() error {
return _BpfClose(
&o.bpfPrograms,
&o.bpfMaps,
)
}
// bpfMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.NbFeatures,
m.NbMapDnsIp,
m.NbMapDnsPort,
m.NbWgProxySettingsMap,
)
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfPrograms struct {
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
}
func (p *bpfPrograms) Close() error {
return _BpfClose(
p.NbXdpProg,
)
}
func _BpfClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed bpf_bpfel.o
var _BpfBytes []byte
Binary file not shown.
@@ -1,52 +0,0 @@
package ebpf
import (
"encoding/binary"
"fmt"
"net/netip"
log "github.com/sirupsen/logrus"
)
const (
mapKeyDNSIP uint32 = 0
mapKeyDNSPort uint32 = 1
)
func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error {
log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort)
tf.lock.Lock()
defer tf.lock.Unlock()
err := tf.loadXdp()
if err != nil {
return err
}
if !ip.Is4() {
return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip)
}
ip4 := ip.As4()
err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:]))
if err != nil {
return err
}
err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort))
if err != nil {
return err
}
tf.setFeatureFlag(featureFlagDnsForwarder)
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
if err != nil {
return err
}
return nil
}
func (tf *GeneralManager) FreeDNSFwd() error {
log.Debugf("free ebpf DNS forwarder")
return tf.unsetFeatureFlag(featureFlagDnsForwarder)
}
-115
View File
@@ -1,115 +0,0 @@
package ebpf
import (
_ "embed"
"net"
"sync"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
mapKeyFeatures uint32 = 0
featureFlagDnsForwarder = 0b00000010
)
var (
singleton manager.Manager
singletonLock = &sync.Mutex{}
)
// required packages libbpf-dev, libc6-dev-i386-amd64-cross
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c).
//
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include
type GeneralManager struct {
lock sync.Mutex
link link.Link
featureFlags uint16
bpfObjs bpfObjects
}
// GetEbpfManagerInstance return a static eBpf Manager instance
func GetEbpfManagerInstance() manager.Manager {
singletonLock.Lock()
defer singletonLock.Unlock()
if singleton != nil {
return singleton
}
singleton = &GeneralManager{}
return singleton
}
func (tf *GeneralManager) setFeatureFlag(feature uint16) {
tf.featureFlags |= feature
}
func (tf *GeneralManager) loadXdp() error {
if tf.link != nil {
return nil
}
// it required for Docker
err := rlimit.RemoveMemlock()
if err != nil {
return err
}
iFace, err := net.InterfaceByName("lo")
if err != nil {
return err
}
// load pre-compiled programs into the kernel.
err = loadBpfObjects(&tf.bpfObjs, nil)
if err != nil {
return err
}
tf.link, err = link.AttachXDP(link.XDPOptions{
Program: tf.bpfObjs.NbXdpProg,
Interface: iFace.Index,
})
if err != nil {
_ = tf.bpfObjs.Close()
tf.link = nil
return err
}
return nil
}
func (tf *GeneralManager) unsetFeatureFlag(feature uint16) error {
tf.lock.Lock()
defer tf.lock.Unlock()
tf.featureFlags &^= feature
if tf.link == nil {
return nil
}
if tf.featureFlags == 0 {
return tf.close()
}
return tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
}
func (tf *GeneralManager) close() error {
log.Debugf("detach ebpf program ")
err := tf.bpfObjs.Close()
if err != nil {
log.Warnf("failed to close eBpf objects: %s", err)
}
err = tf.link.Close()
tf.link = nil
return err
}
@@ -1,44 +0,0 @@
package ebpf
import (
"testing"
)
// featureFlagTest stands in for a second feature flag, so the set and unset
// paths can be exercised with more than the one flag the manager defines.
const featureFlagTest = 0b00000001
func TestManager_setFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagTest)
if mgr.featureFlags != 1 {
t.Errorf("invalid feature state")
}
mgr.setFeatureFlag(featureFlagDnsForwarder)
if mgr.featureFlags != 3 {
t.Errorf("invalid feature state")
}
}
func TestManager_unsetFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagTest)
mgr.setFeatureFlag(featureFlagDnsForwarder)
err := mgr.unsetFeatureFlag(featureFlagTest)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if mgr.featureFlags != 2 {
t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags)
}
err = mgr.unsetFeatureFlag(featureFlagDnsForwarder)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if mgr.featureFlags != 0 {
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
}
}
-67
View File
@@ -1,67 +0,0 @@
const __u32 map_key_dns_ip = 0;
const __u32 map_key_dns_port = 1;
struct bpf_map_def SEC("maps") nb_map_dns_ip = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u32),
.max_entries = 10,
};
struct bpf_map_def SEC("maps") nb_map_dns_port = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
__be32 dns_ip = 0;
__be16 dns_port = 0;
// 13568 is 53 in big endian
__be16 GENERAL_DNS_PORT = 13568;
bool read_settings() {
__u16 *port_value;
__u32 *ip_value;
// read dns ip
ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip);
if(!ip_value) {
return false;
}
dns_ip = htonl(*ip_value);
// read dns port
port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port);
if (!port_value) {
return false;
}
dns_port = htons(*port_value);
return true;
}
int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (dns_port == 0) {
if(!read_settings()){
return XDP_PASS;
}
// bpf_printk("dns port: %d", ntohs(dns_port));
// bpf_printk("dns ip: %d", ntohl(dns_ip));
}
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}
return XDP_PASS;
}
-60
View File
@@ -1,60 +0,0 @@
#include <stdbool.h>
#include <linux/if_ether.h> // ETH_P_IP
#include <linux/udp.h>
#include <linux/ip.h>
#include <netinet/in.h>
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include "dns_fwd.c"
#include "wg_proxy.c"
const __u16 flag_feature_wg_proxy = 0b01;
const __u16 flag_feature_dns_fwd = 0b10;
const __u32 map_key_features = 0;
struct bpf_map_def SEC("maps") nb_features = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
SEC("xdp")
int nb_xdp_prog(struct xdp_md *ctx) {
__u16 *features;
features = bpf_map_lookup_elem(&nb_features, &map_key_features);
if (!features) {
return XDP_PASS;
}
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
struct iphdr *ip = (data + sizeof(struct ethhdr));
struct udphdr *udp = (data + sizeof(struct ethhdr) + sizeof(struct iphdr));
// return early if not enough data
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) > data_end){
return XDP_PASS;
}
// skip non IPv4 packages
if (eth->h_proto != htons(ETH_P_IP)) {
return XDP_PASS;
}
// skip non UPD packages
if (ip->protocol != IPPROTO_UDP) {
return XDP_PASS;
}
if (*features & flag_feature_dns_fwd) {
xdp_dns_fwd(ip, udp);
}
if (*features & flag_feature_wg_proxy) {
xdp_wg_proxy(ip, udp);
}
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
-17
View File
@@ -1,17 +0,0 @@
# DNS forwarder
The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the
traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and
overwrite in it the destination port to 5053.
# Debug
The CONFIG_BPF_EVENTS kernel module is required for bpf_printk.
Apply this code to use bpf_printk
```
#define bpf_printk(fmt, ...) \
({ \
char ____fmt[] = fmt; \
bpf_trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
})
```
-60
View File
@@ -1,60 +0,0 @@
const __u32 map_key_proxy_port = 0;
const __u32 map_key_wg_port = 1;
struct bpf_map_def SEC("maps") nb_wg_proxy_settings_map = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
__u16 proxy_port = 0;
__u16 wg_port = 0;
bool read_port_settings() {
__u16 *value;
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_proxy_port);
if (!value) {
return false;
}
proxy_port = *value;
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_wg_port);
if (!value) {
return false;
}
wg_port = htons(*value);
return true;
}
int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
if (proxy_port == 0 || wg_port == 0) {
if (!read_port_settings()){
return XDP_PASS;
}
// bpf_printk("proxy port: %d, wg port: %d", proxy_port, wg_port);
}
// 2130706433 = 127.0.0.1
if (ip->daddr != htonl(2130706433)) {
return XDP_PASS;
}
if (udp->source != wg_port){
return XDP_PASS;
}
__be16 new_src_port = udp->dest;
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
// and the payload is already integrity-protected, so clear the checksum (a
// zero UDP checksum means "not computed" for IPv4) rather than leave a
// stale value the kernel would drop as UDP_CSUM.
udp->check = 0;
return XDP_PASS;
}
@@ -1,15 +0,0 @@
//go:build !android
package ebpf
import (
"github.com/netbirdio/netbird/client/internal/ebpf/ebpf"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
// GetEbpfManagerInstance is a wrapper function. This encapsulation is required because if the code import the internal
// ebpf package the Go compiler will include the object files. But it is not supported on Android. It can cause instant
// panic on older Android version.
func GetEbpfManagerInstance() manager.Manager {
return ebpf.GetEbpfManagerInstance()
}
@@ -1,10 +0,0 @@
//go:build !linux || android
package ebpf
import "github.com/netbirdio/netbird/client/internal/ebpf/manager"
// GetEbpfManagerInstance return error because ebpf is not supported on all os
func GetEbpfManagerInstance() manager.Manager {
panic("unsupported os")
}
-9
View File
@@ -1,9 +0,0 @@
package manager
import "net/netip"
// Manager is used to load eBPF programs. Currently only the DNS forwarder uses one.
type Manager interface {
LoadDNSFwd(ip netip.Addr, dnsPort int) error
FreeDNSFwd() error
}
+135 -19
View File
@@ -14,12 +14,14 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/hashicorp/go-multierror"
"github.com/pion/ice/v4"
"github.com/pion/stun/v3"
log "github.com/sirupsen/logrus"
wgdevice "golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -94,6 +96,13 @@ const (
// exec, os.Stat); without this bound a single stuck call freezes handleSync, and
// thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
systemInfoTimeout = 15 * time.Second
// dnsForwarderStopTimeout bounds how long stopping the DNS forwarder waits
// for the queries still in flight. One waiting on an unresponsive upstream
// would otherwise hold the stop for the whole upstream timeout, and the
// stop runs with syncMsgMux held. The sockets are closed either way, so
// giving up costs a query that was already failing.
dnsForwarderStopTimeout = 2 * time.Second
)
var ErrResetConnection = fmt.Errorf("reset connection")
@@ -229,6 +238,12 @@ type Engine struct {
wgInterface WGIface
// wgDevice is a lock-free handle on the WireGuard device behind
// wgInterface. Reaching the device through wgInterface requires
// syncMsgMux, which handleSync holds while it adds and removes peers;
// SetPerformance must stay reachable exactly when that work is stuck.
wgDevice atomic.Pointer[wgdevice.Device]
udpMux *udpmux.UniversalUDPMuxDefault
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
@@ -258,6 +273,8 @@ type Engine struct {
// checks are the client-applied posture checks that need to be evaluated on the client
checks []*mgmProto.Checks
infoSource system.InfoSource
relayManager *relayClient.Manager
stateManager *statemanager.Manager
portForwardManager *portforward.Manager
@@ -321,6 +338,10 @@ type localIpUpdater interface {
UpdateLocalIPs() error
}
// overlayRebind rebuilds one subsystem's sockets on the current interface. The
// error it returns names its own subsystem, since the caller can only log it.
type overlayRebind func() error
// NewEngine creates a new Connection Engine with probes attached
func NewEngine(
clientCtx context.Context,
@@ -638,6 +659,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
return fmt.Errorf("up wg interface: %w", err)
}
e.wgDevice.Store(e.wgInterface.GetWGDevice())
// Start after interface is up since port may have been resolved from 0 or changed if occupied
e.shutdownWg.Add(1)
@@ -1209,9 +1231,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
if isChecksEqual(e.checks, checks) {
return nil
}
e.checks = checks
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; skip the meta sync this cycle rather than blocking the
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
@@ -1222,6 +1242,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("could not sync meta: error %s", err)
}
e.checks = checks
return nil
}
@@ -1248,6 +1269,28 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
)
}
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
e.applyInfoFlags(info)
return info
}
// syncInfoFunc returns the info callback for the management sync stream. The
// first connect sends the info refreshed right before it instead of gathering
// again; every reconnect gathers a fresh one. The stream retry loop calls the
// callback sequentially, so the handoff needs no synchronization.
func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info {
return func(ctx context.Context) *system.Info {
if refreshed == nil {
return e.currentSystemInfo(ctx)
}
info := refreshed
refreshed = nil
e.applyInfoFlags(info)
return info
}
}
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
// can be excluded from the reported network addresses; the interface coming and
// going otherwise churns the peer meta on the management server.
@@ -1441,15 +1484,11 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; connect the stream with base info so management
// connectivity still comes up rather than blocking here.
info = system.GetInfo(e.ctx)
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
}
e.applyInfoFlags(info)
err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync)
if err != nil {
// happens if management is unavailable for a long time.
// We want to cancel the operation of the whole client
@@ -2093,6 +2132,10 @@ func (e *Engine) close() {
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
if e.wgInterface != nil {
// Drop the handle before the close starts: a retune that loads it
// afterwards would touch a device on its way out and report success
// for an engine that is already gone.
e.wgDevice.Store(nil)
if err := e.wgInterface.Close(); err != nil {
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
}
@@ -2252,15 +2295,16 @@ type Performance struct {
}
// SetPerformance applies the given tuning to this engine's live Device.
//
// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the
// recovery path for a device whose pool is exhausted, and an exhausted pool
// blocks peer removal inside handleSync, which holds syncMsgMux for as long as
// it stays blocked. Taking the lock here would make the retune unreachable in
// the one situation that needs it.
func (e *Engine) SetPerformance(t Performance) error {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
if e.wgInterface == nil {
return fmt.Errorf("wg interface not initialized")
}
dev := e.wgInterface.GetWGDevice()
dev := e.wgDevice.Load()
if dev == nil {
return fmt.Errorf("wg device not initialized")
return errors.New("wg device not initialized")
}
if t.PreallocatedBuffersPerPool != nil {
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
@@ -2481,7 +2525,72 @@ func (e *Engine) RenewTun(fd int) error {
return fmt.Errorf("wireguard interface not initialized")
}
return wgInterface.RenewTun(fd)
if err := wgInterface.RenewTun(fd); err != nil {
return err
}
e.rebindOverlayListeners()
return nil
}
// rebindOverlayListeners gives the servers that listen on an overlay address
// sockets on the interface as it is now.
//
// A socket belongs to the interface generation it was created on. Renewing the
// TUN builds a new interface and moves the overlay addresses to it, which
// leaves the old sockets in LISTEN with the uspfilter still logging packets
// arriving for them, while every accept fails with EINVAL for the life of the
// socket: from the outside the server looks alive and answers nothing. On
// Android this happens during a normal startup, where the first TUN is
// established before the routes are known and replaced once they arrive.
//
// Rebinding costs whatever those sockets were carrying, which the renewal has
// already broken. Errors are logged rather than returned: the renewal itself
// succeeded, and failing it would hand the caller a working interface and an
// error.
func (e *Engine) rebindOverlayListeners() {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
for _, rebind := range e.overlayRebinds() {
if err := rebind(); err != nil {
log.Errorf("after TUN renewal: %v", err)
}
}
}
// overlayRebinds is every subsystem of this engine that holds sockets bound to
// an overlay address, and how to rebuild each one's.
//
// A subsystem that starts listening on an overlay address belongs in this list.
// Leaving it out costs nothing that review would notice and produces a listener
// that stays in LISTEN, is logged as receiving packets, and refuses every
// connection for the life of the process.
func (e *Engine) overlayRebinds() []overlayRebind {
return []overlayRebind{
e.restartSSHListeners,
e.restartDNSForwarder,
}
}
// restartDNSForwarder rebuilds the DNS forwarder serving the same domains.
// No-op when it is not running. See Engine.rebindOverlayListeners.
func (e *Engine) restartDNSForwarder() error {
if e.dnsForwardMgr == nil {
return nil
}
// Read from the forwarder before it goes away, so the replacement serves
// the domains in force now rather than a copy kept somewhere else.
entries := e.dnsForwardMgr.Domains()
e.stopDNSForwarder()
// Both halves log their own failures, so the only thing left to report is
// the outcome: a start that failed left the manager nil, and the forwarder
// is now down rather than merely rebound.
e.startDNSForwarder(entries)
if e.dnsForwardMgr == nil {
return errors.New("rebind DNS forwarder: it did not come back up")
}
return nil
}
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
@@ -2527,7 +2636,14 @@ func (e *Engine) stopDNSForwarder() {
return
}
if err := e.dnsForwardMgr.Stop(context.Background()); err != nil {
// Bounded because the shutdown waits for queries still in flight, and one
// waiting on an unresponsive upstream holds it for as long as that lookup
// is allowed to take. This runs with syncMsgMux held, so that wait is one
// the whole engine spends.
ctx, cancel := context.WithTimeout(context.Background(), dnsForwarderStopTimeout)
defer cancel()
if err := e.dnsForwardMgr.Stop(ctx); err != nil {
log.Errorf("failed to stop DNS forward: %v", err)
}
+1 -1
View File
@@ -193,7 +193,7 @@ func TestEngine_Sync(t *testing.T) {
// feed updates to Engine via mocked Management client
updates := make(chan *mgmtProto.SyncResponse)
defer close(updates)
syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
syncFunc := func(ctx context.Context, _ func(context.Context) *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
for msg := range updates {
err := msgHandler(msg)
if err != nil {
+32 -4
View File
@@ -24,6 +24,8 @@ type sshServer interface {
Stop() error
GetStatus() (bool, []sshserver.SessionInfo)
UpdateSSHAuth(config *sshauth.Config)
JWTConfig() *sshserver.JWTConfig
AuthConfig() *sshauth.Config
}
func (e *Engine) setupSSHPortRedirection() error {
@@ -77,7 +79,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth {
log.Info("starting SSH server without JWT authentication (authentication disabled by config)")
return e.startSSHServer(nil)
return e.startSSHServer(nil, nil)
}
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
@@ -95,7 +97,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
MaxTokenAge: protoJWT.GetMaxTokenAge(),
}
return e.startSSHServer(jwtConfig)
return e.startSSHServer(jwtConfig, nil)
}
return errors.New("SSH server requires valid JWT configuration")
@@ -231,8 +233,33 @@ func (e *Engine) cleanupSSHConfig() {
}
}
// startSSHServer initializes and starts the SSH server with proper configuration.
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
// restartSSHListeners rebuilds the SSH server so it listens on new sockets, on
// the same terms it was started with. No-op when it is not running. See
// Engine.rebindOverlayListeners for why this is needed.
func (e *Engine) restartSSHListeners() error {
if e.sshServer == nil {
return nil
}
// Read from the server before it goes away. A rebuilt one starts with an
// empty authorizer, which fails closed, so without carrying the
// authorization over every JWT login is refused until the next network map
// happens to bring one.
jwtConfig, authConfig := e.sshServer.JWTConfig(), e.sshServer.AuthConfig()
if err := e.stopSSHServer(); err != nil {
return fmt.Errorf("rebind SSH listeners: %w", err)
}
if err := e.startSSHServer(jwtConfig, authConfig); err != nil {
return fmt.Errorf("rebind SSH listeners: %w", err)
}
return nil
}
// startSSHServer initializes and starts the SSH server with proper
// configuration. authConfig is the fine-grained authorization to open with, and
// is applied before the server accepts anything: a server that starts listening
// with an empty authorizer refuses the logins that arrive in the meantime.
// Nil leaves it as management has not sent one yet.
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig, authConfig *sshauth.Config) error {
if e.wgInterface == nil {
return errors.New("wg interface not initialized")
}
@@ -240,6 +267,7 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
serverConfig := &sshserver.Config{
HostKeyPEM: e.config.SSHKey,
JWT: jwtConfig,
Auth: authConfig,
}
server := sshserver.New(serverConfig)
+114
View File
@@ -2,6 +2,7 @@ package internal
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
@@ -31,6 +32,7 @@ import (
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/monotime"
"github.com/netbirdio/netbird/route"
@@ -246,6 +248,118 @@ func TestEngine_SSHServerConsistency(t *testing.T) {
})
}
func TestEngine_FirstSyncInfoCarriesLoginChecks(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
exe, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
infos := make(chan *system.Info, 1)
mgmClient := &mgmt.MockClient{
SyncFunc: func(ctx context.Context, getInfo func(context.Context) *system.Info, _ func(*mgmtProto.SyncResponse) error) error {
infos <- getInfo(ctx)
return nil
},
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun104",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33100,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: mgmClient,
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
Checks: []*mgmtProto.Checks{{Files: []string{exe}}},
}, MobileDependency{})
engine.receiveManagementEvents()
select {
case info := <-infos:
require.Len(t, info.Files, 1)
assert.Equal(t, exe, info.Files[0].Path)
assert.True(t, info.Files[0].Exist)
case <-time.After(20 * time.Second):
t.Fatal("timeout waiting for the first sync info")
}
engine.shutdownWg.Wait()
}
func TestEngine_SyncInfoFuncReusesRefreshedInfoOnce(t *testing.T) {
engine := &Engine{config: &EngineConfig{}}
refreshed := &system.Info{Hostname: "from-refresh"}
getInfo := engine.syncInfoFunc(refreshed)
first := getInfo(context.Background())
assert.Same(t, refreshed, first, "the first connect should send the refreshed info instead of gathering again")
second := getInfo(context.Background())
assert.NotSame(t, refreshed, second, "the reconnect should gather a fresh info")
assert.NotEqual(t, "from-refresh", second.Hostname, "the fresh info should not carry the refreshed hostname")
}
func TestEngine_SyncInfoFuncGathersWhenRefreshFailed(t *testing.T) {
engine := &Engine{config: &EngineConfig{}}
info := engine.syncInfoFunc(nil)(context.Background())
require.NotNil(t, info, "a failed refresh should fall back to gathering the info")
assert.NotEmpty(t, info.Hostname, "the gathered info should carry the hostname")
}
func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
exe, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
syncMetaCalls := 0
mgmClient := &mgmt.MockClient{
SyncMetaFunc: func(*system.Info) error {
syncMetaCalls++
if syncMetaCalls == 1 {
return errors.New("management unavailable")
}
return nil
},
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun105",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33100,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: mgmClient,
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
}, MobileDependency{})
checks := []*mgmtProto.Checks{{Files: []string{exe}}}
require.Error(t, engine.updateChecksIfNew(checks))
require.NoError(t, engine.updateChecksIfNew(checks))
require.NoError(t, engine.updateChecksIfNew(checks))
assert.Equal(t, 2, syncMetaCalls)
}
func TestEngine_UpdateNetworkMap(t *testing.T) {
// test setup
key, err := wgtypes.GeneratePrivateKey()
+25 -9
View File
@@ -135,9 +135,10 @@ type Conn struct {
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
wgProxyICE wgproxy.Proxy
wgProxyRelay wgproxy.Proxy
handshaker *Handshaker
wgProxyICE wgproxy.Proxy
wgProxyRelay wgproxy.Proxy
relayedConnRef *relayClient.Conn
handshaker *Handshaker
guard *guard.Guard
wg sync.WaitGroup
@@ -560,7 +561,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.mu.Lock()
defer conn.mu.Unlock()
if conn.ctx.Err() != nil {
if conn.ctx.Err() != nil || rci.relayedConn.Context().Err() != nil {
if err := rci.relayedConn.Close(); err != nil {
conn.Log.Warnf("failed to close unnecessary relayed connection: %v", err)
}
@@ -575,7 +576,9 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
return
}
wgProxy.SetDisconnectListener(conn.onRelayDisconnected)
wgProxy.SetDisconnectListener(func() {
conn.onRelayDisconnected(rci.relayedConn)
})
conn.dumpState.NewLocalProxy()
@@ -583,7 +586,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
if conn.isICEActive() {
conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String())
conn.setRelayedProxy(wgProxy)
conn.setRelayedProxy(wgProxy, rci.relayedConn)
conn.statusRelay.SetConnected()
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now())
return
@@ -614,15 +617,26 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
conn.statusRelay.SetConnected()
conn.setRelayedProxy(wgProxy)
conn.setRelayedProxy(wgProxy, rci.relayedConn)
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime)
conn.Log.Infof("start to communicate with peer via relay")
conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime)
}
func (conn *Conn) onRelayDisconnected() {
// onRelayDisconnected reports the teardown of a relayed connection. relayedConn
// names the connection the signal belongs to, so a signal that arrives after
// its connection was replaced is ignored instead of tearing down its successor.
// A nil relayedConn means the caller does not track generations and the current
// connection is always torn down.
func (conn *Conn) onRelayDisconnected(relayedConn *relayClient.Conn) {
conn.mu.Lock()
defer conn.mu.Unlock()
if relayedConn != nil && conn.relayedConnRef != relayedConn {
conn.Log.Debugf("ignoring relay disconnect of a superseded connection")
return
}
conn.handleRelayDisconnectedLocked()
}
@@ -646,6 +660,7 @@ func (conn *Conn) handleRelayDisconnectedLocked() {
_ = conn.wgProxyRelay.CloseConn()
conn.wgProxyRelay = nil
}
conn.relayedConnRef = nil
changed := conn.statusRelay.Get() != worker.StatusDisconnected
if changed {
@@ -930,13 +945,14 @@ func (conn *Conn) logTraceConnState() {
}
}
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) {
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy, relayedConn *relayClient.Conn) {
if conn.wgProxyRelay != nil {
if err := conn.wgProxyRelay.CloseConn(); err != nil {
conn.Log.Warnf("failed to close deprecated wg proxy conn: %v", err)
}
}
conn.wgProxyRelay = proxy
conn.relayedConnRef = relayedConn
}
// onWGHandshakeSuccess is called when the first WireGuard handshake is detected
+4 -4
View File
@@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
for {
select {
case remoteOfferAnswer := <-h.remoteOffersCh:
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
// Record signaling received for reconnection attempts
if h.metricsStages != nil {
@@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
continue
}
case remoteOfferAnswer := <-h.remoteAnswerCh:
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
// Record signaling received for reconnection attempts
if h.metricsStages != nil {
@@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP)
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP)
return h.signaler.SignalAnswer(answer, h.config.Key)
}
+2 -2
View File
@@ -819,8 +819,8 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
// "none" would blank the UI at the exact moment it should say the session
// ended.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
d.mux.RLock()
defer d.mux.RUnlock()
return d.sessionExpiresAt
}
+13 -14
View File
@@ -3,7 +3,6 @@ package peer
import (
"context"
"errors"
"net"
"net/netip"
"sync"
"sync/atomic"
@@ -14,7 +13,7 @@ import (
)
type RelayConnInfo struct {
relayedConn net.Conn
relayedConn *relayClient.Conn
rosenpassPubKey []byte
rosenpassAddr string
}
@@ -27,7 +26,7 @@ type WorkerRelay struct {
conn *Conn
relayManager *relayClient.Manager
relayedConn net.Conn
relayedConn *relayClient.Conn
relayLock sync.Mutex
relaySupportedOnRemotePeer atomic.Bool
@@ -80,12 +79,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.relayedConn = relayedConn
w.relayLock.Unlock()
err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected)
if err != nil {
log.Errorf("failed to add close listener: %s", err)
_ = relayedConn.Close()
return
}
go w.watchRelayedConn(relayedConn)
w.log.Debugf("peer conn opened via Relay: %s", srv)
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
@@ -109,12 +103,15 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool {
func (w *WorkerRelay) CloseConn() {
w.relayLock.Lock()
defer w.relayLock.Unlock()
if w.relayedConn == nil {
conn := w.relayedConn
w.relayedConn = nil
w.relayLock.Unlock()
if conn == nil {
return
}
if err := w.relayedConn.Close(); err != nil {
if err := conn.Close(); err != nil {
w.log.Warnf("failed to close relay connection: %v", err)
}
}
@@ -133,6 +130,8 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st
return remoteRelayAddress
}
func (w *WorkerRelay) onRelayClientDisconnected() {
go w.conn.onRelayDisconnected()
func (w *WorkerRelay) watchRelayedConn(relayedConn *relayClient.Conn) {
<-relayedConn.Context().Done()
w.conn.onRelayDisconnected(relayedConn)
}
+22 -12
View File
@@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
}
// loadMDMPolicy is the package-level indirection used by apply() to read the
// active MDM policy. Tests override this to inject a fake policy.
var loadMDMPolicy = mdm.LoadPolicy
// ConfigInput carries configuration changes to the client
type ConfigInput struct {
ManagementURL string
@@ -202,14 +198,26 @@ type Config struct {
MTU uint16
// policy is the MDM policy that produced the currently-set values for
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
// and reset on every apply() invocation. Never persisted to disk.
// Callers query enforcement state via Policy() and the mdm.Policy API
// (HasKey, ManagedKeys, IsEmpty).
// policy is the MDM policy that produced the currently-set values
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
// invocation. Never persisted to disk. Callers query enforcement
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
// IsEmpty).
policy *mdm.Policy `json:"-"`
}
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
// Config values and records it as Policy(). The overlay is not reversible:
// an empty Policy only clears the enforcement metadata, so resolve the base
// Config again (from disk or JSON) before applying a changed policy, the way
// the lifecycle owners do on every load.
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
if config == nil {
return
}
config.applyMDMPolicy(policy)
}
// Policy returns the MDM policy applied to this Config. Returns a non-nil
// empty Policy when MDM enforcement is inactive; callers can always invoke
// HasKey / ManagedKeys / IsEmpty without a nil check.
@@ -712,9 +720,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
// MDM is the last override layer: any key present in the policy
// supersedes defaults, on-disk config, env vars and CLI input.
config.applyMDMPolicy(loadMDMPolicy())
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
// never returns a stale or nil policy on a freshly applied Config.
// Lifecycle owners that want to enforce a real MDM policy invoke
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
config.applyMDMPolicy(mdm.NewPolicy(nil))
return updated, nil
}
@@ -0,0 +1,52 @@
package profilemanager
import (
"errors"
"fmt"
"github.com/netbirdio/netbird/client/mdm"
)
// ErrMDMManagedFields marks a config change rejected because it diverges from
// MDM-enforced values.
var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified")
// MDMConflicts returns the names of MDM-managed keys whose requested value in
// the ConfigInput differs from the policy-enforced value; a field set to the
// enforced value is a no-op echo, not a conflict.
func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string {
pskGot := input.PreSharedKey
if isPreSharedKeyHidden(pskGot) {
pskGot = nil
}
var port *int64
if input.WireguardPort != nil {
v := int64(*input.WireguardPort)
port = &v
}
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL),
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled),
mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive),
mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect),
mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed),
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed),
mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes),
mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes),
mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound),
mdm.ConflictInt64(mdm.KeyWireguardPort, port),
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled),
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress),
})
}
// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the
// conflicting keys, or nil when the input does not fight the policy.
func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error {
conflicts := MDMConflicts(input, policy)
if len(conflicts) == 0 {
return nil
}
return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts)
}
+141 -68
View File
@@ -10,24 +10,58 @@ import (
"github.com/netbirdio/netbird/client/mdm"
)
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
// apply() observes the supplied Policy. The original loader is restored at
// test cleanup.
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
// map. Test helper used to construct a Loader without touching the OS
// or any package-level state.
type fakeFetcher struct{ values map[string]any }
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
// loaderFor builds an mdm.Loader whose loadPlatform returns the
// supplied Policy's underlying values.
func loaderFor(policy *mdm.Policy) *mdm.Loader {
if policy == nil || policy.IsEmpty() {
return mdm.NewLoader(&fakeFetcher{values: nil})
}
values := make(map[string]any)
for _, k := range policy.ManagedKeys() {
if v, ok := policy.GetString(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetInt(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetBool(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetStringSlice(k); ok {
values[k] = v
}
}
return mdm.NewLoader(&fakeFetcher{values: values})
}
// configWithMDM is the test convenience that builds a Config via
// UpdateOrCreateConfig and overlays the supplied MDM policy on top —
// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay)
// where the Loader lives outside Config and the apply step is driven
// by the lifecycle owner.
func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config {
t.Helper()
prev := loadMDMPolicy
loadMDMPolicy = func() *mdm.Policy { return policy }
t.Cleanup(func() { loadMDMPolicy = prev })
cfg, err := UpdateOrCreateConfig(input)
require.NoError(t, err)
require.NotNil(t, cfg)
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
return cfg
}
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(nil))
cfg, err := UpdateOrCreateConfig(ConfigInput{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
}, mdm.NewPolicy(nil))
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
@@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
const mdmURL = "https://corp.mdm.example.com:443"
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: mdmURL,
mdm.KeyDisableClientRoutes: true,
mdm.KeyBlockInbound: true,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
assert.True(t, cfg.DisableClientRoutes)
assert.True(t, cfg.BlockInbound)
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
const mdmURL = "https://mdm.example.com:443"
const cliURL = "https://cli.example.com:443"
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: mdmURL,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
ManagementURL: cliURL,
})
require.NoError(t, err)
require.NotNil(t, cfg)
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: mdmURL,
}))
// MDM wins over CLI-supplied management URL.
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
}
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "not-a-url",
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
// Invalid MDM URL is logged and skipped: default URL stays in place
// to keep the client functional.
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil))
_, err := UpdateOrCreateConfig(ConfigInput{
configWithMDM(t, ConfigInput{
ConfigPath: tmp,
DisableClientRoutes: boolPtr(false),
RosenpassEnabled: boolPtr(false),
})
require.NoError(t, err)
}, mdm.NewPolicy(nil))
// Now enable MDM enforcement for these keys.
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: tmp,
}, mdm.NewPolicy(map[string]any{
mdm.KeyDisableClientRoutes: true,
mdm.KeyRosenpassEnabled: true,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
assert.True(t, cfg.RosenpassEnabled)
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
@@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil))
_, err := UpdateOrCreateConfig(ConfigInput{
configWithMDM(t, ConfigInput{
ConfigPath: tmp,
LocalMetricsEnabled: boolPtr(false),
})
require.NoError(t, err)
}, mdm.NewPolicy(nil))
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
// Now enable MDM enforcement for these keys.
cfg := configWithMDM(t, ConfigInput{
ConfigPath: tmp,
}, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
@@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyLazyConnection: c.raw,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, c.want, cfg.LazyConnection)
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
})
@@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
}
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
const maskSentinel = "**********"
const maskSentinel = mdm.PreSharedKeyRedactedSentinel
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: maskSentinel,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
// Mask sentinel must not be persisted as the actual PSK.
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
// Key still marked managed so user writes are still rejected.
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
}
func TestMDMConflicts_PreSharedKey(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
})
empty := ""
sentinel := mdm.PreSharedKeyRedactedSentinel
same := "mdm-enforced-psk"
other := "user-psk"
tests := []struct {
name string
psk *string
want []string
}{
{name: "unset", psk: nil, want: nil},
{name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}},
{name: "sentinel echo", psk: &sentinel, want: nil},
{name: "same value", psk: &same, want: nil},
{name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy))
})
}
}
func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyRemoteJobsAllowed: false,
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
})
sameAddr := "127.0.0.1:9999"
otherAddr := "0.0.0.0:9999"
emptyAddr := ""
tests := []struct {
name string
input ConfigInput
want []string
}{
{name: "unset", input: ConfigInput{}, want: nil},
{name: "echo", input: ConfigInput{
RemoteJobsAllowed: boolPtr(false),
LocalMetricsEnabled: boolPtr(true),
LocalMetricsAddress: &sameAddr,
}, want: nil},
{name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}},
{name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}},
{name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
{name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
{name: "all divergent", input: ConfigInput{
RemoteJobsAllowed: boolPtr(true),
LocalMetricsEnabled: boolPtr(false),
LocalMetricsAddress: &otherAddr,
}, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, MDMConflicts(tc.input, policy))
})
}
}
func boolPtr(b bool) *bool { return &b }
@@ -6,6 +6,7 @@ import (
"os/user"
"path/filepath"
"runtime"
"strconv"
log "github.com/sirupsen/logrus"
)
@@ -13,17 +14,21 @@ import (
const envSudoUser = "SUDO_USER"
var (
geteuid = os.Geteuid
lookupUser = user.Lookup
currentUser = user.Current
getegid = os.Getegid
geteuid = os.Geteuid
lookupUser = user.Lookup
)
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
// the user who ran sudo, not root: privileged flags force commands through
// sudo, and resolving profiles as root would silently switch the daemon to
// root's (default) profile instead of the invoking user's. Privilege decisions
// are not made here — those stay on the kernel credentials of the daemon
// connection, which SUDO_USER (a plain environment variable) can never
// influence; a forged value only selects a profile root could select anyway.
// root's (default) profile instead of the invoking user's. An unmapped positive
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
// unavailable platform identities still fail closed. Privilege decisions stay
// on the kernel credentials of the daemon connection, which SUDO_USER (a plain
// environment variable) can never influence; a forged value only selects a
// profile root could select anyway.
func InvokingUser() (*user.User, error) {
if u, ok := sudoInvokingUser(); ok {
return u, nil
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
if sudoActive() {
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
}
return user.Current()
u, err := currentUser()
if err == nil {
return u, nil
}
uid := geteuid()
if uid <= 0 {
return nil, err
}
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
uidString := strconv.Itoa(uid)
return &user.User{
Username: uidString,
Uid: uidString,
Gid: strconv.Itoa(getegid()),
}, nil
}
// IsPlainRoot reports that the process runs as root with no usable sudo
@@ -2,6 +2,7 @@ package profilemanager
import (
"errors"
"fmt"
"io/fs"
"os"
"os/user"
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
assert.Equal(t, current.Username, got.Username)
assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo")
}
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
for _, uid := range []int{0, -1} {
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
t.Setenv(envSudoUser, "")
lookupErr := errors.New("current user unavailable")
fakeUnmappedUser(t, uid, 0, lookupErr)
got, err := InvokingUser()
require.ErrorIs(t, err, lookupErr)
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
})
}
}
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
t.Setenv(envSudoUser, "")
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
profilesRoot := t.TempDir()
origDir := DefaultConfigPathDir
origOverride := ConfigDirOverride
DefaultConfigPathDir = profilesRoot
ConfigDirOverride = ""
t.Cleanup(func() {
DefaultConfigPathDir = origDir
ConfigDirOverride = origOverride
})
profileID := ID("0123456789abcdef0123456789abcdef")
got, err := (&Profile{ID: profileID}).FilePath()
require.NoError(t, err)
assert.Equal(t,
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
got,
"profile path should use the numeric UID namespace",
)
entries, err := os.ReadDir(profilesRoot)
require.NoError(t, err)
require.Len(t, entries, 1, "only the numeric UID directory should be created")
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
}
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
fakeSudo(t, filepath.Join("/home", "misha"))
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
origCurrentUser := currentUser
currentUser = func() (*user.User, error) {
t.Fatal("currentUser must not be called after a sudo lookup failure")
return nil, errors.New("currentUser called unexpectedly")
}
t.Cleanup(func() { currentUser = origCurrentUser })
got, err := InvokingUser()
require.Error(t, err)
assert.Nil(t, got, "must not resolve to the root process user")
@@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) {
})
}
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
t.Helper()
origCurrentUser := currentUser
origEuid := geteuid
origEgid := getegid
currentUser = func() (*user.User, error) { return nil, lookupErr }
geteuid = func() int { return uid }
getegid = func() int { return gid }
t.Cleanup(func() {
currentUser = origCurrentUser
geteuid = origEuid
getegid = origEgid
})
}
func assertNoEntries(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
+41 -18
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"net/url"
"runtime"
"slices"
"sort"
"strings"
"sync"
@@ -472,27 +473,13 @@ func (m *DefaultManager) CurrentRouteRange() []string {
m.mux.Lock()
defer m.mux.Unlock()
if m.disableClientRoutes {
return nil
}
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
var nets []string
for _, routes := range filtered {
for _, r := range routes {
if r.IsDynamic() {
continue
}
nets = append(nets, r.NetString())
}
}
if m.fakeIPManager != nil {
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
nets := m.overlayNetworks()
if !m.disableClientRoutes {
nets = append(nets, m.clientRouteRange()...)
}
sort.Strings(nets)
return nets
return slices.Compact(nets)
}
// GetRouteSelector returns the route selector
@@ -856,6 +843,42 @@ func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.Ne
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
}
// overlayNetworks returns the v4 and v6 overlay networks of the WireGuard interface, each only when it is set.
func (m *DefaultManager) overlayNetworks() []string {
if m.wgInterface == nil {
return nil
}
addr := m.wgInterface.Address()
var nets []string
if addr.Network.IsValid() {
nets = append(nets, addr.Network.String())
}
if addr.IPv6Net.IsValid() {
nets = append(nets, addr.IPv6Net.String())
}
return nets
}
// clientRouteRange returns the static client route networks of the selected exit nodes together with the fake IP blocks.
func (m *DefaultManager) clientRouteRange() []string {
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
var nets []string
for _, routes := range filtered {
for _, r := range routes {
if r.IsDynamic() {
continue
}
nets = append(nets, r.NetString())
}
}
if m.fakeIPManager != nil {
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
}
return nets
}
// minNetID returns the lexicographically smallest NetID, for a deterministic
// default pick that stays stable across restarts.
func minNetID(ids []route.NetID) route.NetID {
@@ -4,8 +4,6 @@ package notifier
import (
"net/netip"
"slices"
"sort"
"sync"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -75,19 +73,3 @@ func (n *Notifier) notifyLocked() {
func (n *Notifier) Close() {
// unused
}
func routesToStrings(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
}
return nets
}
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
as := routesToStrings(a)
bs := routesToStrings(b)
sort.Strings(as)
sort.Strings(bs)
return !slices.Equal(as, bs)
}
@@ -0,0 +1,27 @@
package notifier
import (
"slices"
"sort"
"github.com/netbirdio/netbird/route"
)
// routePrefixes returns the distinct prefixes a route set covers, sorted.
// Duplicates are dropped deliberately: an HA group hands us one route per
// peer serving the same prefix, and the platform is given the prefix, not the
// candidates. Counting them would report a change every time a peer joins or
// leaves a group, and on Android each report renews the TUN.
func routePrefixes(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
}
sort.Strings(nets)
return slices.Compact(nets)
}
// hasRouteDiff reports whether the prefixes the two route sets cover differ.
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
return !slices.Equal(routePrefixes(a), routePrefixes(b))
}
@@ -0,0 +1,88 @@
package notifier
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/route"
)
func routeFor(id route.ID, prefix string) *route.Route {
return &route.Route{
ID: id,
NetID: "net",
Network: netip.MustParsePrefix(prefix),
}
}
// TestHasRouteDiff_IgnoresHACandidateCount is the reason the comparison
// deduplicates. Every notification renews the TUN, and a renewed TUN
// invalidates the sockets the embedded servers are listening on, so a peer
// joining or leaving an HA group must not count as a route change when the
// prefixes the TUN carries are identical.
func TestHasRouteDiff_IgnoresHACandidateCount(t *testing.T) {
onePeer := []*route.Route{routeFor("a", "10.0.0.0/24")}
twoPeers := []*route.Route{
routeFor("a", "10.0.0.0/24"),
routeFor("b", "10.0.0.0/24"),
}
assert.False(t, hasRouteDiff(onePeer, twoPeers),
"a second peer serving the same prefix is not a route change")
assert.False(t, hasRouteDiff(twoPeers, onePeer),
"losing one of two peers serving the same prefix is not a route change")
}
func TestHasRouteDiff_ReportsRealChanges(t *testing.T) {
tests := []struct {
name string
a []*route.Route
b []*route.Route
want bool
}{
{
name: "added prefix",
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
b: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
want: true,
},
{
name: "removed prefix",
a: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
b: []*route.Route{routeFor("a", "10.0.0.0/24")},
want: true,
},
{
name: "replaced prefix",
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
b: []*route.Route{routeFor("a", "10.0.1.0/24")},
want: true,
},
{
name: "same prefix, different order",
a: []*route.Route{routeFor("a", "10.0.1.0/24"), routeFor("b", "10.0.0.0/24")},
b: []*route.Route{routeFor("b", "10.0.0.0/24"), routeFor("a", "10.0.1.0/24")},
want: false,
},
{
name: "all routes gone",
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
b: nil,
want: true,
},
{
name: "both empty",
a: nil,
b: nil,
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, hasRouteDiff(tc.a, tc.b),
"route diff for %s", tc.name)
})
}
}
@@ -17,11 +17,12 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
)
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the
// configured address; every other method is an inert stub because the tests exercise none of them.
type reconcileWGMock struct {
mu sync.Mutex
adds map[string][]netip.Prefix
addr wgaddr.Address
}
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
@@ -42,7 +43,7 @@ func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
func (m *reconcileWGMock) Name() string { return "utun-test" }
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
func (m *reconcileWGMock) Address() wgaddr.Address { return m.addr }
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
@@ -0,0 +1,95 @@
//go:build !windows
package routemanager
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/routeselector"
"github.com/netbirdio/netbird/route"
)
func TestCurrentRouteRange_OverlayNetworkWithClientRoutesDisabled(t *testing.T) {
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
disableClientRoutes: true,
}
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "overlay network must be routed even when client routes are disabled")
}
func TestCurrentRouteRange_OverlayNetworksAndClientRoutes(t *testing.T) {
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64")
static := &route.Route{ID: "static", NetID: "lan", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
dynamic := &route.Route{ID: "dynamic", NetID: "dyn", NetworkType: route.DomainNetwork}
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: addr},
routeSelector: routeselector.NewRouteSelector(),
clientRoutes: route.HAMap{
static.GetHAUniqueID(): {static},
dynamic.GetHAUniqueID(): {dynamic},
},
}
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24", "fd00:1234::/64"}, m.CurrentRouteRange(), "overlay networks and static client routes must be listed, dynamic routes skipped")
}
func TestCurrentRouteRange_NoInterfaceAddress(t *testing.T) {
m := &DefaultManager{
wgInterface: &reconcileWGMock{},
disableClientRoutes: true,
}
assert.Empty(t, m.CurrentRouteRange(), "an unset interface address must not produce a route entry")
}
func TestCurrentRouteRange_IPv6WithoutIPv4Network(t *testing.T) {
addr := wgaddr.Address{
IPv6: netip.MustParseAddr("fd00:1234::1"),
IPv6Net: netip.MustParsePrefix("fd00:1234::/64"),
}
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: addr},
disableClientRoutes: true,
}
assert.Equal(t, []string{"fd00:1234::/64"}, m.CurrentRouteRange(), "a v6 overlay network must not depend on a v4 network being set")
}
func TestCurrentRouteRange_IPv6AddressWithoutNetwork(t *testing.T) {
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: addr},
disableClientRoutes: true,
}
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "a v6 address without a network must not produce a route entry")
}
func TestCurrentRouteRange_DeduplicatesPrefixes(t *testing.T) {
// Two HA peers serve the same prefix, and a client route announces the overlay network itself.
haPeerA := &route.Route{ID: "ha-a", NetID: "lan", Peer: "peer-a", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
haPeerB := &route.Route{ID: "ha-b", NetID: "lan", Peer: "peer-b", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
overlay := &route.Route{ID: "overlay", NetID: "overlay", Network: netip.MustParsePrefix("100.91.0.0/16"), NetworkType: route.IPv4Network}
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
routeSelector: routeselector.NewRouteSelector(),
clientRoutes: route.HAMap{
haPeerA.GetHAUniqueID(): {haPeerA, haPeerB},
overlay.GetHAUniqueID(): {overlay},
},
}
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24"}, m.CurrentRouteRange(), "every prefix must be listed once regardless of how many routes carry it")
}