feat(listener/tun): adopt a host-provided device fd on Android

An unprivileged Android app cannot open /dev/net/tun, so the device can only
come from a VpnService: the Java side creates and configures it (addresses,
routes, MTU, DNS via VpnService.Builder) and hands its fd over as the "fd"
metadata, either as a fixed descriptor or — for an embedder, which is what the
platform's own VPN is — as a provider resolved when each device is built, so a
device rebuilt after its session ended never copies a closed or recycled
descriptor.

The device is created with tun.CreateUnmonitoredTUNFromFD (no netlink listener,
no setMTU — the builder already set it), which makes the Linux variant, with
its netlink and resolvectl calls, inapplicable: it is now linux && !android.
The interface lookup in the accept loop was fatal on any error; it is
informational, and on Android the device may not be listed in this process.
This commit is contained in:
ginuerzh
2026-09-24 15:56:46 +08:00
parent 1776e1a4ef
commit ce290f9143
5 changed files with 100 additions and 7 deletions
+8 -7
View File
@@ -100,15 +100,16 @@ func (l *tunListener) listenLoop(ready context.CancelCauseFunc) {
return err
}
itf, err := net.InterfaceByName(name)
if err != nil {
return err
// Informational only: on Android the device belongs to the
// VpnService and the interface may not be listed in this process.
if itf, err := net.InterfaceByName(name); err != nil {
l.log.Warnf("interface %s: %v", name, err)
} else {
addrs, _ := itf.Addrs()
l.log.Infof("name: %s, net: %s, mtu: %d, addrs: %s",
itf.Name, ip, l.md.config.MTU, addrs)
}
addrs, _ := itf.Addrs()
l.log.Infof("name: %s, net: %s, mtu: %d, addrs: %s",
itf.Name, ip, l.md.config.MTU, addrs)
ctx = ictx.ContextWithMetadata(ctx, mdx.NewMetadata(map[string]any{
"config": l.md.config,
}))
+19
View File
@@ -20,6 +20,13 @@ const (
type metadata struct {
config *tun_util.Config
guid string
// fd yields the descriptor of a device created outside gost, currently
// only the Android VpnService; nil when gost creates its own device. It is
// a function rather than a number because a descriptor is resolved per
// device: by the time the tun listener rebuilds one (a session ended, the
// VPN was rebuilt), a number handed out at configure time may be closed —
// or worse, recycled by an unrelated file.
fd func() int
}
func (l *tunListener) parseMetadata(md mdata.Metadata) (err error) {
@@ -121,5 +128,17 @@ func (l *tunListener) parseMetadata(md mdata.Metadata) (err error) {
l.md.guid = mdutil.GetString(md, "guid", "tun.guid")
// A device created outside gost (the Android VpnService) arrives either as
// a fixed descriptor or — for an embedder, which is what the platform's own
// VPN is — as a provider that resolves one when each device is built.
if md != nil {
switch v := md.Get("fd").(type) {
case int:
l.md.fd = func() int { return v }
case func() int:
l.md.fd = v
}
}
return
}
+7
View File
@@ -78,6 +78,13 @@ func (l *tunListener) createTunDevice() (dev io.ReadWriteCloser, name string, er
return
}
return newTunDevice(ifce)
}
// newTunDevice adapts a tun device to the byte-stream conn this package hands
// out: the device reads and writes packets in batches, the listener speaks
// io.ReadWriteCloser.
func newTunDevice(ifce tun.Device) (dev io.ReadWriteCloser, name string, err error) {
batchSize := ifce.BatchSize()
rbufs := make([][]byte, batchSize)
+64
View File
@@ -0,0 +1,64 @@
//go:build android
package tun
import (
"errors"
"fmt"
"io"
"net"
"syscall"
"golang.zx2c4.com/wireguard/tun"
)
const (
readOffset = 0
writeOffset = 0
)
// createTun adopts the tun device of an Android VpnService. An unprivileged app
// cannot open /dev/net/tun, so the device is created on the Java side
// (VpnService.Builder, which also sets the addresses, routes, MTU and DNS) and
// its fd arrives as the "fd" metadata. That is why nothing here goes through
// netlink, the interface list or resolvectl.
func (l *tunListener) createTun() (dev io.ReadWriteCloser, name string, ip net.IP, err error) {
if l.md.fd == nil {
err = errors.New("tun: no host device fd")
return
}
// The fd belongs to the host and outlives this device, so each device reads
// from a copy of it: closing the device closes the copy, and a later
// recreate (an entrypoint restart, a reload) still has a live fd to copy.
// The provider waits for the VPN to come up, so a start that races it ends
// up with a device rather than an error.
src := l.md.fd()
if src < 0 {
err = errors.New("tun: no device fd: is the host's VPN up?")
return
}
fd, err := syscall.Dup(src)
if err != nil {
err = fmt.Errorf("tun: dup device fd %d: %v", src, err)
return
}
ifce, name, err := tun.CreateUnmonitoredTUNFromFD(fd)
if err != nil {
syscall.Close(fd)
return
}
dev, _, err = newTunDevice(ifce)
if err != nil {
ifce.Close()
return
}
if len(l.md.config.Net) > 0 {
ip = l.md.config.Net[0].IP
}
return
}
+2
View File
@@ -1,3 +1,5 @@
//go:build linux && !android
package tun
import (