Skip to content

[client] Support embed.Client on Android with netstack mode#5623

Merged
pappz merged 2 commits intonetbirdio:mainfrom
tham-le:tham/embed-android-support
Apr 1, 2026
Merged

[client] Support embed.Client on Android with netstack mode#5623
pappz merged 2 commits intonetbirdio:mainfrom
tham-le:tham/embed-android-support

Conversation

@tham-le
Copy link
Copy Markdown
Contributor

@tham-le tham-le commented Mar 18, 2026

Describe your changes

embed.Client.Start() crashes on Android with nil pointer dereferences because ConnectClient.Run() passes an empty MobileDependency{}. The engine then dereferences nil IFaceDiscover, NetworkChangeListener, DnsReadyListener, and TunAdapter.

This PR makes embed.Client usable on Android with netstack/userspace mode (NB_USE_NETSTACK_MODE=true), without requiring VpnService, TunAdapter, or root access.

Approach

Provide complete no-op stubs for all mobile dependencies so the engine's existing Android code paths work unchanged — zero modifications to engine.go.

On Android builds, an init() in connect_android_default.go sets androidRunOverride which routes Run() through runOnAndroidEmbed() with complete stubs:

  • noopIFaceDiscover — returns empty interface list (relay-only, no P2P ICE)
  • noopNetworkChangeListener — ignores network change events
  • noopDnsReadyListener — ignores DNS readiness notifications

The engine sees a fully populated MobileDependency and behaves exactly like the real Android app.

Changes (3 files, no engine.go modifications)

  • connect.go: Add androidRunOverride hook in Run() — checked before the default c.run(MobileDependency{}) path
  • connect_android_default.go (new): Default init() wires complete no-op stubs via androidRunOverride. Includes noopIFaceDiscover, noopNetworkChangeListener, noopDnsReadyListener.
  • connect_android_embed.go (new): Unexported runOnAndroidEmbed() that accepts all mobile deps + runningChan + logPath, builds MobileDependency, calls c.run()

Testing

Tested on Android arm64 (Pixel 7 Pro, Android 15):

  • embed.New() + Start() succeeds
  • NetBird engine starts with netstack TUN interface
  • Relay connection established (WebSocket)
  • Peers discovered, WireGuard handshake completes
  • Localhost TCP+UDP proxy forwards through mesh

Non-Breaking

  • Default (non-Android) code path unchanged — androidRunOverride is nil, Run() behaves exactly as before
  • Engine code untouched — stubs provide complete MobileDependency
  • No new dependencies

Issue ticket number and link

N/A — new feature, no existing issue.

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • Documentation is not needed for this change (explain why)

Internal engine change with no user-facing API modification. The embed package API is unchanged — existing callers work as before. Android support is automatic via build tags.

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

N/A

Summary by CodeRabbit

  • New Features

    • Improved Android support with a configurable embedded startup path for the client.
    • Added default/no-op Android implementations for network, DNS, and interface handling so embedded mode runs in netstack environments.
  • Tests

    • Updated service params path test to be OS/path-separator aware by using path-joining.

Copilot AI review requested due to automatic review settings March 18, 2026 17:16
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8a34398f-1bea-4c16-ad34-6223b589f5b2

📥 Commits

Reviewing files that changed from the base of the PR and between 26f2a7c and 3a6d094.

📒 Files selected for processing (4)
  • client/cmd/service_params_test.go
  • client/internal/connect.go
  • client/internal/connect_android_default.go
  • client/internal/connect_android_embed.go
✅ Files skipped from review due to trivial changes (1)
  • client/cmd/service_params_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • client/internal/connect_android_embed.go
  • client/internal/connect_android_default.go

📝 Walkthrough

Walkthrough

Adds an Android-specific startup hook and Android-only runner plus no-op mobile dependency stubs so ConnectClient.Run can delegate to an embed-focused flow on Android without full platform integrations.

Changes

Cohort / File(s) Summary
Hook mechanism
client/internal/connect.go
Adds package-level androidRunOverride and makes (*ConnectClient).Run delegate to it when non-nil.
Android embed runner
client/internal/connect_android_embed.go
New Android-only runOnAndroidEmbed method that maps provided iface discovery, network listener, DNS addresses/listener into a MobileDependency and calls c.run(...).
Android default stubs & init
client/internal/connect_android_default.go
Android-only file adding no-op implementations for ExternalIFaceDiscover, NetworkChangeListener, and dns.ReadyListener; init() sets androidRunOverride to call runOnAndroidEmbed with these stubs and an empty DNS list.
Tests
client/cmd/service_params_test.go
Updates test expectations to use filepath.Join(configs.StateDir, "service.json") for OS-portable path assertions.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant App as Android App
    participant CC as ConnectClient
    participant IF as ExternalIFaceDiscover
    participant NL as NetworkChangeListener
    participant DNS as dns.ReadyListener
    participant Engine as ConnectClient.run

    App->>CC: Start / ConnectClient.Run(runningChan, logPath)
    alt androidRunOverride != nil
        CC->>CC: androidRunOverride(c, runningChan, logPath)
        androidRunOverride->>CC: runOnAndroidEmbed(...)
        runOnAndroidEmbed->>IF: use provided IFaceDiscover
        runOnAndroidEmbed->>NL: use provided NetworkChangeListener
        runOnAndroidEmbed->>DNS: use provided DnsReadyListener
        runOnAndroidEmbed->>Engine: c.run(MobileDependency{IF,NL,DNS}, runningChan, logPath)
        Engine-->>CC: returns error/ok
        CC-->>App: return result
    else
        CC->>Engine: c.run(MobileDependency{}, runningChan, logPath)
        Engine-->>CC: returns error/ok
        CC-->>App: return result
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • doromaraujo

Poem

🐰 I hooked a runner in Android's spring,
Stubbed the nets so the client can sing,
Quiet interfaces, DNS at bay,
A hopping rabbit helped start the play.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title '[client] Support embed.Client on Android with netstack mode' clearly and specifically summarizes the main change—enabling embed.Client to work on Android with netstack mode, which is the primary objective of the PR.
Description check ✅ Passed The description is thorough and complete, covering problem statement, approach, detailed changes, testing results, and non-breaking compatibility. All required template sections are addressed, including a filled-out checklist and a clear explanation of why documentation is not needed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to make client/embed usable on Android when running in netstack/userspace mode by avoiding nil-pointer dereferences stemming from empty MobileDependency{} passed through ConnectClient.Run().

Changes:

  • Added an Android-only RunOnAndroidEmbed helper to pass runningChan plus selected mobile dependencies into the internal connect flow.
  • Added an androidRunOverride hook in ConnectClient.Run() to allow Android-specific dispatch.
  • Hardened Android engine code paths with nil checks for NetworkChangeListener, TunAdapter, and DnsReadyListener, and adjusted DNS-server selection behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
client/internal/engine.go Adds nil checks for Android/mobile dependencies and adjusts Android DNS/WG interface setup behavior to avoid panics in embed/netstack scenarios.
client/internal/connect_android_embed.go Introduces Android-only RunOnAndroidEmbed helper that forwards a runningChan to the shared connect flow.
client/internal/connect.go Adds androidRunOverride hook to intercept Run() on Android and inject mobile deps for embed use cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread client/internal/connect.go
Comment thread client/internal/engine.go Outdated
Comment thread client/internal/engine.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
client/internal/engine.go (1)

1775-1792: ⚠️ Potential issue | 🔴 Critical

Android DNS fallback currently jumps into the iOS branch.

At Line 1788, fallthrough sends Android execution to dns.NewDefaultServerIos(...). That diverges from the intended non-mobile/default fallback and can break Android embed netstack DNS behavior when DnsReadyListener is nil.

🐛 Proposed fix
 	switch runtime.GOOS {
 	case "android":
 		if e.mobileDep.NetworkChangeListener != nil && e.mobileDep.DnsReadyListener != nil {
 			dnsServer := dns.NewDefaultServerPermanentUpstream(
 				e.ctx,
 				e.wgInterface,
 				e.mobileDep.HostDNSAddresses,
 				*dnsConfig,
 				e.mobileDep.NetworkChangeListener,
 				e.statusRecorder,
 				e.config.DisableDNS,
 			)
 			go e.mobileDep.DnsReadyListener.OnReady()
 			return dnsServer, nil
 		}
-		fallthrough
+		dnsServer, err := dns.NewDefaultServer(e.ctx, dns.DefaultServerConfig{
+			WgInterface:    e.wgInterface,
+			CustomAddress:  e.config.CustomDNSAddress,
+			StatusRecorder: e.statusRecorder,
+			StateManager:   e.stateManager,
+			DisableSys:     e.config.DisableDNS,
+		})
+		if err != nil {
+			return nil, err
+		}
+		return dnsServer, nil
 
 	case "ios":
 		dnsServer := dns.NewDefaultServerIos(e.ctx, e.wgInterface, e.mobileDep.DnsManager, e.statusRecorder, e.config.DisableDNS)
 		return dnsServer, nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/internal/engine.go` around lines 1775 - 1792, The Android branch
incorrectly uses a bare fallthrough which transfers control into the "ios" case
and calls dns.NewDefaultServerIos; remove the fallthrough and instead call the
intended non-mobile/default DNS fallback (e.g., construct/return the same
default server used elsewhere such as dns.NewDefaultServerPermanentUpstream or
the package's generic/default server) when e.mobileDep.DnsReadyListener is nil,
guarding on e.mobileDep.NetworkChangeListener and e.mobileDep.DnsReadyListener;
update the Android branch around the check that references e.mobileDep,
DnsReadyListener, NewDefaultServerPermanentUpstream and NewDefaultServerIos so
Android never falls into the iOS case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/internal/connect.go`:
- Around line 46-49: The declared hook variable androidRunOverride is never
assigned but is checked in ConnectClient.Run(), so either wire it in an
Android-specific init or remove the unused hook; to fix either add an
Android-only file (build tag // +build android) that defines init() which
assigns androidRunOverride to the Android-specific implementation function
(matching signature func(c *ConnectClient, runningChan chan struct{}, logPath
string) error) so the Run() path can execute, or remove the androidRunOverride
declaration and its conditional check in ConnectClient.Run() to eliminate the
dead code.

---

Outside diff comments:
In `@client/internal/engine.go`:
- Around line 1775-1792: The Android branch incorrectly uses a bare fallthrough
which transfers control into the "ios" case and calls dns.NewDefaultServerIos;
remove the fallthrough and instead call the intended non-mobile/default DNS
fallback (e.g., construct/return the same default server used elsewhere such as
dns.NewDefaultServerPermanentUpstream or the package's generic/default server)
when e.mobileDep.DnsReadyListener is nil, guarding on
e.mobileDep.NetworkChangeListener and e.mobileDep.DnsReadyListener; update the
Android branch around the check that references e.mobileDep, DnsReadyListener,
NewDefaultServerPermanentUpstream and NewDefaultServerIos so Android never falls
into the iOS case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 04f8067a-8e3a-4d1f-86be-4bd8f658c470

📥 Commits

Reviewing files that changed from the base of the PR and between a1858a9 and 28f6e55.

📒 Files selected for processing (3)
  • client/internal/connect.go
  • client/internal/connect_android_embed.go
  • client/internal/engine.go

Comment thread client/internal/connect.go
@tham-le tham-le force-pushed the tham/embed-android-support branch 2 times, most recently from 06402ca to e5f0f8e Compare March 18, 2026 17:27
@pappz pappz self-requested a review March 21, 2026 11:09
Comment thread client/internal/engine.go Outdated
Comment thread client/internal/connect_android_embed.go Outdated
Comment thread client/internal/connect_android_default.go Outdated
@pappz
Copy link
Copy Markdown
Collaborator

pappz commented Mar 30, 2026

@tham-le Instead of adding nil-check if/else branches in engine.go, provide complete no-op stubs in connect_android_default.go including a noopDnsReadyListener.

This way, the engine’s existing Android code path (NewDefaultServerPermanentUpstream + DnsReadyListener.OnReady()) works unchanged with embed mode, and no modifications to engine.go
are needed.

Also, logPath is received by androidRunOverride but is not forwarded to RunOnAndroidEmbed — it gets silently dropped.

@tham-le tham-le force-pushed the tham/embed-android-support branch from e5f0f8e to 724d454 Compare March 30, 2026 17:01
tham-le added 2 commits March 31, 2026 10:53
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.
Use filepath.Join in test assertions instead of hardcoded POSIX paths
so the test passes on Windows where filepath.Join uses backslashes.
@tham-le tham-le force-pushed the tham/embed-android-support branch from 26f2a7c to 3a6d094 Compare March 31, 2026 08:54
@sonarqubecloud
Copy link
Copy Markdown

@pappz pappz merged commit 81f45da into netbirdio:main Apr 1, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants