Files
tailscale/tstest/natlab/vmtest/vmtest_test.go
T
Brad Fitzpatrick d0ae993334 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>
2026-04-27 17:06:17 -07:00

373 lines
13 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest_test
import (
"fmt"
"strings"
"testing"
"tailscale.com/tstest/natlab/vmtest"
"tailscale.com/tstest/natlab/vnet"
)
func TestSubnetRouter(t *testing.T) {
testSubnetRouterForOS(t, vmtest.Ubuntu2404)
}
func TestSubnetRouterFreeBSD(t *testing.T) {
testSubnetRouterForOS(t, vmtest.FreeBSD150)
}
func testSubnetRouterForOS(t testing.TB, srOS vmtest.OSImage) {
t.Helper()
env := vmtest.New(t)
clientNet := env.AddNetwork("2.1.1.1", "192.168.1.1/24", "2000:1::1/64", vnet.EasyNAT)
internalNet := env.AddNetwork("10.0.0.1/24", "2000:2::1/64")
client := env.AddNode("client", clientNet,
vmtest.OS(vmtest.Gokrazy))
sr := env.AddNode("subnet-router", clientNet, internalNet,
vmtest.OS(srOS),
vmtest.AdvertiseRoutes("10.0.0.0/24"))
backend := env.AddNode("backend", internalNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
env.ApproveRoutes(sr, "10.0.0.0/24")
body := env.HTTPGet(client, fmt.Sprintf("http://%s:8080/", backend.LanIP(internalNet)))
if !strings.Contains(body, "Hello world I am backend") {
t.Fatalf("got %q", body)
}
}
func TestSiteToSite(t *testing.T) {
testSiteToSite(t, vmtest.Ubuntu2404)
}
// testSiteToSite runs a site-to-site subnet routing test with
// --snat-subnet-routes=false, verifying that original source IPs are preserved
// across Tailscale subnet routes.
//
// Topology:
//
// Site A: backend-a (10.1.0.0/24) ← → sr-a (WAN + LAN-A)
// Site B: backend-b (10.2.0.0/24) ← → sr-b (WAN + LAN-B)
//
// Both subnet routers are on Tailscale with --snat-subnet-routes=false.
// The test sends HTTP from backend-a to backend-b through the subnet routers
// and verifies that backend-b sees backend-a's LAN IP (not the subnet router's).
func testSiteToSite(t *testing.T, srOS vmtest.OSImage) {
env := vmtest.New(t)
// WAN networks for each site (each behind NAT).
wanA := env.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.EasyNAT)
wanB := env.AddNetwork("3.1.1.1", "192.168.2.1/24", vnet.EasyNAT)
// Internal LAN for each site.
lanA := env.AddNetwork("10.1.0.1/24")
lanB := env.AddNetwork("10.2.0.1/24")
// Subnet routers: each on its WAN + LAN, advertising the local LAN,
// with SNAT disabled to preserve source IPs.
srA := env.AddNode("sr-a", wanA, lanA,
vmtest.OS(srOS),
vmtest.AdvertiseRoutes("10.1.0.0/24"),
vmtest.SNATSubnetRoutes(false))
srB := env.AddNode("sr-b", wanB, lanB,
vmtest.OS(srOS),
vmtest.AdvertiseRoutes("10.2.0.0/24"),
vmtest.SNATSubnetRoutes(false))
// Backend servers on each site's LAN (not on Tailscale).
// Use Ubuntu so we can SSH in to add static routes.
backendA := env.AddNode("backend-a", lanA,
vmtest.OS(vmtest.Ubuntu2404),
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
backendB := env.AddNode("backend-b", lanB,
vmtest.OS(vmtest.Ubuntu2404),
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
env.ApproveRoutes(srA, "10.1.0.0/24")
env.ApproveRoutes(srB, "10.2.0.0/24")
// Add static routes on the backends so that traffic to the remote site's
// subnet goes through the local subnet router. This mirrors how a real
// site-to-site deployment is configured.
srALanIP := srA.LanIP(lanA).String()
srBLanIP := srB.LanIP(lanB).String()
t.Logf("sr-a LAN IP: %s, sr-b LAN IP: %s", srALanIP, srBLanIP)
t.Logf("backend-a LAN IP: %s, backend-b LAN IP: %s", backendA.LanIP(lanA), backendB.LanIP(lanB))
env.AddRoute(backendA, "10.2.0.0/24", srALanIP)
env.AddRoute(backendB, "10.1.0.0/24", srBLanIP)
// Make an HTTP request from backend-a to backend-b through the subnet routers.
// TTA's /http-get falls back to direct dial on non-Tailscale nodes.
backendBIP := backendB.LanIP(lanB)
body := env.HTTPGet(backendA, fmt.Sprintf("http://%s:8080/", backendBIP))
t.Logf("response: %s", body)
if !strings.Contains(body, "Hello world I am backend-b") {
t.Fatalf("expected response from backend-b, got %q", body)
}
// Verify the source IP was preserved. With --snat-subnet-routes=false,
// backend-b should see backend-a's LAN IP as the source, not sr-b's LAN IP.
backendAIP := backendA.LanIP(lanA).String()
if !strings.Contains(body, "from "+backendAIP) {
t.Fatalf("source IP not preserved: expected %q in response, got %q", backendAIP, body)
}
}
// TestInterNetworkTCP verifies that vnet routes raw TCP between simulated
// networks: a non-Tailscale VM on one NAT'd LAN can reach a webserver on a
// different network using a 1:1 NAT, and the webserver sees the client's
// network's WAN IP as the source (post-NAT).
func TestInterNetworkTCP(t *testing.T) {
env := vmtest.New(t)
const (
clientWAN = "1.0.0.1"
webWAN = "5.0.0.1"
)
clientNet := env.AddNetwork(clientWAN, "192.168.1.1/24", vnet.EasyNAT)
webNet := env.AddNetwork(webWAN, "192.168.5.1/24", vnet.One2OneNAT)
client := env.AddNode("client", clientNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.DontJoinTailnet())
env.AddNode("webserver", webNet,
vmtest.OS(vmtest.Gokrazy),
vmtest.DontJoinTailnet(),
vmtest.WebServer(8080))
env.Start()
body := env.HTTPGet(client, fmt.Sprintf("http://%s:8080/", webWAN))
t.Logf("response: %s", body)
if !strings.Contains(body, "Hello world I am webserver") {
t.Fatalf("unexpected response: %q", body)
}
if !strings.Contains(body, "from "+clientWAN) {
t.Fatalf("expected source %q in response, got %q", clientWAN, body)
}
}
// 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.
//
// Topology: each of the client and the two exit nodes lives behind its own NAT
// with a unique WAN IP, and a webserver lives on yet another network using a
// 1:1 NAT so it's reachable from the simulated internet at a stable address.
// The webserver echoes the source IP of incoming requests, so we can tell
// which network's NAT the client's traffic egressed through:
// - off: source is the client's network WAN IP.
// - exit1: source is exit1's network WAN IP.
// - exit2: source is exit2's network WAN IP.
func TestExitNode(t *testing.T) {
env := vmtest.New(t)
const (
clientWAN = "1.0.0.1"
exit1WAN = "2.0.0.1"
exit2WAN = "3.0.0.1"
webWAN = "5.0.0.1"
)
clientNet := env.AddNetwork(clientWAN, "192.168.1.1/24", vnet.EasyNAT)
exit1Net := env.AddNetwork(exit1WAN, "192.168.2.1/24", vnet.EasyNAT)
exit2Net := env.AddNetwork(exit2WAN, "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))
exit1 := env.AddNode("exit1", exit1Net,
vmtest.OS(vmtest.Gokrazy),
vmtest.AdvertiseRoutes("0.0.0.0/0,::/0"))
exit2 := env.AddNode("exit2", exit2Net,
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(exit1, "0.0.0.0/0", "::/0")
env.ApproveRoutes(exit2, "0.0.0.0/0", "::/0")
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
tests := []struct {
name string // subtest name
exit *vmtest.Node
wantSrc string
}{
{"off", nil, clientWAN},
{"exit1", exit1, exit1WAN},
{"exit2", exit2, exit2WAN},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env.SetExitNode(client, tt.exit)
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 "+tt.wantSrc) {
t.Fatalf("expected source %q in response, got %q", tt.wantSrc, body)
}
})
}
}