tstest/natlab/vmtest: add more subnet router tests

Add two tests building on TestExitNode's framework:

TestSubnetRouterPublicIP brings up a client, a subnet router, and a
webserver, each on its own NAT'd network with distinct WAN IPs. The
subnet router advertises the webserver's network as a route. The test
toggles the client's --accept-routes preference and asserts that the
webserver's echoed source IP switches between the client's own WAN
(direct dial) and the subnet router's WAN (forwarded through the
router and SNAT'd).

TestSubnetRouterAndExitNode adds a fourth node, an exit node that
advertises 0.0.0.0/0 + ::/0, and uses a table-driven layout with
subtests to cover the four combinations of (exit on/off, subnet
on/off). The case where both are on confirms longest-prefix match
wins: the subnet router's /24 takes precedence over the exit node's
/0. The exit node itself is configured with --accept-routes=off so
that, in the exit-only case, it forwards directly to the simulated
internet rather than re-routing the forwarded traffic via the subnet
router (which would otherwise mask the exit node's WAN as the
observed source).

Adds an Env.SetAcceptRoutes helper for toggling the RouteAll pref via
EditPrefs, used by both tests.

Updates #13038

Change-Id: Ifc2726db1df2f039c477c222484f535bebc40445
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2026-04-27 22:53:39 +00:00 committed by Brad Fitzpatrick
parent c0e6ffed0d
commit d0ae993334
2 changed files with 157 additions and 0 deletions

View File

@ -426,6 +426,23 @@ func (e *Env) SetExitNode(client, exitNode *Node) {
}
}
// SetAcceptRoutes toggles the node's RouteAll preference (the
// --accept-routes flag), controlling whether it installs subnet routes
// advertised by peers.
func (e *Env) SetAcceptRoutes(n *Node, on bool) {
e.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := n.agent.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{RouteAll: on},
RouteAllSet: true,
}); err != nil {
e.t.Fatalf("SetAcceptRoutes(%s, %v): %v", n.name, on, err)
}
e.t.Logf("[%s] accept-routes=%v", n.name, on)
}
// ApproveRoutes tells the test control server to approve subnet routes
// for the given node. The routes should be CIDR strings.
func (e *Env) ApproveRoutes(n *Node, routes ...string) {

View File

@ -163,6 +163,146 @@ func TestInterNetworkTCP(t *testing.T) {
}
}
// TestSubnetRouterPublicIP verifies that toggling --accept-routes on the
// client switches between dialing a webserver directly and routing through a
// subnet router that advertises the webserver's public IP range.
//
// Topology: client, subnet router, and webserver each live behind their own
// NAT'd network with distinct WAN IPs; the subnet router advertises the
// webserver's network as a route. The webserver echoes the source IP it
// sees:
// - accept-routes=off: client dials webserver directly; source is client's WAN.
// - accept-routes=on: client tunnels to the subnet router, which forwards
// and SNATs; source is subnet router's WAN.
func TestSubnetRouterPublicIP(t *testing.T) {
env := vmtest.New(t)
const (
clientWAN = "1.0.0.1"
routerWAN = "2.0.0.1"
webWAN = "5.0.0.1"
webRoute = "5.0.0.0/24"
)
clientNet := env.AddNetwork(clientWAN, "192.168.1.1/24", vnet.EasyNAT)
routerNet := env.AddNetwork(routerWAN, "192.168.2.1/24", vnet.EasyNAT)
webNet := env.AddNetwork(webWAN, "192.168.5.1/24", vnet.One2OneNAT)
client := env.AddNode("client", clientNet,
vmtest.OS(vmtest.Gokrazy))
sr := env.AddNode("subnet-router", routerNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.AdvertiseRoutes(webRoute))
env.AddNode("webserver", webNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
// ApproveRoutes also turns on RouteAll on the client.
env.ApproveRoutes(sr, webRoute)
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
check := func(label, wantSrc string) {
t.Helper()
body := env.HTTPGet(client, webURL)
t.Logf("[%s] response: %s", label, body)
if !strings.Contains(body, "Hello world I am webserver") {
t.Fatalf("[%s] unexpected webserver response: %q", label, body)
}
if !strings.Contains(body, "from "+wantSrc) {
t.Fatalf("[%s] expected source %q in response, got %q", label, wantSrc, body)
}
}
// accept-routes=on (set by ApproveRoutes): traffic flows via the subnet router.
check("accept-routes=on", routerWAN)
// accept-routes=off: client dials the webserver directly.
env.SetAcceptRoutes(client, false)
check("accept-routes=off", clientWAN)
// Toggle back on to confirm the transition works in both directions.
env.SetAcceptRoutes(client, true)
check("accept-routes=on (again)", routerWAN)
}
// TestSubnetRouterAndExitNode checks how the subnet router and exit node
// preferences interact. Topology: client, subnet router, exit node, and
// webserver, each on its own NAT'd network with distinct WAN IPs. The subnet
// router advertises the webserver's network (5.0.0.0/24); the exit node
// advertises 0.0.0.0/0 + ::/0. The webserver echoes the source IP it sees:
//
// exit=off, subnet=off → client's WAN (direct dial)
// exit=off, subnet=on → subnet router's WAN
// exit=on, subnet=off → exit node's WAN
// exit=on, subnet=on → subnet router's WAN (more-specific /24 beats /0)
func TestSubnetRouterAndExitNode(t *testing.T) {
env := vmtest.New(t)
const (
clientWAN = "1.0.0.1"
routerWAN = "2.0.0.1"
exitWAN = "3.0.0.1"
webWAN = "5.0.0.1"
webRoute = "5.0.0.0/24"
)
clientNet := env.AddNetwork(clientWAN, "192.168.1.1/24", vnet.EasyNAT)
routerNet := env.AddNetwork(routerWAN, "192.168.2.1/24", vnet.EasyNAT)
exitNet := env.AddNetwork(exitWAN, "192.168.3.1/24", vnet.EasyNAT)
webNet := env.AddNetwork(webWAN, "192.168.5.1/24", vnet.One2OneNAT)
client := env.AddNode("client", clientNet,
vmtest.OS(vmtest.Gokrazy))
sr := env.AddNode("subnet-router", routerNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.AdvertiseRoutes(webRoute))
exit := env.AddNode("exit", exitNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.AdvertiseRoutes("0.0.0.0/0,::/0"))
env.AddNode("webserver", webNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
env.ApproveRoutes(sr, webRoute)
env.ApproveRoutes(exit, "0.0.0.0/0", "::/0")
// Don't let the exit node itself forward via the subnet router: when the
// client is using the exit node only, we want the exit node to egress to
// the simulated internet directly so the webserver sees the exit's WAN.
env.SetAcceptRoutes(exit, false)
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
tests := []struct {
name string // subtest name; describes (exit, subnet) toggles
exit *vmtest.Node
subnet bool
wantSrc string
}{
{"exit-off,subnet-off", nil, false, clientWAN},
{"exit-off,subnet-on", nil, true, routerWAN},
{"exit-on,subnet-off", exit, false, exitWAN},
// More-specific 5.0.0.0/24 from sr beats 0.0.0.0/0 from exit.
{"exit-on,subnet-on", exit, true, routerWAN},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
env.SetExitNode(client, tc.exit)
env.SetAcceptRoutes(client, tc.subnet)
body := env.HTTPGet(client, webURL)
t.Logf("response: %s", body)
if !strings.Contains(body, "Hello world I am webserver") {
t.Fatalf("unexpected webserver response: %q", body)
}
if !strings.Contains(body, "from "+tc.wantSrc) {
t.Fatalf("expected source %q in response, got %q", tc.wantSrc, body)
}
})
}
}
// TestExitNode verifies that switching the client's exit node setting between
// off, exit1, and exit2 correctly routes the client's internet traffic.
//