diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 481e85d..97b7fe4 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -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, + ) + override fun getInterfacesAsJson(): String { + val interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()) + val out = ArrayList(interfaces.size) + + for (nif in interfaces) { + try { + val addrs = ArrayList() + 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 { diff --git a/libtailscale/ifaceparse/ifaceparse.go b/libtailscale/ifaceparse/ifaceparse.go new file mode 100644 index 0000000..f16cb81 --- /dev/null +++ b/libtailscale/ifaceparse/ifaceparse.go @@ -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 +} diff --git a/libtailscale/ifaceparse/ifaceparse_test.go b/libtailscale/ifaceparse/ifaceparse_test.go new file mode 100644 index 0000000..63b14d8 --- /dev/null +++ b/libtailscale/ifaceparse/ifaceparse_test.go @@ -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 } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index f0d6c6d..73d02f4 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -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 diff --git a/libtailscale/net.go b/libtailscale/net.go index 0a12e19..29242d8 100644 --- a/libtailscale/net.go +++ b/libtailscale/net.go @@ -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