b9eac14ef9
Add an optional --vmtest-web flag that starts an HTTP server showing a live dashboard for vmtest runs. The dashboard includes: - Step progress tracker showing all test phases (compile, image prep, QEMU launch, agent connect, tailscale up, test-specific steps) with status icons and elapsed times - Per-VM "virtual monitor" cards showing serial console output streamed in realtime via WebSocket - Per-NIC DHCP status (supporting multi-homed VMs like subnet routers) - Per-node Tailscale status (hidden for non-tailnet VMs) - Test status badge (Running/Passed/Failed) with live elapsed timer - Event log showing all lifecycle events chronologically Architecture follows the existing util/eventbus HTMX+WebSocket pattern: the server pushes HTML fragments with hx-swap-oob attributes over a WebSocket, and HTMX routes them to the correct DOM elements by ID. Key components: - vmstatus.go: Step tracker (Begin/End lifecycle), EventBus (pub/sub with history for late joiners), VMEvent types, NodeStatus tracking - web.go: HTTP server, WebSocket handler, template loading, ANSI-to-HTML conversion via robert-nix/ansihtml, deterministic port selection - assets/: HTML templates, CSS, HTMX library (copied from eventbus) - vnet/vnet.go: DHCP event callback on Server for observing DHCP lifecycle - qemu.go: Console log file tailing with manual offset-based reading Usage: go test ./tstest/natlab/vmtest/ --run-vm-tests --vmtest-web=:0 -v When using :0, a deterministic port based on the test name is tried first so re-runs get the same URL, falling back to OS-assigned on conflict. Updates #13038 Change-Id: I45281347b3d7af78ed9f4ff896033984f84dcb4d Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
444 lines
15 KiB
Go
444 lines
15 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))
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
}
|