ipn/ipnlocal: prefer one CGNAT route on Android (#19652)

Android rebuilds its VpnService interface when the VPN route
configuration changes, which tears down long lived TCP connections
through the tunnel. Use the same automatic OneCGNATRoute behavior as
macOS on Android, and prefer the single CGNAT route when no other
interface is using the CGNAT, falling back to fine grained peer routes
otherwise.

Updates tailscale/tailscale#19591

Signed-off-by: kari <kari@tailscale.com>
This commit is contained in:
kari-ts
2026-05-05 19:11:17 -07:00
committed by GitHub
parent f844c8bc32
commit c721189cef
2 changed files with 83 additions and 5 deletions
+74
View File
@@ -32,6 +32,7 @@ import (
"tailscale.com/appc"
"tailscale.com/appc/appctest"
"tailscale.com/control/controlclient"
"tailscale.com/control/controlknobs"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl"
"tailscale.com/feature"
@@ -8158,3 +8159,76 @@ func TestStartPreservesLoginFlags(t *testing.T) {
t.Errorf("cc.Login was never called with LoginEphemeral; got flags=%v", flags)
}
}
func TestShouldUseOneCGNATRoute(t *testing.T) {
tests := []struct {
name string
versionOS string
want bool
}{
{"android", "android", true},
{"macOS", "macOS", true},
{"plan9", "plan9", true},
{"linux", "linux", false},
{"windows", "windows", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldUseOneCGNATRoute(t.Logf, nil, nil, tt.versionOS)
if got != tt.want {
t.Errorf("shouldUseOneCGNATRoute(%q) = %v; want %v", tt.versionOS, got, tt.want)
}
})
}
// Control knob takes precedence over everything.
t.Run("control-knob-override", func(t *testing.T) {
knobs := &controlknobs.Knobs{}
knobs.OneCGNAT.Store(opt.NewBool(false))
if got := shouldUseOneCGNATRoute(t.Logf, nil, knobs, "android"); got {
t.Error("control knob should override android default; got true, want false")
}
knobs.OneCGNAT.Store(opt.NewBool(true))
if got := shouldUseOneCGNATRoute(t.Logf, nil, knobs, "linux"); !got {
t.Error("control knob should override linux default; got false, want true")
}
})
}
func TestPeerRoutesCGNATCollapse(t *testing.T) {
pp := netip.MustParsePrefix
// With cgnatThreshold=1 (oneCGNATRoute), adding a peer should not
// change the route list. Both collapse to a single 100.64.0.0/10.
twoPeers := []wgcfg.Peer{
{AllowedIPs: []netip.Prefix{pp("100.64.0.1/32")}},
{AllowedIPs: []netip.Prefix{pp("100.64.0.2/32")}},
}
threePeers := []wgcfg.Peer{
{AllowedIPs: []netip.Prefix{pp("100.64.0.1/32")}},
{AllowedIPs: []netip.Prefix{pp("100.64.0.2/32")}},
{AllowedIPs: []netip.Prefix{pp("100.64.0.3/32")}},
}
routesTwo := peerRoutes(t.Logf, twoPeers, 1, true)
routesThree := peerRoutes(t.Logf, threePeers, 1, true)
wantCGNAT := []netip.Prefix{pp("100.64.0.0/10")}
if !reflect.DeepEqual(routesTwo, wantCGNAT) {
t.Errorf("two peers: got %v; want %v", routesTwo, wantCGNAT)
}
if !reflect.DeepEqual(routesThree, wantCGNAT) {
t.Errorf("three peers: got %v; want %v", routesThree, wantCGNAT)
}
// Subnet routes must still appear alongside the collapsed CGNAT route.
peersWithSubnet := []wgcfg.Peer{
{AllowedIPs: []netip.Prefix{pp("100.64.0.1/32")}},
{AllowedIPs: []netip.Prefix{pp("100.64.0.2/32"), pp("10.0.0.0/24")}},
}
got := peerRoutes(t.Logf, peersWithSubnet, 1, true)
want := []netip.Prefix{pp("100.64.0.0/10"), pp("10.0.0.0/24")}
if !reflect.DeepEqual(got, want) {
t.Errorf("with subnet: got %v; want %v", got, want)
}
}