//go:build linux package tailcore import ( "encoding/json" "net" "net/netip" "strings" "sync/atomic" "tailscale.com/net/netmon" ) // Android interface enumeration bridge. // // Android (SDK 30+) blocks Go's net.Interfaces()/net.Interface.Addrs() — they make raw netlink // RTM_GETLINK/GETADDR syscalls the app sandbox denies, surfacing as "route ip+net: netlinkrib: // permission denied" during wgengine bring-up. Tailscale's netmon anticipates this // (net/netmon/state.go: "Android SDK 30 no longer permits Go's net.Interfaces to work") and exposes // RegisterInterfaceGetter so the host app can supply interfaces via a permitted path. // // We use a push model: the Kotlin VpnService enumerates interfaces with java.net.NetworkInterface (which // Android DOES permit) and pushes a JSON snapshot here — once before Tailcore.start and again whenever // connectivity changes. Our registered getter just parses the latest snapshot. AltAddrs is populated so // netmon.Interface.Addrs() returns them directly and never falls back to the netlink path. var androidIfaceJSON atomic.Value // string; latest snapshot pushed from Kotlin type jsonIface struct { Name string `json:"name"` Index int `json:"index"` MTU int `json:"mtu"` Up bool `json:"up"` Loopback bool `json:"loopback"` P2P bool `json:"pointToPoint"` Multicast bool `json:"multicast"` MAC string `json:"mac"` Addrs []string `json:"addrs"` // "10.0.0.2/24", "fe80::1%wlan0/64" } func init() { netmon.RegisterInterfaceGetter(androidInterfaces) } // SetAndroidInterfaces is called from Kotlin with a JSON array snapshot of the device's network // interfaces. Must be called before Tailcore.start (netmon reads interfaces during bring-up) and on // each connectivity change to keep endpoint discovery current. func SetAndroidInterfaces(jsonStr string) { androidIfaceJSON.Store(jsonStr) } func androidInterfaces() ([]netmon.Interface, error) { s, _ := androidIfaceJSON.Load().(string) if s == "" { // No snapshot yet — return empty rather than falling back to the (denied) netlink path. return nil, nil } var raw []jsonIface if err := json.Unmarshal([]byte(s), &raw); err != nil { return nil, err } out := make([]netmon.Interface, 0, len(raw)) for _, r := range raw { var flags net.Flags if r.Up { flags |= net.FlagUp | net.FlagRunning } if r.Loopback { flags |= net.FlagLoopback } if r.P2P { flags |= net.FlagPointToPoint } if r.Multicast { flags |= net.FlagMulticast } var mac net.HardwareAddr if r.MAC != "" { mac, _ = net.ParseMAC(r.MAC) } ni := &net.Interface{ Index: r.Index, MTU: r.MTU, Name: r.Name, HardwareAddr: mac, Flags: flags, } var altAddrs []net.Addr for _, a := range r.Addrs { pfx, err := netip.ParsePrefix(stripIPZone(a)) if err != nil { continue } altAddrs = append(altAddrs, &net.IPNet{ IP: pfx.Addr().AsSlice(), Mask: net.CIDRMask(pfx.Bits(), pfx.Addr().BitLen()), }) } out = append(out, netmon.Interface{Interface: ni, AltAddrs: altAddrs}) } return out, nil } // stripIPZone removes an IPv6 scope zone from a CIDR string ("fe80::1%wlan0/64" → "fe80::1/64") so // netip.ParsePrefix accepts it. Java's InterfaceAddress hands us link-local v6 with a %zone suffix. func stripIPZone(s string) string { host, suffix := s, "" if slash := strings.LastIndexByte(s, '/'); slash >= 0 { host, suffix = s[:slash], s[slash:] } if pct := strings.IndexByte(host, '%'); pct >= 0 { host = host[:pct] } return host + suffix }