5c1738fd56
Add a vmtest TestExitNode that brings up a client, two exit nodes, and a non-Tailscale webserver, each on its own NAT'd vnet network with a distinct WAN IP. The test cycles the client's exit node setting between off, exit1, and exit2 and asserts that the webserver echoes the expected post-NAT source IP for each. Three pieces were needed to make this work: vnet now forwards TCP between simulated networks at the packet level, mirroring the existing UDP path. When a guest VM sends TCP to another simulated network's WAN IP, the source network's gateway rewrites src via doNATOut and routeTCPPacket hands the packet off to the destination network, which rewrites dst via doNATIn and writes the rewritten frame onto the destination LAN. The TCP stacks of the two guest VM kernels talk end-to-end; vnet just NATs the IP/port headers in flight, so all TCP semantics (handshakes, options, sequence numbers, payload) are preserved without a gvisor TCP termination in the middle. Adds a focused TestInterNetworkTCP that exercises this path without any Tailscale machinery. cmd/tta binds its outbound dial to the default route's interface using SO_BINDTODEVICE. Without that, the moment tailscaled installs 0.0.0.0/0 → tailscale0 in response to setting an exit node, TTA's existing TCP connection to test-driver gets rerouted through the exit node. From the test driver's perspective the connection's packets then arrive with the exit node's WAN IP as the source rather than the client's, so they don't match the existing flow and the connection is dead — manifesting in the test as a hang on EditPrefs (which had actually completed in milliseconds on the daemon side, but whose response never made it back). Pinning the socket to the underlying NIC keeps TTA's agent connection on a real interface regardless of any policy routing tailscaled installs later. We bind rather than carry the Tailscale bypass fwmark because the fwmark approach is conditional on tailscaled having configured SO_MARK-based policy routing, while binding is unconditional. vmtest grows an Env.SetExitNode helper that sets ExitNodeIP via EditPrefs through the agent, used by the new test. Updates #13038 Change-Id: I9fc8f91848b7aa2297ef3eaf71fed9d96056a024 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
233 lines
7.6 KiB
Go
233 lines
7.6 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|