diff --git a/libtailscale/backend.go b/libtailscale/backend.go index 7e01a1670e..9f72572a29 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -101,7 +101,12 @@ type backend struct { settings settingsFunc lastCfg *router.Config lastDNSCfg *dns.OSConfig - netMon *netmon.Monitor + // lastEffective is the snapshot of the VpnService.Builder-relevant + // subset of the last-applied config. updateTUN uses it to skip a + // rebuild when only fields outside that subset (e.g. peer /32 churn + // that we coalesce) have changed. See tailscale/tailscale#19591. + lastEffective vpnEffectiveCfg + netMon *netmon.Monitor logIDPublic logid.PublicID logger *logtail.Logger diff --git a/libtailscale/net.go b/libtailscale/net.go index 17986e0c00..d49225de7b 100644 --- a/libtailscale/net.go +++ b/libtailscale/net.go @@ -9,11 +9,13 @@ import ( "log" "net/netip" "runtime/debug" + "slices" "strings" "syscall" "github.com/tailscale/tailscale-android/libtailscale/ifaceparse" rangescalc "github.com/tailscale/tailscale-android/libtailscale/ranges_calc" + routesutil "github.com/tailscale/tailscale-android/libtailscale/routesutil" "github.com/tailscale/wireguard-go/tun" "tailscale.com/net/dns" "tailscale.com/net/netmon" @@ -77,6 +79,20 @@ var googleDNSServers = []netip.Addr{ } func (b *backend) updateTUN(rcfg *router.Config, dcfg *dns.OSConfig) (err error) { + // On large tailnets, wgengine fires a router.Config update on every peer + // up/down — but the change is just a per-peer /32 inside the tailnet's + // CGNAT range, which the Android VPN layer doesn't need (peer-level + // routing is handled inside wgengine). Coalescing those /32s into a + // single parent prefix lets us no-op when only peer membership changed + // and skip the VpnService.Builder.establish() rebuild that was tearing + // down every TCP socket on tun0. See tailscale/tailscale#19591. + eff := computeEffective(rcfg, dcfg) + if rcfg != nil && len(rcfg.LocalAddrs) > 0 && b.lastEffective.Equal(eff) { + b.logger.Logf("updateTUN: VPN-effective cfg unchanged; skipping rebuild (routes %d, coalesced %d)", + len(rcfg.Routes), len(eff.routes)) + return nil + } + b.logger.Logf("updateTUN: changed") defer b.logger.Logf("updateTUN: finished") @@ -144,7 +160,8 @@ func (b *backend) updateTUN(rcfg *router.Config, dcfg *dns.OSConfig) (err error) if useExclude { // For API 33+, use ExcludeRoute for LocalRoutes and AddRoute for Routes. - for _, route := range rcfg.Routes { + // eff.routes is the coalesced set — see computeEffective. + for _, route := range eff.routes { // Normalize route address; Builder.addRoute does not accept non-zero masked bits. route = route.Masked() if err := builder.AddRoute(route.Addr().String(), int32(route.Bits())); err != nil { @@ -163,10 +180,10 @@ func (b *backend) updateTUN(rcfg *router.Config, dcfg *dns.OSConfig) (err error) } } - b.logger.Logf("updateTUN: added %d routes (exclude-mode), localRoutes=%d", len(rcfg.Routes), len(rcfg.LocalRoutes)) + b.logger.Logf("updateTUN: added %d routes (exclude-mode), localRoutes=%d", len(eff.routes), len(rcfg.LocalRoutes)) } else { // Older APIs: compute allowed-minus-disallowed prefixes and AddRoute them. - prefixesV4, prefixesV6, err := rangescalc.Calculate(rcfg.Routes, rcfg.LocalRoutes) + prefixesV4, prefixesV6, err := rangescalc.Calculate(eff.routes, rcfg.LocalRoutes) if err != nil { b.logger.Logf("updateTUN: route calculation error: %v", err) return err @@ -253,6 +270,7 @@ func (b *backend) updateTUN(rcfg *router.Config, dcfg *dns.OSConfig) (err error) b.lastCfg = rcfg b.lastDNSCfg = dcfg + b.lastEffective = eff return nil } @@ -269,6 +287,7 @@ func closeFileDescriptor() error { // CloseVPN closes any active TUN devices. func (b *backend) CloseTUNs() { b.lastCfg = nil + b.lastEffective = vpnEffectiveCfg{} b.devices.Shutdown() } @@ -339,3 +358,47 @@ func (b *backend) getPlatformDNSConfig() string { func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error { return b.settings(rcfg, dcfg) } + +// vpnEffectiveCfg captures the subset of router.Config + dns.OSConfig +// that VpnService.Builder actually consumes. updateTUN compares this +// against the last-applied snapshot to decide whether a tun rebuild is +// needed. +type vpnEffectiveCfg struct { + localAddrs []netip.Prefix + routes []netip.Prefix // peer routes coalesced via routesutil.CoalescePeerRoutes + localRoutes []netip.Prefix + mtu int + nameservers []netip.Addr + searchDomains []dnsname.FQDN +} + +func computeEffective(rcfg *router.Config, dcfg *dns.OSConfig) vpnEffectiveCfg { + if rcfg == nil { + return vpnEffectiveCfg{} + } + eff := vpnEffectiveCfg{ + localAddrs: slices.Clone(rcfg.LocalAddrs), + routes: routesutil.CoalescePeerRoutes(rcfg.Routes), + localRoutes: slices.Clone(rcfg.LocalRoutes), + mtu: rcfg.NewMTU, + } + if dcfg != nil { + eff.nameservers = slices.Clone(dcfg.Nameservers) + eff.searchDomains = slices.Clone(dcfg.SearchDomains) + } + return eff +} + +// Equal reports whether two effective configs are identical. The routes +// slice is sorted by routesutil.CoalescePeerRoutes; the other slices +// preserve wgengine's emission order, which is deterministic for a given +// netmap. A spurious mismatch would only cause an extra rebuild, never a +// missed one. +func (e vpnEffectiveCfg) Equal(o vpnEffectiveCfg) bool { + return e.mtu == o.mtu && + slices.Equal(e.localAddrs, o.localAddrs) && + slices.Equal(e.routes, o.routes) && + slices.Equal(e.localRoutes, o.localRoutes) && + slices.Equal(e.nameservers, o.nameservers) && + slices.Equal(e.searchDomains, o.searchDomains) +} diff --git a/libtailscale/routesutil/routesutil.go b/libtailscale/routesutil/routesutil.go new file mode 100644 index 0000000000..7b976ad5cf --- /dev/null +++ b/libtailscale/routesutil/routesutil.go @@ -0,0 +1,82 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package routesutil provides helpers for manipulating the route slices +// produced by wgengine before they are pushed into Android's +// VpnService.Builder. It lives in its own package (rather than inside +// libtailscale) so the pure-Go helpers can be unit-tested without an +// Android NDK toolchain — Makefile's go-test target excludes only the +// libtailscale package itself. +package routesutil + +import ( + "cmp" + "net/netip" + "slices" + + "tailscale.com/net/tsaddr" +) + +// CoalescePeerRoutes folds per-peer /32 (IPv4) and /128 (IPv6) routes +// inside Tailscale's well-known address ranges (the CGNAT range +// 100.64.0.0/10 and the Tailscale ULA fd7a:115c:a1e0::/48) into a single +// parent prefix per family. Other prefixes — subnet routers, exit nodes, +// custom advertised routes — pass through unchanged. +// +// Peer-level routing inside the tailnet is handled by wgengine's +// userspace WireGuard, so the Android VPN layer only needs the parent +// prefix to deliver tailnet-bound traffic into tun0. On a tailnet with +// N peers and subnets-off, the input shrinks from ~N entries to ~2 (one +// /10, one /48) plus any non-tailnet routes. +// +// The output is sorted (IPv4 before IPv6, then ascending prefix length, +// then ascending address) so callers can compare two coalesced sets +// with slices.Equal. +// +// See tailscale/tailscale#19591 for context. +func CoalescePeerRoutes(in []netip.Prefix) []netip.Prefix { + if len(in) == 0 { + return nil + } + v4 := tsaddr.CGNATRange() + v6 := tsaddr.TailscaleULARange() + + var hasV4Peer, hasV6Peer bool + out := make([]netip.Prefix, 0, len(in)+2) + for _, r := range in { + switch { + case r.Bits() == 32 && v4.Contains(r.Addr()): + hasV4Peer = true + case r.Bits() == 128 && v6.Contains(r.Addr()): + hasV6Peer = true + default: + out = append(out, r) + } + } + // Avoid emitting a duplicate parent prefix if wgengine already + // included it among non-peer routes (theoretically possible if the + // tailnet admin advertises the parent as a subnet route). + if hasV4Peer && !slices.Contains(out, v4) { + out = append(out, v4) + } + if hasV6Peer && !slices.Contains(out, v6) { + out = append(out, v6) + } + slices.SortFunc(out, prefixLess) + return out +} + +// prefixLess sorts prefixes for stable comparison: IPv4 before IPv6, +// then ascending prefix length, then ascending address. +func prefixLess(a, b netip.Prefix) int { + if a.Addr().Is4() != b.Addr().Is4() { + if a.Addr().Is4() { + return -1 + } + return 1 + } + if c := cmp.Compare(a.Bits(), b.Bits()); c != 0 { + return c + } + return a.Addr().Compare(b.Addr()) +} diff --git a/libtailscale/routesutil/routesutil_test.go b/libtailscale/routesutil/routesutil_test.go new file mode 100644 index 0000000000..54c9dfbd3e --- /dev/null +++ b/libtailscale/routesutil/routesutil_test.go @@ -0,0 +1,195 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package routesutil + +import ( + "net/netip" + "slices" + "testing" + + "tailscale.com/net/tsaddr" +) + +func mustPfx(s string) netip.Prefix { + return netip.MustParsePrefix(s) +} + +func TestCoalescePeerRoutes(t *testing.T) { + v4 := tsaddr.CGNATRange() + v6 := tsaddr.TailscaleULARange() + + tests := []struct { + name string + in []netip.Prefix + want []netip.Prefix + }{ + { + name: "nil", + in: nil, + want: nil, + }, + { + name: "empty", + in: []netip.Prefix{}, + want: nil, + }, + { + name: "no peer routes — passthrough", + in: []netip.Prefix{ + mustPfx("10.0.0.0/8"), + mustPfx("192.168.1.0/24"), + mustPfx("0.0.0.0/0"), + }, + want: []netip.Prefix{ + mustPfx("0.0.0.0/0"), + mustPfx("10.0.0.0/8"), + mustPfx("192.168.1.0/24"), + }, + }, + { + name: "single IPv4 peer collapses to CGNAT range", + in: []netip.Prefix{ + mustPfx("100.64.1.5/32"), + }, + want: []netip.Prefix{v4}, + }, + { + name: "many IPv4 peers collapse to single CGNAT range", + in: []netip.Prefix{ + mustPfx("100.64.1.5/32"), + mustPfx("100.68.32.10/32"), + mustPfx("100.70.224.35/32"), + mustPfx("100.95.255.1/32"), + }, + want: []netip.Prefix{v4}, + }, + { + name: "IPv6 peer collapses to ULA range", + in: []netip.Prefix{ + mustPfx("fd7a:115c:a1e0::a105:e023/128"), + }, + want: []netip.Prefix{v6}, + }, + { + name: "mixed v4 + v6 peers", + in: []netip.Prefix{ + mustPfx("100.64.1.5/32"), + mustPfx("fd7a:115c:a1e0::a105:e023/128"), + mustPfx("100.95.255.1/32"), + }, + want: []netip.Prefix{v4, v6}, + }, + { + // Output is sorted by (family, bits asc, addr asc): /0, /8, /10, /12 for v4; /0, /48 for v6. + name: "peer + subnet routers + exit node", + in: []netip.Prefix{ + mustPfx("10.0.0.0/8"), + mustPfx("100.64.1.5/32"), + mustPfx("172.16.0.0/12"), + mustPfx("100.68.32.10/32"), + mustPfx("0.0.0.0/0"), + mustPfx("fd7a:115c:a1e0::a105:e023/128"), + mustPfx("::/0"), + }, + want: []netip.Prefix{ + mustPfx("0.0.0.0/0"), + mustPfx("10.0.0.0/8"), + v4, // 100.64.0.0/10 + mustPfx("172.16.0.0/12"), + mustPfx("::/0"), + v6, + }, + }, + { + // /10 sorts before /14 (bits ascending) even though both have the same network address. + name: "non-/32 inside CGNAT range passes through (subnet route, 4via6, etc.)", + in: []netip.Prefix{ + mustPfx("100.64.0.0/14"), + mustPfx("100.68.32.10/32"), + }, + want: []netip.Prefix{ + v4, + mustPfx("100.64.0.0/14"), + }, + }, + { + name: "deduplication when CGNAT range itself is in input", + in: []netip.Prefix{ + v4, + mustPfx("100.68.32.10/32"), + mustPfx("100.95.255.1/32"), + }, + want: []netip.Prefix{v4}, // not [v4, v4] + }, + { + name: "deduplication when ULA range itself is in input", + in: []netip.Prefix{ + v6, + mustPfx("fd7a:115c:a1e0::a105:e023/128"), + }, + want: []netip.Prefix{v6}, + }, + { + name: "stable sort: same input in different orders → same output", + in: []netip.Prefix{ + mustPfx("192.168.1.0/24"), + mustPfx("10.0.0.0/8"), + mustPfx("172.16.0.0/12"), + }, + want: []netip.Prefix{ + mustPfx("10.0.0.0/8"), + mustPfx("172.16.0.0/12"), + mustPfx("192.168.1.0/24"), + }, + }, + { + // /10 sorts before /32 (bits ascending). + name: "address outside CGNAT range with /32 passes through (e.g. corporate /32)", + in: []netip.Prefix{ + mustPfx("10.5.5.5/32"), + mustPfx("100.64.1.5/32"), + }, + want: []netip.Prefix{ + v4, + mustPfx("10.5.5.5/32"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CoalescePeerRoutes(tt.in) + if !slices.Equal(got, tt.want) { + t.Errorf("CoalescePeerRoutes(%v)\n got: %v\n want: %v", tt.in, got, tt.want) + } + }) + } +} + +// TestCoalescePeerRoutes_StableAcrossPeerChurn is the regression test +// for tailscale/tailscale#19591: adding or removing peers must NOT +// perturb the coalesced output. +func TestCoalescePeerRoutes_StableAcrossPeerChurn(t *testing.T) { + base := []netip.Prefix{ + mustPfx("10.0.0.0/8"), + mustPfx("100.64.1.5/32"), + mustPfx("100.68.32.10/32"), + } + withExtraPeer := append(slices.Clone(base), mustPfx("100.95.255.1/32")) + withFewerPeers := []netip.Prefix{ + mustPfx("10.0.0.0/8"), + mustPfx("100.64.1.5/32"), + } + + a := CoalescePeerRoutes(base) + b := CoalescePeerRoutes(withExtraPeer) + c := CoalescePeerRoutes(withFewerPeers) + + if !slices.Equal(a, b) { + t.Errorf("peer-up perturbed output\n base: %v\n +1 peer: %v", a, b) + } + if !slices.Equal(a, c) { + t.Errorf("peer-down perturbed output\n base: %v\n -1 peer: %v", a, c) + } +}