[GH-ISSUE #7023] Android: no way to supply a tun.Device, so NetBird cannot run alongside another VPN backend #12294

Open
opened 2026-08-05 01:32:48 -04:00 by saavagebueno · 1 comment
Owner

Originally created by @camiloariza on GitHub (Aug 1, 2026).
Original GitHub issue: https://github.com/netbirdio/netbird/issues/7023

Raising this before a PR, per CONTRIBUTING — it adds a public interface, so the shape is worth agreeing first.

The constraint

Android permits exactly one active VpnService per user profile: a second establish() revokes the first. An application that runs NetBird alongside another overlay backend therefore has to own that single tun itself and route each packet to whichever backend claims its destination.

Such a host has no second tun to hand over. And handing over the shared one is not an option either — NetBird would receive every other backend's packets, and the other backends none.

Why the current API cannot express that

On Android the only injection point is MobileIFaceArguments.TunAdapter:

// client/iface/device/adapter.go
type TunAdapter interface {
    ConfigureInterface(address, addressV6 string, mtu int, dns, searchDomains, routes string) (int, error)
    UpdateAddr(address string) error
    ProtectSocket(fd int32) bool
}

That int is a file descriptor, and client/iface/device/device_android.go feeds it straight to tun.CreateUnmonitoredTUNFromFD, which ioctls TUNGETIFF for the interface name. Only a real tun answers that — a socketpair returns EPERM — so a host that owns the tun cannot substitute anything.

There is no other seam: newDeviceFilter is unexported, MobileIFaceArguments carries only TunAdapter (Android) and TunFd (iOS), and the WGTunDevice interface in client/iface/device.go carries a comment saying it is deliberately not used on Android.

What would fix it

An optional interface a TunAdapter may also implement, supplying the device instead of a descriptor:

type TunDeviceProvider interface {
    TunDevice(address, addressV6 string, mtu int, dns, searchDomains, routes string) (tun.Device, string, error)
}

Create would type-assert for it: when present, ask for the device; when absent, the existing descriptor path is untouched. gomobile cannot express a Go interface return, so gomobile bindings are unaffected either way.

I have this working — NetBird carrying traffic on Android beside three other overlay backends on one tun, in a separate process, verified on a Galaxy S23 — as a ~50-line patch against v0.75.0 currently applied locally at build time.

Question

Is an optional tun.Device hand-off something you would take, and is that the shape you would want? Happy to adjust — a distinct interface rather than an optional assertion, a field on MobileIFaceArguments, or something else entirely. I will open the PR once the direction is agreed.

Originally created by @camiloariza on GitHub (Aug 1, 2026). Original GitHub issue: https://github.com/netbirdio/netbird/issues/7023 Raising this before a PR, per CONTRIBUTING — it adds a public interface, so the shape is worth agreeing first. ## The constraint Android permits exactly one active `VpnService` per user profile: a second `establish()` revokes the first. An application that runs NetBird *alongside* another overlay backend therefore has to own that single tun itself and route each packet to whichever backend claims its destination. Such a host has no second tun to hand over. And handing over the shared one is not an option either — NetBird would receive every other backend's packets, and the other backends none. ## Why the current API cannot express that On Android the only injection point is `MobileIFaceArguments.TunAdapter`: ```go // client/iface/device/adapter.go type TunAdapter interface { ConfigureInterface(address, addressV6 string, mtu int, dns, searchDomains, routes string) (int, error) UpdateAddr(address string) error ProtectSocket(fd int32) bool } ``` That `int` is a file descriptor, and `client/iface/device/device_android.go` feeds it straight to `tun.CreateUnmonitoredTUNFromFD`, which ioctls `TUNGETIFF` for the interface name. Only a real tun answers that — a socketpair returns `EPERM` — so a host that owns the tun cannot substitute anything. There is no other seam: `newDeviceFilter` is unexported, `MobileIFaceArguments` carries only `TunAdapter` (Android) and `TunFd` (iOS), and the `WGTunDevice` interface in `client/iface/device.go` carries a comment saying it is deliberately not used on Android. ## What would fix it An optional interface a `TunAdapter` may also implement, supplying the device instead of a descriptor: ```go type TunDeviceProvider interface { TunDevice(address, addressV6 string, mtu int, dns, searchDomains, routes string) (tun.Device, string, error) } ``` `Create` would type-assert for it: when present, ask for the device; when absent, the existing descriptor path is untouched. gomobile cannot express a Go interface return, so gomobile bindings are unaffected either way. I have this working — NetBird carrying traffic on Android beside three other overlay backends on one tun, in a separate process, verified on a Galaxy S23 — as a ~50-line patch against v0.75.0 currently applied locally at build time. ## Question Is an optional `tun.Device` hand-off something you would take, and is that the shape you would want? Happy to adjust — a distinct interface rather than an optional assertion, a field on `MobileIFaceArguments`, or something else entirely. I will open the PR once the direction is agreed.
Author
Owner

@linear-code[bot] commented on GitHub (Aug 1, 2026):

NET-1464

<!-- gh-comment-id:5152038648 --> @linear-code[bot] commented on GitHub (Aug 1, 2026): <!-- linear-linkback --> <p><a href="https://linear.app/netbird/issue/NET-1464">NET-1464</a></p>
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DYNR/netbird#12294