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 37e17a36319..df8704c5713 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -138,6 +138,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() @@ -179,6 +189,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() @@ -216,6 +234,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 to 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() +} + // DebugBundle generates a debug bundle, uploads it, and returns the upload key. // It works both with and without a running engine. func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) { diff --git a/client/internal/engine.go b/client/internal/engine.go index 8d7e02bd552..77e15863451 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -75,9 +75,11 @@ 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" + networkAddressWatchInterval = 10 * time.Second + networkAddressResyncDebounce = 30 * time.Second ) var ErrResetConnection = fmt.Errorf("reset connection") @@ -213,6 +215,13 @@ 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. + // Protected by networkAddrMu; always acquire networkAddrMu before + // reading or writing these fields. + networkAddrMu sync.Mutex + lastNetworkAddresses []system.NetworkAddress + lastNetworkAddressSync time.Time + relayManager *relayClient.Manager stateManager *statemanager.Manager portForwardManager *portforward.Manager @@ -577,6 +586,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() @@ -903,6 +916,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(false) + nm := update.GetNetworkMap() if nm == nil { return nil @@ -992,10 +1009,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, @@ -1022,6 +1039,149 @@ 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. +// 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(true) +} + +// 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(networkAddressWatchInterval) + defer ticker.Stop() + + for { + select { + case <-e.ctx.Done(): + return + case <-ticker.C: + e.syncMsgMux.Lock() + e.resyncMetaIfNetworkChanged(false) + e.syncMsgMux.Unlock() + } + } +} + +// 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. +// Must be called while holding syncMsgMux. +// +// 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. + // Explicit platform callbacks (force=true) bypass this. + if !force && time.Since(e.lastNetworkAddressSync) < networkAddressResyncDebounce { + return + } + + // 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 network addresses during resync check: %v", err) + return + } + + 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)) + + // 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, + &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, + ) + + // 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 + } + // 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() +} + +func networkAddressesEqual(a, b []system.NetworkAddress) bool { + if len(a) != len(b) { + return false + } + // 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{}{} + } + 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") @@ -1133,10 +1293,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, @@ -1732,7 +1892,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/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 175d1f07fba..d131ce04b05 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -23,6 +23,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 diff --git a/client/system/info_android.go b/client/system/info_android.go index 794ff15edd4..d6a687ab5d7 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(ctx) + 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_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_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/info_ios.go b/client/system/info_ios.go index ad42b1edf3d..5079735fd0b 100644 --- a/client/system/info_ios.go +++ b/client/system/info_ios.go @@ -44,6 +44,13 @@ func GetInfo(ctx context.Context) *Info { return gio } +// NetworkAddresses returns the current set of non-loopback network addresses. +// On iOS the system does not expose an external interface discoverer, so the +// context is unused. +func NetworkAddresses(_ context.Context) ([]NetworkAddress, error) { + return networkAddresses() +} + // networkAddresses returns the list of network addresses on iOS. // On iOS, hardware (MAC) addresses are not available due to Apple's privacy // restrictions (iOS returns a fixed 02:00:00:00:00:00 placeholder), so we 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 27821f3c53c..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) } @@ -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(context.Background()) + 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(context.Background()) + 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/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_addr.go b/client/system/network_addr.go index 5423cf8ad90..cc0bf75913c 100644 --- a/client/system/network_addr.go +++ b/client/system/network_addr.go @@ -3,25 +3,42 @@ package system import ( + "context" "net" "net/netip" ) -func networkAddresses() ([]NetworkAddress, error) { - interfaces, err := net.Interfaces() +// 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 { return nil, err } + // 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 + var netAddresses []NetworkAddress for _, iface := range interfaces { if iface.Flags&net.FlagUp == 0 { continue } - if iface.HardwareAddr.String() == "" { + if !skipNoMacFilter && iface.HardwareAddr.String() == "" { continue } - addrs, err := iface.Addrs() + addrs, err := getInterfaceAddrs(ctx, &iface) if err != nil { continue } diff --git a/client/system/network_addresses.go b/client/system/network_addresses.go new file mode 100644 index 00000000000..716a70c46eb --- /dev/null +++ b/client/system/network_addresses.go @@ -0,0 +1,26 @@ +//go:build !android + +package system + +import ( + "context" + "net" +) + +// 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() +} + +// 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 new file mode 100644 index 00000000000..621a0b9a386 --- /dev/null +++ b/client/system/network_addresses_android.go @@ -0,0 +1,176 @@ +//go:build android + +package system + +import ( + "context" + "fmt" + "net" + "strings" + + log "github.com/sirupsen/logrus" +) + +// 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 +} + +// 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.Warn("network_addresses_android: cannot split interface line from external discoverer (expected '|' separator)") + continue + } + + ni, err := parseIfaceHeader(fields[0]) + if err != nil { + log.Warn("network_addresses_android: failed to parse interface header from external discoverer") + continue + } + ifaces = append(ifaces, ni) + addrMap[ni.Name] = parseIfaceAddrs(fields[1]) + } + return ifaces, addrMap +} + +// 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 interface header from external discoverer: %v", 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 + } + } + + return ni, nil +} + +// 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.Warn("network_addresses_android: skipping unparsable address from external discoverer") + continue + } + ipNet.IP = ip + addrs = append(addrs, ipNet) + } + return addrs +}