4b8e0ede6d
Add a vmtest that brings up a Tailscale client, an Ubuntu VM acting as a Mullvad-style plain-WireGuard exit node, and a non-Tailscale webserver, each on its own NAT'd vnet network with a distinct WAN IP. The test exercises Tailscale's IsWireGuardOnly peer code path: the way the control plane wires Mullvad exit nodes into a client's netmap, including the per-client SelfNodeV4MasqAddrForThisPeer source-IP rewrite that lets a Tailscale CGNAT IP egress through a plain-WireGuard tunnel that has no idea what Tailscale is. The mullvad VM doesn't run wireguard-tools or kernel WireGuard; instead, a new TTA endpoint /wg-server-up creates a real Linux TUN named wg0, drives it with wireguard-go (already vendored), and configures the kernel side (ip addr/up, ip_forward, iptables NAT MASQUERADE) so decrypted traffic from the peer egresses with the mullvad VM's WAN IP. Userspace vs kernel WireGuard makes no difference on the wire — what's being tested is Tailscale's plain-WireGuard exit-node code path, not the kernel module — and this lets the test avoid downloading and installing .deb packages inside the VM. Adds Env.BringUpMullvadWGServer (calls /wg-server-up, returns the generated WG public key as a key.NodePublic), Env.SetExitNodeIP (EditPrefs ExitNodeIP directly, for exit nodes whose IPs aren't discoverable via TTA), Env.ControlServer (exposes the underlying testcontrol.Server so tests can UpdateNode / SetMasqueradeAddresses to inject custom peers), and Env.Status (fetches a node's tailscale status, used to read the client's pubkey so we can pin it as the WG server's only allowed peer). The test verifies that the webserver's echoed source IP is the client's WAN with no exit node selected, the mullvad VM's WAN with the WG-only peer selected as exit, and the client's WAN again after clearing. Updates #13038 Change-Id: I5bac4e0d832f05929f12cb77fa9946d7f5fb5ef1 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
596 lines
21 KiB
Go
596 lines
21 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package vmtest_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tstest/integration/testcontrol"
|
|
"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))
|
|
|
|
// Declare test-specific steps for the web UI.
|
|
approveStep := env.AddStep("Approve subnet routes")
|
|
httpStep := env.AddStep("HTTP GET through subnet router")
|
|
|
|
env.Start()
|
|
|
|
approveStep.Begin()
|
|
env.ApproveRoutes(sr, "10.0.0.0/24")
|
|
approveStep.End(nil)
|
|
|
|
httpStep.Begin()
|
|
body := env.HTTPGet(client, fmt.Sprintf("http://%s:8080/", backend.LanIP(internalNet)))
|
|
if !strings.Contains(body, "Hello world I am backend") {
|
|
httpStep.End(fmt.Errorf("got %q", body))
|
|
t.Fatalf("got %q", body)
|
|
}
|
|
httpStep.End(nil)
|
|
}
|
|
|
|
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))
|
|
|
|
// Declare test-specific steps for the web UI.
|
|
approveStep := env.AddStep("Approve subnet routes (sr-a, sr-b)")
|
|
staticRouteStep := env.AddStep("Add static routes on backends")
|
|
httpStep := env.AddStep("HTTP GET through site-to-site")
|
|
|
|
env.Start()
|
|
|
|
approveStep.Begin()
|
|
env.ApproveRoutes(srA, "10.1.0.0/24")
|
|
env.ApproveRoutes(srB, "10.2.0.0/24")
|
|
approveStep.End(nil)
|
|
|
|
// 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))
|
|
|
|
staticRouteStep.Begin()
|
|
env.AddRoute(backendA, "10.2.0.0/24", srALanIP)
|
|
env.AddRoute(backendB, "10.1.0.0/24", srBLanIP)
|
|
staticRouteStep.End(nil)
|
|
|
|
// 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.
|
|
httpStep.Begin()
|
|
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") {
|
|
httpStep.End(fmt.Errorf("expected response from backend-b, got %q", body))
|
|
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) {
|
|
httpStep.End(fmt.Errorf("source IP not preserved: expected %q in response, got %q", backendAIP, body))
|
|
t.Fatalf("source IP not preserved: expected %q in response, got %q", backendAIP, body)
|
|
}
|
|
httpStep.End(nil)
|
|
}
|
|
|
|
// 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))
|
|
|
|
// Declare test-specific steps for the web UI.
|
|
httpStep := env.AddStep("HTTP GET across networks via NAT")
|
|
|
|
env.Start()
|
|
|
|
httpStep.Begin()
|
|
body := env.HTTPGet(client, fmt.Sprintf("http://%s:8080/", webWAN))
|
|
t.Logf("response: %s", body)
|
|
if !strings.Contains(body, "Hello world I am webserver") {
|
|
httpStep.End(fmt.Errorf("unexpected response: %q", body))
|
|
t.Fatalf("unexpected response: %q", body)
|
|
}
|
|
if !strings.Contains(body, "from "+clientWAN) {
|
|
httpStep.End(fmt.Errorf("expected source %q in response, got %q", clientWAN, body))
|
|
t.Fatalf("expected source %q in response, got %q", clientWAN, body)
|
|
}
|
|
httpStep.End(nil)
|
|
}
|
|
|
|
// 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))
|
|
|
|
// Declare test-specific steps for the web UI.
|
|
approveStep := env.AddStep("Approve subnet route (public IP)")
|
|
checkOn1Step := env.AddStep("HTTP GET (accept-routes=on)")
|
|
checkOffStep := env.AddStep("HTTP GET (accept-routes=off)")
|
|
checkOn2Step := env.AddStep("HTTP GET (accept-routes=on, again)")
|
|
|
|
env.Start()
|
|
// ApproveRoutes also turns on RouteAll on the client.
|
|
approveStep.Begin()
|
|
env.ApproveRoutes(sr, webRoute)
|
|
approveStep.End(nil)
|
|
|
|
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
|
|
check := func(step *vmtest.Step, label, wantSrc string) {
|
|
t.Helper()
|
|
step.Begin()
|
|
body := env.HTTPGet(client, webURL)
|
|
t.Logf("[%s] response: %s", label, body)
|
|
if !strings.Contains(body, "Hello world I am webserver") {
|
|
step.End(fmt.Errorf("[%s] unexpected webserver response: %q", label, body))
|
|
t.Fatalf("[%s] unexpected webserver response: %q", label, body)
|
|
}
|
|
if !strings.Contains(body, "from "+wantSrc) {
|
|
step.End(fmt.Errorf("[%s] expected source %q in response, got %q", label, wantSrc, body))
|
|
t.Fatalf("[%s] expected source %q in response, got %q", label, wantSrc, body)
|
|
}
|
|
step.End(nil)
|
|
}
|
|
|
|
// accept-routes=on (set by ApproveRoutes): traffic flows via the subnet router.
|
|
check(checkOn1Step, "accept-routes=on", routerWAN)
|
|
|
|
// accept-routes=off: client dials the webserver directly.
|
|
env.SetAcceptRoutes(client, false)
|
|
check(checkOffStep, "accept-routes=off", clientWAN)
|
|
|
|
// Toggle back on to confirm the transition works in both directions.
|
|
env.SetAcceptRoutes(client, true)
|
|
check(checkOn2Step, "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))
|
|
|
|
// Declare test-specific steps for the web UI.
|
|
approveStep := env.AddStep("Approve subnet & exit routes")
|
|
|
|
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
|
|
tests := []struct {
|
|
name string // subtest name; describes (exit, subnet) toggles
|
|
exit *vmtest.Node
|
|
subnet bool
|
|
wantSrc string
|
|
step *vmtest.Step
|
|
}{
|
|
{"exit-off,subnet-off", nil, false, clientWAN, nil},
|
|
{"exit-off,subnet-on", nil, true, routerWAN, nil},
|
|
{"exit-on,subnet-off", exit, false, exitWAN, nil},
|
|
// More-specific 5.0.0.0/24 from sr beats 0.0.0.0/0 from exit.
|
|
{"exit-on,subnet-on", exit, true, routerWAN, nil},
|
|
}
|
|
for i := range tests {
|
|
tests[i].step = env.AddStep("HTTP GET: " + tests[i].name)
|
|
}
|
|
|
|
env.Start()
|
|
approveStep.Begin()
|
|
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)
|
|
approveStep.End(nil)
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tc.step.Begin()
|
|
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") {
|
|
tc.step.End(fmt.Errorf("unexpected webserver response: %q", body))
|
|
t.Fatalf("unexpected webserver response: %q", body)
|
|
}
|
|
if !strings.Contains(body, "from "+tc.wantSrc) {
|
|
tc.step.End(fmt.Errorf("expected source %q in response, got %q", tc.wantSrc, body))
|
|
t.Fatalf("expected source %q in response, got %q", tc.wantSrc, body)
|
|
}
|
|
tc.step.End(nil)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
|
|
// Declare test-specific steps for the web UI.
|
|
approveStep := env.AddStep("Approve exit-node routes (exit1, exit2)")
|
|
|
|
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
|
|
tests := []struct {
|
|
name string // subtest name
|
|
exit *vmtest.Node
|
|
wantSrc string
|
|
step *vmtest.Step
|
|
}{
|
|
{"off", nil, clientWAN, nil},
|
|
{"exit1", exit1, exit1WAN, nil},
|
|
{"exit2", exit2, exit2WAN, nil},
|
|
}
|
|
for i := range tests {
|
|
tests[i].step = env.AddStep("HTTP GET: exit=" + tests[i].name)
|
|
}
|
|
|
|
env.Start()
|
|
approveStep.Begin()
|
|
env.ApproveRoutes(exit1, "0.0.0.0/0", "::/0")
|
|
env.ApproveRoutes(exit2, "0.0.0.0/0", "::/0")
|
|
approveStep.End(nil)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tt.step.Begin()
|
|
env.SetExitNode(client, tt.exit)
|
|
body := env.HTTPGet(client, webURL)
|
|
t.Logf("response: %s", body)
|
|
if !strings.Contains(body, "Hello world I am webserver") {
|
|
tt.step.End(fmt.Errorf("unexpected webserver response: %q", body))
|
|
t.Fatalf("unexpected webserver response: %q", body)
|
|
}
|
|
if !strings.Contains(body, "from "+tt.wantSrc) {
|
|
tt.step.End(fmt.Errorf("expected source %q in response, got %q", tt.wantSrc, body))
|
|
t.Fatalf("expected source %q in response, got %q", tt.wantSrc, body)
|
|
}
|
|
tt.step.End(nil)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMullvadExitNode verifies that a Tailscale client whose netmap contains
|
|
// a plain-WireGuard exit node (the way Mullvad exit nodes are wired up by
|
|
// the control plane) can route internet traffic through it, with the source
|
|
// IP rewritten to the per-client Mullvad-assigned address.
|
|
//
|
|
// Topology:
|
|
//
|
|
// client (Tailscale, gokrazy) — clientNet (EasyNAT) WAN 1.0.0.1
|
|
// mullvad (Ubuntu, userspace WG) — mullvadNet (One2OneNAT) WAN 2.0.0.1
|
|
// webserver (no Tailscale, gokrazy) — webNet (One2OneNAT) WAN 5.0.0.1
|
|
//
|
|
// The mullvad VM impersonates a Mullvad WireGuard server. After boot, the
|
|
// test asks its TTA agent to bring up a userspace WireGuard interface (a
|
|
// real Linux TUN driven by wireguard-go) that pins the client's Tailscale
|
|
// node public key as its only allowed peer, sets up IP-forwarding + a
|
|
// MASQUERADE rule, and reports the WG server's freshly generated public
|
|
// key back. Userspace vs kernel WireGuard makes no difference on the wire
|
|
// — what's being tested is Tailscale's plain-WireGuard exit-node code
|
|
// path, not the kernel module.
|
|
//
|
|
// The test then injects a netmap peer with IsWireGuardOnly=true,
|
|
// AllowedIPs=[gw/32, 0.0.0.0/0, ::/0], the WG endpoint, and a per-client
|
|
// SelfNodeV4MasqAddrForThisPeer (the mock equivalent of the per-client IP
|
|
// Mullvad's API hands out at registration time).
|
|
//
|
|
// The webserver echoes the source IP it sees:
|
|
// - exit-node off: source is client's WAN (direct egress)
|
|
// - exit-node on: source is mullvad's WAN (egress via WG + MASQUERADE)
|
|
func TestMullvadExitNode(t *testing.T) {
|
|
env := vmtest.New(t)
|
|
|
|
const (
|
|
clientWAN = "1.0.0.1"
|
|
mullvadWAN = "2.0.0.1"
|
|
webWAN = "5.0.0.1"
|
|
)
|
|
// Mullvad-side WG network. The client appears as clientMasqIP to
|
|
// mullvad's wg0; mullvad terminates the tunnel at gw.
|
|
var (
|
|
mullvadWGNet = netip.MustParsePrefix("10.64.0.0/24")
|
|
gw = netip.MustParsePrefix("10.64.0.1/24")
|
|
clientMasq = netip.MustParsePrefix("10.64.0.2/32")
|
|
)
|
|
const wgListenPort uint16 = 51820
|
|
|
|
clientNet := env.AddNetwork(clientWAN, "192.168.1.1/24", vnet.EasyNAT)
|
|
mullvadNet := env.AddNetwork(mullvadWAN, "192.168.2.1/24", vnet.One2OneNAT)
|
|
webNet := env.AddNetwork(webWAN, "192.168.5.1/24", vnet.One2OneNAT)
|
|
|
|
client := env.AddNode("client", clientNet, vmtest.OS(vmtest.Gokrazy))
|
|
mullvad := env.AddNode("mullvad", mullvadNet,
|
|
vmtest.OS(vmtest.Ubuntu2404),
|
|
vmtest.DontJoinTailnet())
|
|
env.AddNode("webserver", webNet,
|
|
vmtest.OS(vmtest.Gokrazy),
|
|
vmtest.DontJoinTailnet(),
|
|
vmtest.WebServer(8080))
|
|
|
|
// Declare test-specific steps for the web UI.
|
|
wgUpStep := env.AddStep("Bring up Mullvad WG server")
|
|
injectStep := env.AddStep("Inject Mullvad netmap peer")
|
|
checkOff1Step := env.AddStep("HTTP GET (exit off)")
|
|
checkMullvadStep := env.AddStep("HTTP GET (exit=mullvad)")
|
|
checkOff2Step := env.AddStep("HTTP GET (exit off, again)")
|
|
|
|
env.Start()
|
|
|
|
// Bring up the WG server inside mullvad's TTA, pinning the client's
|
|
// Tailscale node public key as the sole allowed peer.
|
|
wgUpStep.Begin()
|
|
clientStatus := env.Status(client)
|
|
mullvadPub := env.BringUpMullvadWGServer(mullvad,
|
|
gw, wgListenPort,
|
|
clientStatus.Self.PublicKey, clientMasq, mullvadWGNet)
|
|
wgUpStep.End(nil)
|
|
|
|
// Inject the mullvad node into the netmap as a plain-WireGuard exit
|
|
// node. This mirrors how the control plane describes Mullvad exit
|
|
// nodes to clients (see control/cmullvad in the closed repo): a
|
|
// peer with IsWireGuardOnly=true, an Endpoints entry pointing at
|
|
// the public WG host:port, and AllowedIPs covering both the gateway
|
|
// /32 and the 0.0.0.0/0+::/0 exit-node routes.
|
|
injectStep.Begin()
|
|
mullvadEndpoint := netip.AddrPortFrom(netip.MustParseAddr(mullvadWAN), wgListenPort)
|
|
gwHost := netip.PrefixFrom(gw.Addr(), gw.Addr().BitLen())
|
|
mullvadNode := &tailcfg.Node{
|
|
ID: 999_001,
|
|
StableID: "mullvad-test",
|
|
Name: "mullvad-test.fake-control.example.net.",
|
|
Key: mullvadPub,
|
|
MachineAuthorized: true,
|
|
IsWireGuardOnly: true,
|
|
Endpoints: []netip.AddrPort{mullvadEndpoint},
|
|
Addresses: []netip.Prefix{gwHost},
|
|
AllowedIPs: []netip.Prefix{
|
|
gwHost,
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
netip.MustParsePrefix("::/0"),
|
|
},
|
|
Hostinfo: (&tailcfg.Hostinfo{
|
|
Hostname: "mullvad-test",
|
|
}).View(),
|
|
}
|
|
cs := env.ControlServer()
|
|
cs.UpdateNode(mullvadNode)
|
|
|
|
// Set the per-peer source-IP masquerade. The control plane normally
|
|
// derives this from the Mullvad API's per-client registration; here
|
|
// we just pin it to the address mullvad's wg0 was told to accept.
|
|
cs.SetMasqueradeAddresses([]testcontrol.MasqueradePair{{
|
|
Node: clientStatus.Self.PublicKey,
|
|
Peer: mullvadPub,
|
|
NodeMasqueradesAs: clientMasq.Addr(),
|
|
}})
|
|
injectStep.End(nil)
|
|
|
|
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
|
|
check := func(step *vmtest.Step, label, wantSrc string) {
|
|
t.Helper()
|
|
step.Begin()
|
|
body := env.HTTPGet(client, webURL)
|
|
t.Logf("[%s] response: %s", label, body)
|
|
if !strings.Contains(body, "Hello world I am webserver") {
|
|
step.End(fmt.Errorf("[%s] unexpected webserver response: %q", label, body))
|
|
t.Fatalf("[%s] unexpected webserver response: %q", label, body)
|
|
}
|
|
if !strings.Contains(body, "from "+wantSrc) {
|
|
step.End(fmt.Errorf("[%s] expected source %q in response, got %q", label, wantSrc, body))
|
|
t.Fatalf("[%s] expected source %q in response, got %q", label, wantSrc, body)
|
|
}
|
|
step.End(nil)
|
|
}
|
|
|
|
// Exit-node off: client routes 0.0.0.0/0 directly via its host stack,
|
|
// so the webserver sees client's WAN IP.
|
|
check(checkOff1Step, "exit-off", clientWAN)
|
|
|
|
// Switch to the Mullvad WG-only peer as exit node. The client should
|
|
// now route 0.0.0.0/0 through the WG tunnel; mullvad MASQUERADEs to
|
|
// its WAN; the webserver sees the mullvad VM's WAN IP.
|
|
env.SetExitNodeIP(client, gw.Addr())
|
|
check(checkMullvadStep, "exit-mullvad", mullvadWAN)
|
|
|
|
// And back off again, to make sure the transition works in both
|
|
// directions.
|
|
env.SetExitNodeIP(client, netip.Addr{})
|
|
check(checkOff2Step, "exit-off (again)", clientWAN)
|
|
}
|