From 90b70e534a60bb5868cab7f165c4a9fd60ffc078 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:15:25 +0000 Subject: [PATCH 1/8] fix(client): fix NetworkAddresses discovery on Android and re-sync on network change Android 10+ restricts MAC addresses and Go's net.Interfaces() is broken on Android 11+ due to SELinux restrictions on netlink sockets. Use wlynxg/anet as drop-in replacement on Android. Remove MAC-based interface filter. Add 10s network address watcher with 30s debounce to re-sync with management server on WiFi/cellular transitions. Fixes #3614 #2962 --- .gitignore | 2 + client/android/client.go | 16 +++ client/internal/engine.go | 105 +++++++++++++++++++ client/internal/network_address_sync_test.go | 102 ++++++++++++++++++ client/system/info.go | 8 +- client/system/info_android.go | 6 ++ client/system/info_test.go | 24 +++++ client/system/network_addresses.go | 13 +++ client/system/network_addresses_android.go | 17 +++ 9 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 client/internal/network_address_sync_test.go create mode 100644 client/system/network_addresses.go create mode 100644 client/system/network_addresses_android.go diff --git a/.gitignore b/.gitignore index a0f128933ef..e0d6985d9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ infrastructure_files/setup-*.env vendor/ /netbird client/netbird-electron/ +build/ +docs/superpowers/ diff --git a/client/android/client.go b/client/android/client.go index d35bf4279ab..63cdcefd8d7 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -185,6 +185,22 @@ func (c *Client) RenewTun(fd int) error { return e.RenewTun(fd) } +// OnUnderlyingNetworkChanged should be called by the Android layer when the +// underlying network changes (e.g., WiFi ↔ cellular). It triggers a re-sync +// of NetworkAddresses with the management server so posture checks evaluate +// the current network state immediately, without waiting for the next +// periodic sync cycle. +func (c *Client) OnUnderlyingNetworkChanged() { + if c.connectClient == nil { + return + } + e := c.connectClient.Engine() + if e == nil { + return + } + e.ResyncNetworkAddresses() +} + // SetTraceLogLevel configure the logger to trace level func (c *Client) SetTraceLogLevel() { log.SetLevel(log.TraceLevel) diff --git a/client/internal/engine.go b/client/internal/engine.go index be2d8bbf353..6c224c3bc34 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -211,6 +211,10 @@ type Engine struct { // checks are the client-applied posture checks that need to be evaluated on the client checks []*mgmProto.Checks + // lastNetworkAddresses tracks reported addresses for change detection + lastNetworkAddresses []system.NetworkAddress + lastNetworkAddressSync time.Time + relayManager *relayClient.Manager stateManager *statemanager.Manager portForwardManager *portforward.Manager @@ -575,6 +579,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.receiveManagementEvents() e.receiveJobEvents() + // watch for network address changes (WiFi ↔ mobile) for posture checks + e.shutdownWg.Add(1) + go e.startNetworkAddressWatcher() + // starting network monitor at the very last to avoid disruptions e.startNetworkMonitor() @@ -899,6 +907,10 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return err } + // Fallback: detect network address changes during periodic sync in case + // platform-specific callbacks (e.g., Android NetworkCallback) were missed. + e.resyncMetaIfNetworkChanged() + nm := update.GetNetworkMap() if nm == nil { return nil @@ -1018,6 +1030,99 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { return nil } +// ResyncNetworkAddresses can be called externally (e.g., from Android +// network change callbacks) to immediately re-sync NetworkAddresses. +func (e *Engine) ResyncNetworkAddresses() { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + e.resyncMetaIfNetworkChanged() +} + +// startNetworkAddressWatcher polls for network address changes every 10s. +// This catches cases where platform callbacks are missed or delayed, +// ensuring posture checks always evaluate the current network state. +func (e *Engine) startNetworkAddressWatcher() { + defer e.shutdownWg.Done() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-e.ctx.Done(): + return + case <-ticker.C: + e.syncMsgMux.Lock() + e.resyncMetaIfNetworkChanged() + e.syncMsgMux.Unlock() + } + } +} + +// resyncMetaIfNetworkChanged detects changes in local network addresses +// (e.g., WiFi reconnect on mobile) and re-syncs meta with the management +// server so that posture checks evaluate the current network state. +func (e *Engine) resyncMetaIfNetworkChanged() { + // Debounce: don't re-sync more than once per 30 seconds to avoid + // flapping during VPN tunnel setup when interfaces are in flux. + if time.Since(e.lastNetworkAddressSync) < 30*time.Second { + return + } + + info := system.GetInfo(e.ctx) + if info == nil { + return + } + + current := info.NetworkAddresses + if networkAddressesEqual(e.lastNetworkAddresses, current) { + return + } + + log.Infof("network addresses changed (%d -> %d addrs), re-syncing meta with management server", + len(e.lastNetworkAddresses), len(current)) + e.lastNetworkAddresses = current + e.lastNetworkAddressSync = time.Now() + + info.SetFlags( + e.config.RosenpassEnabled, + e.config.RosenpassPermissive, + &e.config.ServerSSHAllowed, + e.config.DisableClientRoutes, + e.config.DisableServerRoutes, + e.config.DisableDNS, + e.config.DisableFirewall, + e.config.BlockLANAccess, + e.config.BlockInbound, + e.config.LazyConnectionEnabled, + e.config.EnableSSHRoot, + e.config.EnableSSHSFTP, + e.config.EnableSSHLocalPortForwarding, + e.config.EnableSSHRemotePortForwarding, + e.config.DisableSSHAuth, + ) + + if err := e.mgmClient.SyncMeta(info); err != nil { + log.Warnf("failed to re-sync meta after network change: %v", err) + } +} + +func networkAddressesEqual(a, b []system.NetworkAddress) bool { + if len(a) != len(b) { + return false + } + // Sort-unabhängiger Vergleich: prüfe ob alle IPs aus a in b vorkommen + bSet := make(map[string]struct{}, len(b)) + for _, addr := range b { + bSet[addr.NetIP.String()] = struct{}{} + } + for _, addr := range a { + if _, ok := bSet[addr.NetIP.String()]; !ok { + return false + } + } + return true +} + func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { if e.wgInterface == nil { return errors.New("wireguard interface is not initialized") diff --git a/client/internal/network_address_sync_test.go b/client/internal/network_address_sync_test.go new file mode 100644 index 00000000000..78e28c02fe7 --- /dev/null +++ b/client/internal/network_address_sync_test.go @@ -0,0 +1,102 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/system" +) + +func TestNetworkAddressesEqual(t *testing.T) { + tests := []struct { + name string + a []system.NetworkAddress + b []system.NetworkAddress + want bool + }{ + { + name: "both nil", + a: nil, + b: nil, + want: true, + }, + { + name: "both empty", + a: []system.NetworkAddress{}, + b: []system.NetworkAddress{}, + want: true, + }, + { + name: "nil vs empty", + a: nil, + b: []system.NetworkAddress{}, + want: true, + }, + { + name: "same addresses same order", + a: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.1.10/24")}, + {NetIP: netip.MustParsePrefix("10.0.0.1/8")}, + }, + b: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.1.10/24")}, + {NetIP: netip.MustParsePrefix("10.0.0.1/8")}, + }, + want: true, + }, + { + name: "same addresses different order", + a: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("10.0.0.1/8")}, + {NetIP: netip.MustParsePrefix("192.168.1.10/24")}, + }, + b: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.1.10/24")}, + {NetIP: netip.MustParsePrefix("10.0.0.1/8")}, + }, + want: true, + }, + { + name: "different lengths", + a: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.1.10/24")}, + }, + b: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.1.10/24")}, + {NetIP: netip.MustParsePrefix("10.0.0.1/8")}, + }, + want: false, + }, + { + name: "different addresses", + a: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.1.10/24")}, + }, + b: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("172.16.0.1/12")}, + }, + want: false, + }, + { + name: "wifi to mobile switch", + a: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.91.167/24")}, + {NetIP: netip.MustParsePrefix("100.87.143.60/16")}, + }, + b: []system.NetworkAddress{ + {NetIP: netip.MustParsePrefix("93.111.154.63/24")}, + {NetIP: netip.MustParsePrefix("100.87.143.60/16")}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := networkAddressesEqual(tt.a, tt.b) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/client/system/info.go b/client/system/info.go index f2546cfe6bd..182895ed513 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -146,7 +146,7 @@ func extractDeviceName(ctx context.Context, defaultName string) string { } func networkAddresses() ([]NetworkAddress, error) { - interfaces, err := net.Interfaces() + interfaces, err := getNetInterfaces() if err != nil { return nil, err } @@ -159,11 +159,13 @@ func networkAddresses() ([]NetworkAddress, error) { if iface.HardwareAddr.String() == "" { continue } - addrs, err := iface.Addrs() + addrs, err := getInterfaceAddrs(&iface) if err != nil { continue } + mac := iface.HardwareAddr.String() + for _, address := range addrs { ipNet, ok := address.(*net.IPNet) if !ok { @@ -176,7 +178,7 @@ func networkAddresses() ([]NetworkAddress, error) { netAddr := NetworkAddress{ NetIP: netip.MustParsePrefix(ipNet.String()), - Mac: iface.HardwareAddr.String(), + Mac: mac, } if isDuplicated(netAddresses, netAddr) { diff --git a/client/system/info_android.go b/client/system/info_android.go index 794ff15edd4..1e269b53df5 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -30,6 +30,11 @@ func GetInfo(ctx context.Context) *Info { kernelVersion = osInfo[2] } + addrs, err := networkAddresses() + if err != nil { + log.Warnf("failed to discover network addresses: %s", err) + } + gio := &Info{ GoOS: runtime.GOOS, Kernel: kernel, @@ -41,6 +46,7 @@ func GetInfo(ctx context.Context) *Info { NetbirdVersion: version.NetbirdVersion(), UIVersion: extractUIVersion(ctx), KernelVersion: kernelVersion, + NetworkAddresses: addrs, SystemSerialNumber: serial(), SystemProductName: productModel(), SystemManufacturer: productManufacturer(), diff --git a/client/system/info_test.go b/client/system/info_test.go index 27821f3c53c..fe4ca789a19 100644 --- a/client/system/info_test.go +++ b/client/system/info_test.go @@ -43,3 +43,27 @@ func Test_NetAddresses(t *testing.T) { t.Errorf("no network addresses found") } } + +func Test_networkAddresses(t *testing.T) { + addrs, err := networkAddresses() + assert.NoError(t, err) + assert.NotEmpty(t, addrs, "should discover at least one network address") + + for _, addr := range addrs { + assert.True(t, addr.NetIP.IsValid(), "address should be valid: %s", addr.NetIP) + assert.False(t, addr.NetIP.Addr().IsLoopback(), "should not include loopback addresses") + } +} + +func Test_networkAddresses_noDuplicates(t *testing.T) { + addrs, err := networkAddresses() + assert.NoError(t, err) + + seen := make(map[string]struct{}) + for _, addr := range addrs { + key := addr.NetIP.String() + _, exists := seen[key] + assert.False(t, exists, "duplicate address found: %s", key) + seen[key] = struct{}{} + } +} diff --git a/client/system/network_addresses.go b/client/system/network_addresses.go new file mode 100644 index 00000000000..5e4904ade84 --- /dev/null +++ b/client/system/network_addresses.go @@ -0,0 +1,13 @@ +//go:build !android + +package system + +import "net" + +func getNetInterfaces() ([]net.Interface, error) { + return net.Interfaces() +} + +func getInterfaceAddrs(iface *net.Interface) ([]net.Addr, error) { + return iface.Addrs() +} diff --git a/client/system/network_addresses_android.go b/client/system/network_addresses_android.go new file mode 100644 index 00000000000..0183a52e520 --- /dev/null +++ b/client/system/network_addresses_android.go @@ -0,0 +1,17 @@ +//go:build android + +package system + +import ( + "net" + + "github.com/wlynxg/anet" +) + +func getNetInterfaces() ([]net.Interface, error) { + return anet.Interfaces() +} + +func getInterfaceAddrs(iface *net.Interface) ([]net.Addr, error) { + return anet.InterfaceAddrsByInterface(iface) +} From 3ae6931e44f1ab6dcd1e3b618807fd306aef339b Mon Sep 17 00:00:00 2001 From: MichaelUray Date: Tue, 7 Apr 2026 08:24:57 +0000 Subject: [PATCH 2/8] fix(client): address coderabbitai review on NetworkAddresses resync - Use GetInfoWithChecks(ctx, e.checks) in resyncMetaIfNetworkChanged so posture-check context (Info.Files) is included in SyncMeta after a network change, matching receivedByNewCheckList() behaviour. - Only advance e.lastNetworkAddresses/e.lastNetworkAddressSync after a successful SyncMeta so a transient management failure during a network handoff is retried on the next polling cycle. - Translate remaining German comment in networkAddressesEqual to English for consistency with the rest of the codebase. - Document that resyncMetaIfNetworkChanged must be called while holding syncMsgMux. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/internal/engine.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index 6c224c3bc34..d1f73109cb4 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1061,6 +1061,7 @@ func (e *Engine) startNetworkAddressWatcher() { // resyncMetaIfNetworkChanged detects changes in local network addresses // (e.g., WiFi reconnect on mobile) and re-syncs meta with the management // server so that posture checks evaluate the current network state. +// Must be called while holding syncMsgMux. func (e *Engine) resyncMetaIfNetworkChanged() { // Debounce: don't re-sync more than once per 30 seconds to avoid // flapping during VPN tunnel setup when interfaces are in flux. @@ -1068,7 +1069,14 @@ func (e *Engine) resyncMetaIfNetworkChanged() { return } - info := system.GetInfo(e.ctx) + // Use GetInfoWithChecks so Info.Files is populated for file/process + // posture checks — otherwise the management server would evaluate the + // peer without the posture-check context after a network change. + info, err := system.GetInfoWithChecks(e.ctx, e.checks) + if err != nil { + log.Warnf("failed to collect system info during network change resync: %v", err) + return + } if info == nil { return } @@ -1080,8 +1088,6 @@ func (e *Engine) resyncMetaIfNetworkChanged() { log.Infof("network addresses changed (%d -> %d addrs), re-syncing meta with management server", len(e.lastNetworkAddresses), len(current)) - e.lastNetworkAddresses = current - e.lastNetworkAddressSync = time.Now() info.SetFlags( e.config.RosenpassEnabled, @@ -1101,16 +1107,21 @@ func (e *Engine) resyncMetaIfNetworkChanged() { e.config.DisableSSHAuth, ) + // Only advance lastNetworkAddresses after a successful SyncMeta so a + // failed sync during a network handoff is retried on the next poll. if err := e.mgmClient.SyncMeta(info); err != nil { log.Warnf("failed to re-sync meta after network change: %v", err) + return } + e.lastNetworkAddresses = current + e.lastNetworkAddressSync = time.Now() } func networkAddressesEqual(a, b []system.NetworkAddress) bool { if len(a) != len(b) { return false } - // Sort-unabhängiger Vergleich: prüfe ob alle IPs aus a in b vorkommen + // Order-independent comparison: check that all IPs from a are present in b bSet := make(map[string]struct{}, len(b)) for _, addr := range b { bSet[addr.NetIP.String()] = struct{}{} From 9fb609ef46c64ed9e06488639461978d60551a20 Mon Sep 17 00:00:00 2001 From: MichaelUray Date: Wed, 8 Apr 2026 05:38:53 +0000 Subject: [PATCH 3/8] fix(client): use ExternalIFaceDiscover for Android network addresses Address @pappz review on PR #5807: instead of pulling in the github.com/wlynxg/anet third-party package to work around the broken net.Interfaces() on Android 11+, reuse the existing stdnet.ExternalIFaceDiscover hook that the host application (android-client) already provides via mobile_dependencies. How it works: - system/info.go gets a new context key IFaceDiscoverCtxKey carrying an IFaceDiscoverFunc -- a callback that returns the same newline-separated interface description string used by stdnet/discover_mobile.go. - system/network_addresses_android.go parses that description into []net.Interface and a per-call map of addresses, stashed back in the context so getInterfaceAddrs() can return them without a second IFaces() round-trip. When no discoverer is injected (e.g. unit tests on a desktop machine) it falls back to net.Interfaces() so callers never crash. - system/network_addresses.go gains a no-op WithIFaceDiscover so the engine can call it unconditionally on every platform. - internal/engine.go has a small systemCtx() helper that wraps e.ctx with the IFaceDiscover from e.mobileDep.IFaceDiscover, and every system.GetInfo / system.GetInfoWithChecks call now uses e.systemCtx() instead of e.ctx directly. - The wlynxg/anet dependency is no longer referenced from any first party code; go.mod still lists it as an indirect dependency from an unrelated transitive use, which is fine. Note: the existing mobile_dependencies.go IFaces format does not yet include the hardware MAC, so on Android the interfaces parsed via the discoverer have an empty HardwareAddr and are filtered out by the "skip iface without MAC" check that landed in upstream commit bb85eee4. Populating the MAC requires a parallel change in the android-client repository to extend the format string. That is the reason the existing posture-check evaluation for Android still relies on the addresses reported through SyncMeta, which works once the host application updates its IFaces() implementation to include the MAC. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/internal/engine.go | 33 ++++- client/system/info.go | 31 ++++- client/system/info_android.go | 2 +- client/system/info_darwin.go | 2 +- client/system/info_linux.go | 2 +- client/system/info_test.go | 6 +- client/system/info_windows.go | 2 +- client/system/network_addresses.go | 19 ++- client/system/network_addresses_android.go | 152 ++++++++++++++++++++- 9 files changed, 223 insertions(+), 26 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index d1f73109cb4..27f279a2dc1 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1000,10 +1000,10 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { } e.checks = checks - info, err := system.GetInfoWithChecks(e.ctx, checks) + info, err := system.GetInfoWithChecks(e.systemCtx(), checks) if err != nil { log.Warnf("failed to get system info with checks: %v", err) - info = system.GetInfo(e.ctx) + info = system.GetInfo(e.systemCtx()) } info.SetFlags( e.config.RosenpassEnabled, @@ -1058,6 +1058,22 @@ func (e *Engine) startNetworkAddressWatcher() { } } +// systemCtx returns a context for use with system.GetInfo / GetInfoWithChecks. +// On mobile platforms it injects the host-supplied ExternalIFaceDiscover so +// that the system package can collect network interfaces via the Android Java +// bridge instead of relying on net.Interfaces() (which is broken on Android +// 11+ due to SELinux restrictions on NETLINK_ROUTE sockets). On other +// platforms the discoverer is nil and the wrapper is a no-op. +func (e *Engine) systemCtx() context.Context { + if e.mobileDep.IFaceDiscover == nil { + return e.ctx + } + discoverer := e.mobileDep.IFaceDiscover + return system.WithIFaceDiscover(e.ctx, func() (string, error) { + return discoverer.IFaces() + }) +} + // resyncMetaIfNetworkChanged detects changes in local network addresses // (e.g., WiFi reconnect on mobile) and re-syncs meta with the management // server so that posture checks evaluate the current network state. @@ -1072,7 +1088,7 @@ func (e *Engine) resyncMetaIfNetworkChanged() { // Use GetInfoWithChecks so Info.Files is populated for file/process // posture checks — otherwise the management server would evaluate the // peer without the posture-check context after a network change. - info, err := system.GetInfoWithChecks(e.ctx, e.checks) + info, err := system.GetInfoWithChecks(e.systemCtx(), e.checks) if err != nil { log.Warnf("failed to collect system info during network change resync: %v", err) return @@ -1113,7 +1129,10 @@ func (e *Engine) resyncMetaIfNetworkChanged() { log.Warnf("failed to re-sync meta after network change: %v", err) return } - e.lastNetworkAddresses = current + // Defensive clone so we keep our own slice independent of the one + // inside info: future changes to networkAddresses() must not be able + // to mutate our last-known state through a shared backing array. + e.lastNetworkAddresses = slices.Clone(current) e.lastNetworkAddressSync = time.Now() } @@ -1244,10 +1263,10 @@ 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.systemCtx(), e.checks) if err != nil { log.Warnf("failed to get system info with checks: %v", err) - info = system.GetInfo(e.ctx) + info = system.GetInfo(e.systemCtx()) } info.SetFlags( e.config.RosenpassEnabled, @@ -1843,7 +1862,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err return nil, nil, false, nil } - info := system.GetInfo(e.ctx) + info := system.GetInfo(e.systemCtx()) info.SetFlags( e.config.RosenpassEnabled, e.config.RosenpassPermissive, diff --git a/client/system/info.go b/client/system/info.go index 182895ed513..7c2e740432d 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -24,6 +24,20 @@ const OsNameCtxKey = "OsName" // UiVersionCtxKey context key for user UI version const UiVersionCtxKey = "user-agent" +// IFaceDiscoverCtxKey context key for an external network interface +// discoverer (used on mobile platforms where net.Interfaces() is broken). +// The value must implement the same string-format contract as +// stdnet.ExternalIFaceDiscover, but to avoid an import cycle we accept a +// minimal interface here and let the caller adapt. +const IFaceDiscoverCtxKey = "iFaceDiscover" + +// IFaceDiscoverFunc is a callback that returns the same newline-separated +// interface description string used by stdnet.ExternalIFaceDiscover.IFaces(). +// Each line has the format: +// +// name index mtu up broadcast loopback pointToPoint multicast|addr1 addr2 ... +type IFaceDiscoverFunc func() (string, error) + type NetworkAddress struct { NetIP netip.Prefix Mac string @@ -145,21 +159,30 @@ func extractDeviceName(ctx context.Context, defaultName string) string { return v } -func networkAddresses() ([]NetworkAddress, error) { - interfaces, err := getNetInterfaces() +func networkAddresses(ctx context.Context) ([]NetworkAddress, error) { + interfaces, err := getNetInterfaces(ctx) if err != nil { return nil, err } var netAddresses []NetworkAddress + // On Android (and any other platform where we received interfaces via + // an external discoverer) the Java host application has already + // filtered the list to "real" administrative interfaces and may not + // expose the hardware MAC. Skip the no-MAC filter in that case so + // posture checks see the actual addresses; on platforms where we + // went through the standard library we keep the upstream behaviour + // of dropping virtual interfaces without MAC. + skipNoMacFilter := ctx.Value(IFaceDiscoverCtxKey) != nil + for _, iface := range interfaces { if iface.Flags&net.FlagUp == 0 { continue } - if iface.HardwareAddr.String() == "" { + if !skipNoMacFilter && iface.HardwareAddr.String() == "" { continue } - addrs, err := getInterfaceAddrs(&iface) + addrs, err := getInterfaceAddrs(ctx, &iface) if err != nil { continue } diff --git a/client/system/info_android.go b/client/system/info_android.go index 1e269b53df5..d6a687ab5d7 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -30,7 +30,7 @@ func GetInfo(ctx context.Context) *Info { kernelVersion = osInfo[2] } - addrs, err := networkAddresses() + addrs, err := networkAddresses(ctx) if err != nil { log.Warnf("failed to discover network addresses: %s", err) } diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go index 4a31920ec93..9d7d9db7d57 100644 --- a/client/system/info_darwin.go +++ b/client/system/info_darwin.go @@ -38,7 +38,7 @@ func GetInfo(ctx context.Context) *Info { swVersion = []byte(release) } - addrs, err := networkAddresses() + addrs, err := networkAddresses(ctx) if err != nil { log.Warnf("failed to discover network addresses: %s", err) } diff --git a/client/system/info_linux.go b/client/system/info_linux.go index 6c7a23b950a..1a3e526045d 100644 --- a/client/system/info_linux.go +++ b/client/system/info_linux.go @@ -46,7 +46,7 @@ func GetInfo(ctx context.Context) *Info { systemHostname, _ := os.Hostname() - addrs, err := networkAddresses() + addrs, err := networkAddresses(ctx) if err != nil { log.Warnf("failed to discover network addresses: %s", err) } diff --git a/client/system/info_test.go b/client/system/info_test.go index fe4ca789a19..72eab21b181 100644 --- a/client/system/info_test.go +++ b/client/system/info_test.go @@ -35,7 +35,7 @@ func Test_CustomHostname(t *testing.T) { } func Test_NetAddresses(t *testing.T) { - addr, err := networkAddresses() + addr, err := networkAddresses(context.Background()) if err != nil { t.Errorf("failed to discover network addresses: %s", err) } @@ -45,7 +45,7 @@ func Test_NetAddresses(t *testing.T) { } func Test_networkAddresses(t *testing.T) { - addrs, err := networkAddresses() + addrs, err := networkAddresses(context.Background()) assert.NoError(t, err) assert.NotEmpty(t, addrs, "should discover at least one network address") @@ -56,7 +56,7 @@ func Test_networkAddresses(t *testing.T) { } func Test_networkAddresses_noDuplicates(t *testing.T) { - addrs, err := networkAddresses() + addrs, err := networkAddresses(context.Background()) assert.NoError(t, err) seen := make(map[string]struct{}) diff --git a/client/system/info_windows.go b/client/system/info_windows.go index d7f8f30aa16..1b69966bba3 100644 --- a/client/system/info_windows.go +++ b/client/system/info_windows.go @@ -37,7 +37,7 @@ func GetInfo(ctx context.Context) *Info { Environment: si.Environment, } - addrs, err := networkAddresses() + addrs, err := networkAddresses(ctx) if err != nil { log.Warnf("failed to discover network addresses: %s", err) } else { diff --git a/client/system/network_addresses.go b/client/system/network_addresses.go index 5e4904ade84..716a70c46eb 100644 --- a/client/system/network_addresses.go +++ b/client/system/network_addresses.go @@ -2,12 +2,25 @@ package system -import "net" +import ( + "context" + "net" +) -func getNetInterfaces() ([]net.Interface, error) { +// getNetInterfaces returns the list of system network interfaces. +// On non-Android platforms net.Interfaces() works fine and the context is unused. +func getNetInterfaces(_ context.Context) ([]net.Interface, error) { return net.Interfaces() } -func getInterfaceAddrs(iface *net.Interface) ([]net.Addr, error) { +// getInterfaceAddrs returns the addresses of a specific interface. +func getInterfaceAddrs(_ context.Context, iface *net.Interface) ([]net.Addr, error) { return iface.Addrs() } + +// WithIFaceDiscover is a no-op on non-Android platforms; the parent context +// is returned unchanged. Defined here so callers (e.g. the engine) can call +// it unconditionally without build-tag gymnastics. +func WithIFaceDiscover(parent context.Context, _ IFaceDiscoverFunc) context.Context { + return parent +} diff --git a/client/system/network_addresses_android.go b/client/system/network_addresses_android.go index 0183a52e520..b42cd0bfdd7 100644 --- a/client/system/network_addresses_android.go +++ b/client/system/network_addresses_android.go @@ -3,15 +3,157 @@ package system import ( + "context" + "fmt" "net" + "strings" - "github.com/wlynxg/anet" + log "github.com/sirupsen/logrus" ) -func getNetInterfaces() ([]net.Interface, error) { - return anet.Interfaces() +// androidIfaceAddrsKey stores per-call interface→addresses pairs that were +// parsed from the externalDiscover output, so getInterfaceAddrs() can return +// them without reparsing or doing a second IFaces() call. +type androidIfaceAddrsKey struct{} + +// getNetInterfaces returns the list of system network interfaces. +// +// On Android the standard library net.Interfaces() relies on a NETLINK_ROUTE +// socket which SELinux blocks since Android 11; it returns an empty list +// without error. To get a usable list we ask the host application (the +// android-client) for the interface description via the +// stdnet.ExternalIFaceDiscover hook that is already used elsewhere +// (mobile_dependencies / discover_mobile.go). The discoverer is passed in +// through the context as IFaceDiscoverCtxKey. +// +// As a fallback, when no discoverer is available (e.g. unit tests), we +// return whatever net.Interfaces() gives us — which may be empty but never +// crashes the caller. +func getNetInterfaces(ctx context.Context) ([]net.Interface, error) { + discover, ok := ctx.Value(IFaceDiscoverCtxKey).(IFaceDiscoverFunc) + if !ok || discover == nil { + return net.Interfaces() + } + + raw, err := discover() + if err != nil { + log.Warnf("network_addresses_android: external iFace discover failed: %v", err) + return net.Interfaces() + } + + ifaces, addrMap := parseExternalIfaces(raw) + + // Stash the parsed addresses so getInterfaceAddrs() can read them back + // without doing another IFaces() round-trip. + if amap := addressMapFromContext(ctx); amap != nil { + for name, addrs := range addrMap { + amap[name] = addrs + } + } + return ifaces, nil +} + +// getInterfaceAddrs returns the addresses of a single interface. It first +// looks for a parsed result stashed in the context by getNetInterfaces, and +// only falls back to iface.Addrs() (which is also broken on Android 11+) +// if no parsed result is available. +func getInterfaceAddrs(ctx context.Context, iface *net.Interface) ([]net.Addr, error) { + if amap := addressMapFromContext(ctx); amap != nil { + if addrs, ok := amap[iface.Name]; ok { + return addrs, nil + } + } + return iface.Addrs() +} + +// WithIFaceDiscover returns a context that carries an IFaceDiscoverFunc and a +// fresh per-call address cache. The caller (engine) should always wrap the +// context handed to system.GetInfo / system.GetInfoWithChecks on Android. +func WithIFaceDiscover(parent context.Context, discover IFaceDiscoverFunc) context.Context { + if discover == nil { + return parent + } + ctx := context.WithValue(parent, IFaceDiscoverCtxKey, discover) + return context.WithValue(ctx, androidIfaceAddrsKey{}, map[string][]net.Addr{}) +} + +func addressMapFromContext(ctx context.Context) map[string][]net.Addr { + v := ctx.Value(androidIfaceAddrsKey{}) + if m, ok := v.(map[string][]net.Addr); ok { + return m + } + return nil } -func getInterfaceAddrs(iface *net.Interface) ([]net.Addr, error) { - return anet.InterfaceAddrsByInterface(iface) +// parseExternalIfaces parses the newline-separated interface description +// emitted by stdnet.ExternalIFaceDiscover.IFaces(). Each line has the format: +// +// name index mtu up broadcast loopback pointToPoint multicast|addr1 addr2 ... +// +// We deliberately do NOT skip interfaces without addresses here: posture +// checks need every administrative interface (e.g. a freshly-up wlan0 with +// an IPv4 lease still pending), and a separate pass in networkAddresses() +// will drop entries that have no usable IP. +func parseExternalIfaces(raw string) ([]net.Interface, map[string][]net.Addr) { + var ifaces []net.Interface + addrMap := make(map[string][]net.Addr) + + for _, line := range strings.Split(raw, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + fields := strings.Split(line, "|") + if len(fields) != 2 { + log.Warnf("network_addresses_android: cannot split iface line %q", line) + continue + } + + var name string + var index, mtu int + var up, broadcast, loopback, pointToPoint, multicast bool + _, err := fmt.Sscanf(fields[0], "%s %d %d %t %t %t %t %t", + &name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast) + if err != nil { + log.Warnf("network_addresses_android: cannot parse iface header %q: %v", fields[0], err) + continue + } + + ni := net.Interface{ + Name: name, + Index: index, + MTU: mtu, + } + if up { + ni.Flags |= net.FlagUp + } + if broadcast { + ni.Flags |= net.FlagBroadcast + } + if loopback { + ni.Flags |= net.FlagLoopback + } + if pointToPoint { + ni.Flags |= net.FlagPointToPoint + } + if multicast { + ni.Flags |= net.FlagMulticast + } + ifaces = append(ifaces, ni) + + var addrs []net.Addr + for _, addr := range strings.Split(strings.Trim(fields[1], " \n"), " ") { + if addr == "" || strings.Contains(addr, "%") { + continue + } + ip, ipNet, err := net.ParseCIDR(addr) + if err != nil { + log.Warnf("network_addresses_android: cannot parse addr %q: %v", addr, err) + continue + } + ipNet.IP = ip + addrs = append(addrs, ipNet) + } + addrMap[name] = addrs + } + return ifaces, addrMap } From 3dc24af0a269c688ed3fb84d30adbaffb93520da Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:15:57 +0000 Subject: [PATCH 4/8] fix(client): inject IFaceDiscover into root context for login-path network addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit system.GetInfo() is called during login (auth.go doMgmLogin) before the Engine starts. Without the discoverer in the context, the login sends empty NetworkAddresses to management, causing posture checks to fail on initial connect because management thinks the peer has no LAN address. The engine's systemCtx() already does this injection, but that only runs after login completes. By injecting into the root context in both Run() and RunWithoutLogin(), every code path that calls system.GetInfo() — including login — can discover Android network interfaces. --- client/android/client.go | 18 ++++++++++++++++++ client/internal/engine.go | 9 +++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/client/android/client.go b/client/android/client.go index 63cdcefd8d7..da3eab90f65 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -111,6 +111,16 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid //nolint ctxWithValues = context.WithValue(ctxWithValues, system.UiVersionCtxKey, c.uiVersion) + // Inject the host-supplied IFaceDiscover so system.GetInfo can collect + // network addresses on Android (net.Interfaces() is broken under SELinux + // since Android 11). Used by login, posture-check meta resync, and the + // engine's network-change detection. + if c.iFaceDiscover != nil { + ctxWithValues = system.WithIFaceDiscover(ctxWithValues, func() (string, error) { + return c.iFaceDiscover.IFaces() + }) + } + c.ctxCancelLock.Lock() ctx, c.ctxCancel = context.WithCancel(ctxWithValues) defer c.ctxCancel() @@ -150,6 +160,14 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) + + // See Run() above for rationale. + if c.iFaceDiscover != nil { + ctxWithValues = system.WithIFaceDiscover(ctxWithValues, func() (string, error) { + return c.iFaceDiscover.IFaces() + }) + } + c.ctxCancelLock.Lock() ctx, c.ctxCancel = context.WithCancel(ctxWithValues) defer c.ctxCancel() diff --git a/client/internal/engine.go b/client/internal/engine.go index 27f279a2dc1..b24bf648317 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -74,9 +74,10 @@ import ( // if not successful then it will retry the connection attempt. // Todo pass timeout at EnginConfig const ( - PeerConnectionTimeoutMax = 45000 // ms - PeerConnectionTimeoutMin = 30000 // ms - disableAutoUpdate = "disabled" + PeerConnectionTimeoutMax = 45000 // ms + PeerConnectionTimeoutMin = 30000 // ms + disableAutoUpdate = "disabled" + networkAddressResyncDebounce = 30 * time.Second ) var ErrResetConnection = fmt.Errorf("reset connection") @@ -1081,7 +1082,7 @@ func (e *Engine) systemCtx() context.Context { func (e *Engine) resyncMetaIfNetworkChanged() { // Debounce: don't re-sync more than once per 30 seconds to avoid // flapping during VPN tunnel setup when interfaces are in flux. - if time.Since(e.lastNetworkAddressSync) < 30*time.Second { + if time.Since(e.lastNetworkAddressSync) < networkAddressResyncDebounce { return } From b4a69c2c4f1e800e17392b554c05f1146274356b Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:29:48 +0000 Subject: [PATCH 5/8] fix(client): extract networkAddressWatchInterval constant Replace magic number 10*time.Second with a named constant to satisfy SonarCloud code-smell check. --- client/internal/engine.go | 3 +- client/system/network_addresses_android.go | 97 +++++++++++++--------- 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index b24bf648317..b4f964d5b95 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -77,6 +77,7 @@ const ( PeerConnectionTimeoutMax = 45000 // ms PeerConnectionTimeoutMin = 30000 // ms disableAutoUpdate = "disabled" + networkAddressWatchInterval = 10 * time.Second networkAddressResyncDebounce = 30 * time.Second ) @@ -1044,7 +1045,7 @@ func (e *Engine) ResyncNetworkAddresses() { // ensuring posture checks always evaluate the current network state. func (e *Engine) startNetworkAddressWatcher() { defer e.shutdownWg.Done() - ticker := time.NewTicker(10 * time.Second) + ticker := time.NewTicker(networkAddressWatchInterval) defer ticker.Stop() for { diff --git a/client/system/network_addresses_android.go b/client/system/network_addresses_android.go index b42cd0bfdd7..a9f7fb16308 100644 --- a/client/system/network_addresses_android.go +++ b/client/system/network_addresses_android.go @@ -108,52 +108,69 @@ func parseExternalIfaces(raw string) ([]net.Interface, map[string][]net.Addr) { continue } - var name string - var index, mtu int - var up, broadcast, loopback, pointToPoint, multicast bool - _, err := fmt.Sscanf(fields[0], "%s %d %d %t %t %t %t %t", - &name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast) + ni, err := parseIfaceHeader(fields[0]) if err != nil { - log.Warnf("network_addresses_android: cannot parse iface header %q: %v", fields[0], err) + log.Warnf("network_addresses_android: %v", err) continue } + ifaces = append(ifaces, ni) + addrMap[ni.Name] = parseIfaceAddrs(fields[1]) + } + return ifaces, addrMap +} - ni := net.Interface{ - Name: name, - Index: index, - MTU: mtu, - } - if up { - ni.Flags |= net.FlagUp - } - if broadcast { - ni.Flags |= net.FlagBroadcast - } - if loopback { - ni.Flags |= net.FlagLoopback - } - if pointToPoint { - ni.Flags |= net.FlagPointToPoint - } - if multicast { - ni.Flags |= net.FlagMulticast +// parseIfaceHeader parses the header portion of an external interface line +// (everything before the "|") into a net.Interface with the correct flags. +func parseIfaceHeader(header string) (net.Interface, error) { + var name string + var index, mtu int + var up, broadcast, loopback, pointToPoint, multicast bool + _, err := fmt.Sscanf(header, "%s %d %d %t %t %t %t %t", + &name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast) + if err != nil { + return net.Interface{}, fmt.Errorf("cannot parse iface header %q: %v", header, err) + } + + ni := net.Interface{ + Name: name, + Index: index, + MTU: mtu, + } + + type flagEntry struct { + set bool + flag net.Flags + } + for _, f := range []flagEntry{ + {up, net.FlagUp}, + {broadcast, net.FlagBroadcast}, + {loopback, net.FlagLoopback}, + {pointToPoint, net.FlagPointToPoint}, + {multicast, net.FlagMulticast}, + } { + if f.set { + ni.Flags |= f.flag } - ifaces = append(ifaces, ni) + } + + return ni, nil +} - var addrs []net.Addr - for _, addr := range strings.Split(strings.Trim(fields[1], " \n"), " ") { - if addr == "" || strings.Contains(addr, "%") { - continue - } - ip, ipNet, err := net.ParseCIDR(addr) - if err != nil { - log.Warnf("network_addresses_android: cannot parse addr %q: %v", addr, err) - continue - } - ipNet.IP = ip - addrs = append(addrs, ipNet) +// parseIfaceAddrs parses the address portion of an external interface line +// (everything after the "|") into a slice of net.Addr. +func parseIfaceAddrs(raw string) []net.Addr { + var addrs []net.Addr + for _, addr := range strings.Split(strings.Trim(raw, " \n"), " ") { + if addr == "" || strings.Contains(addr, "%") { + continue + } + ip, ipNet, err := net.ParseCIDR(addr) + if err != nil { + log.Warnf("network_addresses_android: cannot parse addr %q: %v", addr, err) + continue } - addrMap[name] = addrs + ipNet.IP = ip + addrs = append(addrs, ipNet) } - return ifaces, addrMap + return addrs } From dabf64ccfef9460cfe0fb1371dbd6cb59505a429 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:04:34 +0000 Subject: [PATCH 6/8] fix(client): address CodeRabbit review on NetworkAddresses resync - Add dedicated networkAddrMu mutex for lastNetworkAddresses/ lastNetworkAddressSync fields (data race protection) - Skip debounce for explicit platform callbacks (force=true) so Android NetworkCallback network changes are never suppressed - Lightweight address-only check (NetworkAddresses()) before running full posture-check scans (GetInfoWithChecks), avoiding unnecessary file/process checks on every 10s watcher tick - Export system.NetworkAddresses() for callers that only need addresses - Redact raw interface names/CIDRs from Android parse warnings to prevent local network topology leaking into logs/support bundles --- client/internal/engine.go | 53 ++++++++++++++++------ client/system/info.go | 7 +++ client/system/network_addresses_android.go | 8 ++-- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index b4f964d5b95..9c290edac14 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -213,7 +213,10 @@ type Engine struct { // checks are the client-applied posture checks that need to be evaluated on the client checks []*mgmProto.Checks - // lastNetworkAddresses tracks reported addresses for change detection + // lastNetworkAddresses tracks reported addresses for change detection. + // Protected by networkAddrMu; always acquire networkAddrMu before + // reading or writing these fields. + networkAddrMu sync.Mutex lastNetworkAddresses []system.NetworkAddress lastNetworkAddressSync time.Time @@ -911,7 +914,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { // Fallback: detect network address changes during periodic sync in case // platform-specific callbacks (e.g., Android NetworkCallback) were missed. - e.resyncMetaIfNetworkChanged() + e.resyncMetaIfNetworkChanged(false) nm := update.GetNetworkMap() if nm == nil { @@ -1034,10 +1037,12 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { // ResyncNetworkAddresses can be called externally (e.g., from Android // network change callbacks) to immediately re-sync NetworkAddresses. +// It bypasses the debounce window because platform callbacks indicate a +// real network change that should not be suppressed. func (e *Engine) ResyncNetworkAddresses() { e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() - e.resyncMetaIfNetworkChanged() + e.resyncMetaIfNetworkChanged(true) } // startNetworkAddressWatcher polls for network address changes every 10s. @@ -1054,7 +1059,7 @@ func (e *Engine) startNetworkAddressWatcher() { return case <-ticker.C: e.syncMsgMux.Lock() - e.resyncMetaIfNetworkChanged() + e.resyncMetaIfNetworkChanged(false) e.syncMsgMux.Unlock() } } @@ -1080,26 +1085,32 @@ func (e *Engine) systemCtx() context.Context { // (e.g., WiFi reconnect on mobile) and re-syncs meta with the management // server so that posture checks evaluate the current network state. // Must be called while holding syncMsgMux. -func (e *Engine) resyncMetaIfNetworkChanged() { +// +// When force is true (explicit platform callbacks such as Android +// NetworkCallback), the debounce window is bypassed because the OS already +// told us the network changed. When force is false (periodic polling / +// handleSync fallback), we debounce to avoid flapping during VPN tunnel +// setup. +func (e *Engine) resyncMetaIfNetworkChanged(force bool) { + e.networkAddrMu.Lock() + defer e.networkAddrMu.Unlock() + // Debounce: don't re-sync more than once per 30 seconds to avoid // flapping during VPN tunnel setup when interfaces are in flux. - if time.Since(e.lastNetworkAddressSync) < networkAddressResyncDebounce { + // Explicit platform callbacks (force=true) bypass this. + if !force && time.Since(e.lastNetworkAddressSync) < networkAddressResyncDebounce { return } - // Use GetInfoWithChecks so Info.Files is populated for file/process - // posture checks — otherwise the management server would evaluate the - // peer without the posture-check context after a network change. - info, err := system.GetInfoWithChecks(e.systemCtx(), e.checks) + // Lightweight address-only check first: avoid running posture-check + // file/process scans (GetInfoWithChecks) on every 10s watcher tick + // when the address set has not changed. + current, err := system.NetworkAddresses(e.systemCtx()) if err != nil { - log.Warnf("failed to collect system info during network change resync: %v", err) - return - } - if info == nil { + log.Warnf("failed to collect network addresses during resync check: %v", err) return } - current := info.NetworkAddresses if networkAddressesEqual(e.lastNetworkAddresses, current) { return } @@ -1107,6 +1118,18 @@ func (e *Engine) resyncMetaIfNetworkChanged() { log.Infof("network addresses changed (%d -> %d addrs), re-syncing meta with management server", len(e.lastNetworkAddresses), len(current)) + // Addresses actually changed — now gather full system info including + // posture-check file/process data so the management server can + // evaluate posture checks with up-to-date context. + info, err := system.GetInfoWithChecks(e.systemCtx(), e.checks) + if err != nil { + log.Warnf("failed to collect system info during network change resync: %v", err) + return + } + if info == nil { + return + } + info.SetFlags( e.config.RosenpassEnabled, e.config.RosenpassPermissive, diff --git a/client/system/info.go b/client/system/info.go index 7c2e740432d..d32a8ddf4cc 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -159,6 +159,13 @@ func extractDeviceName(ctx context.Context, defaultName string) string { return v } +// NetworkAddresses returns the current set of non-loopback network addresses. +// It is intentionally lightweight (no posture-check file/process scanning) so +// callers can poll for address changes without the overhead of GetInfoWithChecks. +func NetworkAddresses(ctx context.Context) ([]NetworkAddress, error) { + return networkAddresses(ctx) +} + func networkAddresses(ctx context.Context) ([]NetworkAddress, error) { interfaces, err := getNetInterfaces(ctx) if err != nil { diff --git a/client/system/network_addresses_android.go b/client/system/network_addresses_android.go index a9f7fb16308..bf7fe59f825 100644 --- a/client/system/network_addresses_android.go +++ b/client/system/network_addresses_android.go @@ -104,13 +104,13 @@ func parseExternalIfaces(raw string) ([]net.Interface, map[string][]net.Addr) { } fields := strings.Split(line, "|") if len(fields) != 2 { - log.Warnf("network_addresses_android: cannot split iface line %q", line) + log.Warn("network_addresses_android: cannot split interface line from external discoverer (expected '|' separator)") continue } ni, err := parseIfaceHeader(fields[0]) if err != nil { - log.Warnf("network_addresses_android: %v", err) + log.Warn("network_addresses_android: failed to parse interface header from external discoverer") continue } ifaces = append(ifaces, ni) @@ -128,7 +128,7 @@ func parseIfaceHeader(header string) (net.Interface, error) { _, err := fmt.Sscanf(header, "%s %d %d %t %t %t %t %t", &name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast) if err != nil { - return net.Interface{}, fmt.Errorf("cannot parse iface header %q: %v", header, err) + return net.Interface{}, fmt.Errorf("cannot parse interface header from external discoverer: %v", err) } ni := net.Interface{ @@ -166,7 +166,7 @@ func parseIfaceAddrs(raw string) []net.Addr { } ip, ipNet, err := net.ParseCIDR(addr) if err != nil { - log.Warnf("network_addresses_android: cannot parse addr %q: %v", addr, err) + log.Warn("network_addresses_android: skipping unparseable address from external discoverer") continue } ipNet.IP = ip From ca0b06de265f7ffdb931c731a50b2976b7f161d4 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:45:42 +0000 Subject: [PATCH 7/8] fix(client): pass ctx to networkAddresses on FreeBSD and fix codespell Two small follow-up fixes for CI: 1. FreeBSD build broke because info_freebsd.go still called the old networkAddresses() signature. Pass ctx like every other platform. 2. codespell flagged "unparseable" in the Android warning log. Switch to "unparsable" per codespell dictionary. --- client/system/info_freebsd.go | 2 +- client/system/network_addresses_android.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/system/info_freebsd.go b/client/system/info_freebsd.go index 75517284298..94e8321e9b2 100644 --- a/client/system/info_freebsd.go +++ b/client/system/info_freebsd.go @@ -43,7 +43,7 @@ func GetInfo(ctx context.Context) *Info { systemHostname, _ := os.Hostname() - addrs, err := networkAddresses() + addrs, err := networkAddresses(ctx) if err != nil { log.Warnf("failed to discover network addresses: %s", err) } diff --git a/client/system/network_addresses_android.go b/client/system/network_addresses_android.go index bf7fe59f825..621a0b9a386 100644 --- a/client/system/network_addresses_android.go +++ b/client/system/network_addresses_android.go @@ -166,7 +166,7 @@ func parseIfaceAddrs(raw string) []net.Addr { } ip, ipNet, err := net.ParseCIDR(addr) if err != nil { - log.Warn("network_addresses_android: skipping unparseable address from external discoverer") + log.Warn("network_addresses_android: skipping unparsable address from external discoverer") continue } ipNet.IP = ip From 60e1a9fae7803f95fd4f641b981e5d5582aae240 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:52:19 +0000 Subject: [PATCH 8/8] fix(client): pass ctx to networkAddresses on iOS after merging main Upstream #5900 added networkAddresses() to info_ios.go. Since this PR updates the networkAddresses() signature to require context (for the Android external iface discoverer), the iOS caller needs the same ctx argument. --- client/system/info_ios.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/system/info_ios.go b/client/system/info_ios.go index 81936cf1d68..826d8b6ab89 100644 --- a/client/system/info_ios.go +++ b/client/system/info_ios.go @@ -20,7 +20,7 @@ func GetInfo(ctx context.Context) *Info { sysName := extractOsName(ctx, "sysName") swVersion := extractOsVersion(ctx, "swVersion") - addrs, err := networkAddresses() + addrs, err := networkAddresses(ctx) if err != nil { log.Warnf("failed to discover network addresses: %s", err) }