android: fix local endpoint parsing and add regression ifaceparse_test (#728)

Android reports interface addresses in CIDR form. Recent changes
(see 9c933a08a2 (diff-1d627686c31972e04ef60d7d301e8a2a93714c60096a50055a6bbe9aa041ca8fL105),  9c933a08a2 (diff-1d627686c31972e04ef60d7d301e8a2a93714c60096a50055a6bbe9aa041ca8fL105)) parsed addresses as CIDR prefixes, resulting in prefix base being advertised as a local endpoint.
This change:
-instead of parsing CIDR prefixes, the parser strips the /NN portion and uses netip.ParseAddr to parse the host IP only
-extracts the interface parsing into a helper in a new package to make it testable on non-Android platforms
-adds a unit test to verify that host addresses are present and prefix-base ones are not

Fixes tailscale/tailscale#16836

Signed-off-by: kari-ts <kari@tailscale.com>
pull/724/merge
kari-ts 5 days ago committed by GitHub
parent 9c5f890358
commit 94f2829e7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -41,9 +41,11 @@ import com.tailscale.ipn.util.NoSuchKeyException
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import java.io.IOException
import java.lang.UnsupportedOperationException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -54,8 +56,9 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.Serializable
import libtailscale.Libtailscale
import java.lang.UnsupportedOperationException
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -333,6 +336,60 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return sb.toString()
}
@Serializable
data class AddrJson(
val ip: String,
val prefixLen: Int,
)
@Serializable
data class InterfaceJson(
val name: String,
val index: Int,
val mtu: Int,
val up: Boolean,
val broadcast: Boolean,
val loopback: Boolean,
val pointToPoint: Boolean,
val multicast: Boolean,
val addrs: List<AddrJson>,
)
override fun getInterfacesAsJson(): String {
val interfaces = Collections.list(NetworkInterface.getNetworkInterfaces())
val out = ArrayList<InterfaceJson>(interfaces.size)
for (nif in interfaces) {
try {
val addrs = ArrayList<AddrJson>()
for (ia in nif.interfaceAddresses) {
val addr = ia.address ?: continue
// hostAddress is stable and avoids InterfaceAddress.toString() formatting risks.
val host = addr.hostAddress ?: continue
addrs.add(AddrJson(ip = host, prefixLen = ia.networkPrefixLength.toInt()))
}
out.add(
InterfaceJson(
name = nif.name,
index = nif.index,
mtu = nif.mtu,
up = nif.isUp,
broadcast = nif.supportsMulticast(),
loopback = nif.isLoopback,
pointToPoint = nif.isPointToPoint,
multicast = nif.supportsMulticast(),
addrs = addrs,
)
)
} catch (_: Exception) {
continue
}
}
// Avoid pretty printing to keep payload small.
return Json { encodeDefaults = true }.encodeToString(out)
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {

@ -0,0 +1,141 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ifaceparse
import (
"encoding/json"
"errors"
"net"
"net/netip"
"slices"
"strings"
"tailscale.com/net/netmon"
)
type ParseStats struct {
IfacesTotal int
IfacesParsed int
IfacesSkipped int
AddrsTotal int
AddrsParsed int
AddrsSkipped int
}
type addrJSON struct {
IP string `json:"ip"`
PrefixLen int `json:"prefixLen"`
}
type ifaceJSON struct {
Name string `json:"name"`
Index int `json:"index"`
MTU int `json:"mtu"`
Up bool `json:"up"`
Broadcast bool `json:"broadcast"`
Loopback bool `json:"loopback"`
PointToPt bool `json:"pointToPoint"`
Multicast bool `json:"multicast"`
Addrs []addrJSON `json:"addrs"`
}
var ErrNotJSON = errors.New("not a JSON interfaces payload")
// ParseInterfacesJSONAsNetmon parses a JSON payload produced by getInterfacesAsJson()
// and returns netmon.Interfaces plus parsing stats.
func ParseInterfacesJSONAsNetmon(b []byte) ([]netmon.Interface, ParseStats, error) {
var st ParseStats
trim := strings.TrimSpace(string(b))
if trim == "" {
return nil, st, nil
}
if !(strings.HasPrefix(trim, "[") || strings.HasPrefix(trim, "{")) {
return nil, st, ErrNotJSON
}
var in []ifaceJSON
if err := json.Unmarshal([]byte(trim), &in); err != nil {
return nil, st, err
}
out := make([]netmon.Interface, 0, len(in))
for _, it := range in {
st.IfacesTotal++
if it.Name == "" {
st.IfacesSkipped++
continue
}
nif := netmon.Interface{
Interface: &net.Interface{
Name: it.Name,
Index: it.Index,
MTU: it.MTU,
},
AltAddrs: []net.Addr{},
}
if it.Up {
nif.Flags |= net.FlagUp
}
if it.Broadcast {
nif.Flags |= net.FlagBroadcast
}
if it.Loopback {
nif.Flags |= net.FlagLoopback
}
if it.PointToPt {
nif.Flags |= net.FlagPointToPoint
}
if it.Multicast {
nif.Flags |= net.FlagMulticast
}
st.AddrsTotal += len(it.Addrs)
for _, a := range it.Addrs {
na, err := a.NetAddr()
if err != nil {
st.AddrsSkipped++
continue
}
nif.AltAddrs = append(nif.AltAddrs, na)
st.AddrsParsed++
}
out = append(out, nif)
st.IfacesParsed++
}
return out, st, nil
}
func (a addrJSON) NetAddr() (net.Addr, error) {
na, err := netip.ParseAddr(a.IP)
if err != nil {
return nil, err
}
zone := na.Zone()
ip := net.IP(slices.Clone(na.AsSlice()))
// Zoned addresses can't be represented as *net.IPNet.
if zone != "" {
return &net.IPAddr{IP: ip, Zone: zone}, nil
}
bits := 128
if na.Is4() {
bits = 32
}
if a.PrefixLen < 0 || a.PrefixLen > bits {
// Keep the IP but drop the prefix if it's invalid.
return &net.IPAddr{IP: ip}, nil
}
// Host IP + prefixLen mask (not prefix base).
return &net.IPNet{
IP: ip,
Mask: net.CIDRMask(a.PrefixLen, bits),
}, nil
}

@ -0,0 +1,223 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ifaceparse
import (
"net"
"sort"
"testing"
"tailscale.com/net/netmon"
)
func TestParseInterfacesJSONAsNetmon(t *testing.T) {
tests := []struct {
name string
json string
wantIfaceName string
wantPresent []string
wantAbsent []string
wantIfacesTotal *int
wantIfacesParsed *int
wantIfacesSkipped *int
wantAddrsTotal *int
minAddrsParsed *int
}{
{
name: "basic_v4_v6_and_zoned_linklocal",
json: `[
{
"name":"wlan0",
"index":30,
"mtu":1500,
"up":true,
"broadcast":true,
"loopback":false,
"pointToPoint":false,
"multicast":true,
"addrs":[
{"ip":"fe80::2f60:2c82:4163:8389%wlan0","prefixLen":64},
{"ip":"10.1.10.131","prefixLen":24},
{"ip":"2601:647:6801:2640:842b:a104:7efe:3f74","prefixLen":64}
]
}
]`,
wantIfaceName: "wlan0",
wantPresent: []string{
"10.1.10.131",
"10.1.10.131/24",
"2601:647:6801:2640:842b:a104:7efe:3f74",
"2601:647:6801:2640:842b:a104:7efe:3f74/64",
"fe80::2f60:2c82:4163:8389%wlan0",
},
wantAbsent: []string{
"10.1.10.0",
"2601:647:6801:2640::",
"2601:647:6801:2640::/64",
},
wantIfacesTotal: intp(1),
wantIfacesParsed: intp(1),
wantIfacesSkipped: intp(0),
wantAddrsTotal: intp(3),
minAddrsParsed: intp(2),
},
{
name: "empty_addrs_ok",
json: `[
{
"name":"wlan0",
"index":30,
"mtu":1500,
"up":true,
"broadcast":true,
"loopback":false,
"pointToPoint":false,
"multicast":true,
"addrs":[]
}
]`,
wantIfaceName: "wlan0",
wantPresent: nil,
wantAbsent: []string{"10.1.10.0"},
wantIfacesTotal: intp(1),
wantIfacesParsed: intp(1),
wantAddrsTotal: intp(0),
minAddrsParsed: intp(0),
wantIfacesSkipped: intp(0),
},
{
name: "skips_bad_ip_but_keeps_good",
json: `[
{
"name":"wlan0",
"index":30,
"mtu":1500,
"up":true,
"broadcast":true,
"loopback":false,
"pointToPoint":false,
"multicast":true,
"addrs":[
{"ip":"not-an-ip","prefixLen":24},
{"ip":"10.1.10.131","prefixLen":24}
]
}
]`,
wantIfaceName: "wlan0",
wantPresent: []string{"10.1.10.131/24"},
wantAbsent: []string{"10.1.10.0"},
wantIfacesTotal: intp(1),
wantIfacesParsed: intp(1),
wantAddrsTotal: intp(2),
minAddrsParsed: intp(1),
},
{
name: "skips_iface_with_empty_name",
json: `[
{"name":"","index":1,"mtu":1500,"up":true,"broadcast":false,"loopback":false,"pointToPoint":false,"multicast":false,"addrs":[{"ip":"10.0.0.1","prefixLen":24}]},
{"name":"wlan0","index":30,"mtu":1500,"up":true,"broadcast":true,"loopback":false,"pointToPoint":false,"multicast":true,"addrs":[{"ip":"10.1.10.131","prefixLen":24}]}
]`,
wantIfaceName: "wlan0",
wantPresent: []string{"10.1.10.131/24"},
wantIfacesTotal: intp(2),
wantIfacesParsed: intp(1),
wantIfacesSkipped: intp(1),
wantAddrsTotal: intp(1), // only counts addrs for parsed iface
minAddrsParsed: intp(1),
},
{
name: "non_json_rejected",
json: "not json",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ifaces, st, err := ParseInterfacesJSONAsNetmon([]byte(tc.json))
if tc.name == "non_json_rejected" {
if err == nil {
t.Fatalf("expected error, got nil (stats=%+v ifaces=%v)", st, ifaces)
}
return
}
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(ifaces) != 1 {
t.Fatalf("expected 1 parsed interface, got %d (stats=%+v)", len(ifaces), st)
}
if ifaces[0].Name != tc.wantIfaceName {
t.Fatalf("expected interface %q, got %q", tc.wantIfaceName, ifaces[0].Name)
}
if tc.wantIfacesTotal != nil && st.IfacesTotal != *tc.wantIfacesTotal {
t.Fatalf("IfacesTotal=%d, want %d (stats=%+v)", st.IfacesTotal, *tc.wantIfacesTotal, st)
}
if tc.wantIfacesParsed != nil && st.IfacesParsed != *tc.wantIfacesParsed {
t.Fatalf("IfacesParsed=%d, want %d (stats=%+v)", st.IfacesParsed, *tc.wantIfacesParsed, st)
}
if tc.wantIfacesSkipped != nil && st.IfacesSkipped != *tc.wantIfacesSkipped {
t.Fatalf("IfacesSkipped=%d, want %d (stats=%+v)", st.IfacesSkipped, *tc.wantIfacesSkipped, st)
}
if tc.wantAddrsTotal != nil && st.AddrsTotal != *tc.wantAddrsTotal {
t.Fatalf("AddrsTotal=%d, want %d (stats=%+v)", st.AddrsTotal, *tc.wantAddrsTotal, st)
}
if tc.minAddrsParsed != nil && st.AddrsParsed < *tc.minAddrsParsed {
t.Fatalf("AddrsParsed=%d, want >= %d (stats=%+v)", st.AddrsParsed, *tc.minAddrsParsed, st)
}
got := collectAltAddrStrings(t, ifaces[0])
for _, want := range tc.wantPresent {
if !got[want] {
t.Fatalf("missing %q; got=%v", want, keys(got))
}
}
for _, bad := range tc.wantAbsent {
if got[bad] {
t.Fatalf("unexpected %q; got=%v", bad, keys(got))
}
}
})
}
}
// collectAltAddrStrings formats AltAddrs into comparable strings.
// Supports both *net.IPNet and *net.IPAddr.
func collectAltAddrStrings(t *testing.T, ifc netmon.Interface) map[string]bool {
t.Helper()
out := map[string]bool{}
for _, a := range ifc.AltAddrs {
switch v := a.(type) {
case *net.IPNet:
out[v.String()] = true // includes /prefix
out[v.IP.String()] = true
case *net.IPAddr:
out[v.IP.String()] = true
if v.Zone != "" {
out[v.IP.String()+"%"+v.Zone] = true
}
default:
t.Fatalf("unexpected AltAddrs type: %T", a)
}
}
return out
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func intp(v int) *int { return &v }

@ -52,6 +52,10 @@ type AppContext interface {
// interfaces.
GetInterfacesAsString() (string, error)
// GetInterfacesAsJson gets a JSON representation of all network
// interfaces.
GetInterfacesAsJson() (string, error)
// GetPlatformDNSConfig gets a string representation of the current DNS
// configuration.
GetPlatformDNSConfig() string

@ -7,12 +7,12 @@ import (
"errors"
"fmt"
"log"
"net"
"net/netip"
"runtime/debug"
"strings"
"syscall"
"github.com/tailscale/tailscale-android/libtailscale/ifaceparse"
"github.com/tailscale/wireguard-go/tun"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
@ -42,83 +42,24 @@ var vpnService = &VpnService{}
// Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]netmon.Interface, error) {
var ifaces []netmon.Interface
ifaceString, err := a.appCtx.GetInterfacesAsString()
jsonStr, err := a.appCtx.GetInterfacesAsJson()
if err != nil {
return ifaces, err
return nil, err
}
jsonStr = strings.TrimSpace(jsonStr)
if jsonStr == "" {
return nil, nil
}
for _, iface := range strings.Split(ifaceString, "\n") {
// Example of the strings we're processing:
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// mnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
if strings.TrimSpace(iface) == "" {
continue
}
fields := strings.Split(iface, "|")
if len(fields) != 2 {
log.Printf("getInterfaces: unable to split %q", iface)
continue
}
var name string
var index, mtu int
var up, broadcast, loopback, pointToPoint, multicast bool
_, err := fmt.Sscanf(fields[0], "%s %d %d %t %t %t %t %t",
&name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast)
if err != nil {
log.Printf("getInterfaces: unable to parse %q: %v", iface, err)
continue
}
newIf := netmon.Interface{
Interface: &net.Interface{
Name: name,
Index: index,
MTU: mtu,
},
AltAddrs: []net.Addr{}, // non-nil to avoid Go using netlink
}
if up {
newIf.Flags |= net.FlagUp
}
if broadcast {
newIf.Flags |= net.FlagBroadcast
}
if loopback {
newIf.Flags |= net.FlagLoopback
}
if pointToPoint {
newIf.Flags |= net.FlagPointToPoint
}
if multicast {
newIf.Flags |= net.FlagMulticast
}
ifaces, st, err := ifaceparse.ParseInterfacesJSONAsNetmon([]byte(jsonStr))
if err != nil {
return nil, err
}
addrs := strings.Trim(fields[1], " \n")
for _, addr := range strings.Split(addrs, " ") {
pfx, err := netip.ParsePrefix(addr)
var ip net.IP
if pfx.Addr().Is4() {
v4 := pfx.Addr().As4()
ip = net.IP(v4[:])
} else {
v6 := pfx.Addr().As16()
ip = net.IP(v6[:])
}
if err == nil {
newIf.AltAddrs = append(newIf.AltAddrs, &net.IPAddr{
IP: ip,
Zone: pfx.Addr().Zone(),
})
}
}
if st.IfacesSkipped > 0 || st.AddrsSkipped > 0 {
log.Printf("getInterfaces(JSON): parsed %d/%d ifaces, %d/%d addrs (skipped %d ifaces, %d addrs)",
st.IfacesParsed, st.IfacesTotal, st.AddrsParsed, st.AddrsTotal, st.IfacesSkipped, st.AddrsSkipped)
ifaces = append(ifaces, newIf)
}
return ifaces, nil

Loading…
Cancel
Save