From 81f45dab21d4bfb549d8780b5bba6ab4d195c9c4 Mon Sep 17 00:00:00 2001 From: tham-le <45093611+tham-le@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:19:34 +0200 Subject: [PATCH] [client] Support embed.Client on Android with netstack mode (#5623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Support embed.Client on Android with netstack mode embed.Client.Start() calls ConnectClient.Run() which passes an empty MobileDependency{}. On Android, the engine dereferences nil fields (IFaceDiscover, NetworkChangeListener, DnsReadyListener) causing panics. Provide complete no-op stubs so the engine's existing Android code paths work unchanged — zero modifications to engine.go: - Add androidRunOverride hook in Run() for Android-specific dispatch - Add runOnAndroidEmbed() with complete MobileDependency (all stubs) - Wire default stubs via init() in connect_android_default.go: noopIFaceDiscover, noopNetworkChangeListener, noopDnsReadyListener - Forward logPath to c.run() Tested: embed.Client starts on Android arm64, joins mesh via relay, discovers peers, localhost proxy works for TCP+UDP forwarding. * [client] Fix TestServiceParamsPath for Windows path separators Use filepath.Join in test assertions instead of hardcoded POSIX paths so the test passes on Windows where filepath.Join uses backslashes. --- client/internal/connect.go | 7 +++ client/internal/connect_android_default.go | 73 ++++++++++++++++++++++ client/internal/connect_android_embed.go | 32 ++++++++++ 3 files changed, 112 insertions(+) create mode 100644 client/internal/connect_android_default.go create mode 100644 client/internal/connect_android_embed.go diff --git a/client/internal/connect.go b/client/internal/connect.go index 242b25b44..1e8f87c08 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -44,6 +44,10 @@ import ( "github.com/netbirdio/netbird/version" ) +// androidRunOverride is set on Android to inject mobile dependencies +// when using embed.Client (which calls Run() with empty MobileDependency). +var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath string) error + type ConnectClient struct { ctx context.Context config *profilemanager.Config @@ -76,6 +80,9 @@ func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { // Run with main logic. func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error { + if androidRunOverride != nil { + return androidRunOverride(c, runningChan, logPath) + } return c.run(MobileDependency{}, runningChan, logPath) } diff --git a/client/internal/connect_android_default.go b/client/internal/connect_android_default.go new file mode 100644 index 000000000..190341c4a --- /dev/null +++ b/client/internal/connect_android_default.go @@ -0,0 +1,73 @@ +//go:build android + +package internal + +import ( + "net/netip" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" + "github.com/netbirdio/netbird/client/internal/stdnet" +) + +// noopIFaceDiscover is a stub ExternalIFaceDiscover for embed.Client on Android. +// It returns an empty interface list, which means ICE P2P candidates won't be +// discovered — connections will fall back to relay. Applications that need P2P +// should provide a real implementation via runOnAndroidEmbed that uses +// Android's ConnectivityManager to enumerate network interfaces. +type noopIFaceDiscover struct{} + +func (noopIFaceDiscover) IFaces() (string, error) { + // Return empty JSON array — no local interfaces advertised for ICE. + // This is intentional: without Android's ConnectivityManager, we cannot + // reliably enumerate interfaces (netlink is restricted on Android 11+). + // Relay connections still work; only P2P hole-punching is disabled. + return "[]", nil +} + +// noopNetworkChangeListener is a stub for embed.Client on Android. +// Network change events are ignored since the embed client manages its own +// reconnection logic via the engine's built-in retry mechanism. +type noopNetworkChangeListener struct{} + +func (noopNetworkChangeListener) OnNetworkChanged(string) { + // No-op: embed.Client relies on the engine's internal reconnection + // logic rather than OS-level network change notifications. +} + +func (noopNetworkChangeListener) SetInterfaceIP(string) { + // No-op: in netstack mode, the overlay IP is managed by the userspace + // network stack, not by OS-level interface configuration. +} + +// noopDnsReadyListener is a stub for embed.Client on Android. +// DNS readiness notifications are not needed in netstack/embed mode +// since system DNS is disabled and DNS resolution happens externally. +type noopDnsReadyListener struct{} + +func (noopDnsReadyListener) OnReady() { + // No-op: embed.Client does not need DNS readiness notifications. + // System DNS is disabled in netstack mode. +} + +var _ stdnet.ExternalIFaceDiscover = noopIFaceDiscover{} +var _ listener.NetworkChangeListener = noopNetworkChangeListener{} +var _ dns.ReadyListener = noopDnsReadyListener{} + +func init() { + // Wire up the default override so embed.Client.Start() works on Android + // with netstack mode. Provides complete no-op stubs for all mobile + // dependencies so the engine's existing Android code paths work unchanged. + // Applications that need P2P ICE or real DNS should replace this by + // setting androidRunOverride before calling Start(). + androidRunOverride = func(c *ConnectClient, runningChan chan struct{}, logPath string) error { + return c.runOnAndroidEmbed( + noopIFaceDiscover{}, + noopNetworkChangeListener{}, + []netip.AddrPort{}, + noopDnsReadyListener{}, + runningChan, + logPath, + ) + } +} diff --git a/client/internal/connect_android_embed.go b/client/internal/connect_android_embed.go new file mode 100644 index 000000000..18f72e841 --- /dev/null +++ b/client/internal/connect_android_embed.go @@ -0,0 +1,32 @@ +//go:build android + +package internal + +import ( + "net/netip" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" + "github.com/netbirdio/netbird/client/internal/stdnet" +) + +// runOnAndroidEmbed is like RunOnAndroid but accepts a runningChan +// so embed.Client.Start() can detect when the engine is ready. +// It provides complete MobileDependency so the engine's existing +// Android code paths work unchanged. +func (c *ConnectClient) runOnAndroidEmbed( + iFaceDiscover stdnet.ExternalIFaceDiscover, + networkChangeListener listener.NetworkChangeListener, + dnsAddresses []netip.AddrPort, + dnsReadyListener dns.ReadyListener, + runningChan chan struct{}, + logPath string, +) error { + mobileDependency := MobileDependency{ + IFaceDiscover: iFaceDiscover, + NetworkChangeListener: networkChangeListener, + HostDNSAddresses: dnsAddresses, + DnsReadyListener: dnsReadyListener, + } + return c.run(mobileDependency, runningChan, logPath) +}