From fd96b8c12fd9601b597372660baacaa63ba1f13e Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sun, 28 Jun 2026 12:44:40 +0200 Subject: [PATCH] [client] Improve network addresses filter (#6515) * [client] Filter link-local and multicast from network addresses Skip IPv6 link-local and multicast addresses when building the peer network_addresses list on non-iOS platforms, matching the existing iOS behavior. A flapping NIC's link-local address otherwise churns the peer meta on every interface up/down. * [client] Skip engine restart when default route is unchanged After the network monitor's debounce window, re-check the default next hop before triggering a client restart. A flapping NIC that returns to the same default route no longer forces a restart, avoiding redundant sync stream reconnects and peer meta churn. * [client] Exclude own overlay address from reported network addresses The peer's own WireGuard overlay address (v4 and v6) was reported in network_addresses. As the interface comes and goes during reconnects it churned the peer meta on the management server. Drop it in GetInfoWithChecks, matching the IP regardless of prefix length since the engine knows the overlay address with the network mask while the interface reports it as a host address. * [client] Treat missing default route per protocol in next-hop check A failed GetNextHop lookup is now treated as an absent route (zero Nexthop) and compared per protocol, instead of forcing a restart. In a single-stack network the missing IPv6 default route no longer counts as a change on every debounce, which previously defeated the unchanged-route check. * [client] Make next-hop check injectable for network monitor tests Move the next-hop comparison behind a NetworkMonitor field set by New(), so tests can supply a stub instead of hitting the host's real default route. Fixes the Event/MultiEvent tests hanging after the unchanged-route check was added. * Revert "[client] Make next-hop check injectable for network monitor tests" This reverts commit 88a9d96e8f26c987c93ca62811f2f5b106c31147. * Revert "[client] Treat missing default route per protocol in next-hop check" This reverts commit 0fb531e4bc8227eaac7ca6d9c9dca34a89b8f531. * Revert "[client] Skip engine restart when default route is unchanged" This reverts commit a071b55f35a7d5eb6f83d0a9b3bb17600c5217f8. --- client/internal/engine.go | 18 ++++++++++-- client/system/info.go | 23 ++++++++++++++- client/system/info_test.go | 40 ++++++++++++++++++++++++++ client/system/network_addr.go | 4 ++- client/system/network_addr_test.go | 45 ++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 client/system/network_addr_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 452075da8..e7f1c0501 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1066,7 +1066,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { } e.checks = checks - info, err := system.GetInfoWithChecks(e.ctx, checks) + info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...) if err != nil { log.Warnf("failed to get system info with checks: %v", err) info = system.GetInfo(e.ctx) @@ -1097,6 +1097,20 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { return nil } +// 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. +func (e *Engine) overlayAddresses() []netip.Addr { + var ips []netip.Addr + if e.config.WgAddr.IP.IsValid() { + ips = append(ips, e.config.WgAddr.IP) + } + if e.config.WgAddr.HasIPv6() { + ips = append(ips, e.config.WgAddr.IPv6) + } + return ips +} + func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { if e.wgInterface == nil { return errors.New("wireguard interface is not initialized") @@ -1240,7 +1254,7 @@ func (e *Engine) receiveManagementEvents() { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() - info, err := system.GetInfoWithChecks(e.ctx, e.checks) + info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...) if err != nil { log.Warnf("failed to get system info with checks: %v", err) info = system.GetInfo(e.ctx) diff --git a/client/system/info.go b/client/system/info.go index 477d5162b..27588859e 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -3,6 +3,7 @@ package system import ( "context" "net/netip" + "slices" "strings" log "github.com/sirupsen/logrus" @@ -121,6 +122,23 @@ func (i *Info) SetFlags( } } +// removeAddresses drops network addresses whose IP matches any of the given +// addresses, regardless of prefix length. Used to exclude the NetBird overlay +// address, which otherwise churns the meta as the interface comes and goes. +func (i *Info) removeAddresses(ips ...netip.Addr) { + if len(ips) == 0 { + return + } + filtered := i.NetworkAddresses[:0] + for _, addr := range i.NetworkAddresses { + if slices.Contains(ips, addr.NetIP.Addr()) { + continue + } + filtered = append(filtered, addr) + } + i.NetworkAddresses = filtered +} + // extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context func extractUserAgent(ctx context.Context) string { md, hasMeta := metadata.FromOutgoingContext(ctx) @@ -147,7 +165,9 @@ func extractDeviceName(ctx context.Context, defaultName string) string { } // GetInfoWithChecks retrieves and parses the system information with applied checks. -func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, error) { +// excludeIPs are dropped from the reported network addresses (e.g. our own +// WireGuard overlay address, which otherwise churns the peer meta). +func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, error) { log.Debugf("gathering system information with checks: %d", len(checks)) processCheckPaths := make([]string, 0) for _, check := range checks { @@ -162,6 +182,7 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, erro info := GetInfo(ctx) info.Files = files + info.removeAddresses(excludeIPs...) log.Debugf("all system information gathered successfully") return info, nil diff --git a/client/system/info_test.go b/client/system/info_test.go index 27821f3c5..dcda18e61 100644 --- a/client/system/info_test.go +++ b/client/system/info_test.go @@ -2,6 +2,7 @@ package system import ( "context" + "net/netip" "testing" "github.com/stretchr/testify/assert" @@ -43,3 +44,42 @@ func Test_NetAddresses(t *testing.T) { t.Errorf("no network addresses found") } } + +func TestInfo_RemoveAddresses(t *testing.T) { + addr := func(cidr string) NetworkAddress { + return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)} + } + + info := &Info{ + NetworkAddresses: []NetworkAddress{ + addr("192.168.1.7/24"), + addr("100.76.70.97/32"), // overlay v4 (host mask /32) + addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6 + addr("fd00:1234::1/64"), // overlay v6 + }, + } + + // Overlay addresses as the engine knows them, with a different mask (/16, /64). + info.removeAddresses( + netip.MustParseAddr("100.76.70.97"), + netip.MustParseAddr("fd00:1234::1"), + ) + + want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"} + if len(info.NetworkAddresses) != len(want) { + t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses) + } + for i, w := range want { + if got := info.NetworkAddresses[i].NetIP.String(); got != w { + t.Errorf("address[%d] = %s, want %s", i, got, w) + } + } +} + +func TestInfo_RemoveAddresses_NoOp(t *testing.T) { + info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}} + info.removeAddresses() + if len(info.NetworkAddresses) != 1 { + t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses) + } +} diff --git a/client/system/network_addr.go b/client/system/network_addr.go index 5423cf8ad..44260a938 100644 --- a/client/system/network_addr.go +++ b/client/system/network_addr.go @@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) { if !ok { return NetworkAddress{}, false } - if ipNet.IP.IsLoopback() { + // Skip link-local and multicast: they carry no routable peer info and the + // IPv6 link-local of a flapping NIC churns the meta on every up/down. + if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() { return NetworkAddress{}, false } prefix, err := netip.ParsePrefix(ipNet.String()) diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go new file mode 100644 index 000000000..a5f9c4279 --- /dev/null +++ b/client/system/network_addr_test.go @@ -0,0 +1,45 @@ +//go:build !ios + +package system + +import ( + "net" + "testing" +) + +func mustIPNet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + ip, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + t.Fatalf("parse %q: %v", cidr, err) + } + ipNet.IP = ip + return ipNet +} + +func TestToNetworkAddress_Filtering(t *testing.T) { + const mac = "c8:4b:d6:b6:04:ac" + + tests := []struct { + name string + cidr string + want bool + }{ + {"ipv4 global", "10.65.16.181/23", true}, + {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true}, + {"ipv4 loopback", "127.0.0.1/8", false}, + {"ipv6 loopback", "::1/128", false}, + {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false}, + {"ipv4 link-local", "169.254.1.2/16", false}, + {"ipv6 multicast", "ff02::1/128", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac) + if got != tt.want { + t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want) + } + }) + } +}