diff --git a/.github/workflows/natlab-test.yml b/.github/workflows/natlab-test.yml
index 4bbd8e1ad..16412a87e 100644
--- a/.github/workflows/natlab-test.yml
+++ b/.github/workflows/natlab-test.yml
@@ -131,7 +131,7 @@ jobs:
# compileBinariesForOS in vmtest.go.
- name: Warm Go build cache
run: |
- ./tool/go test -c -o /dev/null ./tstest/natlab/vmtest ./tstest/integration/nat
+ ./tool/go test -c -o /dev/null ./tstest/natlab/vmtest
for pkg in tta tailscale tailscaled; do
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o /dev/null ./cmd/"$pkg"
done
@@ -148,9 +148,9 @@ jobs:
# single-test-per-matrix-job model. They stay runnable locally.
run: |
set -euo pipefail
- exclude='^(TestGrid|TestVnetPerf.*)$'
+ exclude='^(TestGrid|TestPair|TestVnetPerf.*)$'
tmp=$(mktemp)
- for pkg_dir in tstest/natlab/vmtest tstest/integration/nat; do
+ for pkg_dir in tstest/natlab/vmtest; do
pkg="./${pkg_dir}/"
for f in "${pkg_dir}"/*_test.go; do
[ -e "$f" ] || continue
diff --git a/tstest/integration/nat/nat_test.go b/tstest/integration/nat/nat_test.go
deleted file mode 100644
index 520d1f093..000000000
--- a/tstest/integration/nat/nat_test.go
+++ /dev/null
@@ -1,699 +0,0 @@
-// Copyright (c) Tailscale Inc & contributors
-// SPDX-License-Identifier: BSD-3-Clause
-
-package nat
-
-import (
- "bytes"
- "cmp"
- "context"
- "encoding/json"
- "errors"
- "flag"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/netip"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "sync"
- "testing"
- "time"
-
- "golang.org/x/mod/modfile"
- "golang.org/x/sync/errgroup"
- "tailscale.com/client/tailscale"
- "tailscale.com/ipn/ipnstate"
- "tailscale.com/syncs"
- "tailscale.com/tailcfg"
- "tailscale.com/tstest/natlab/vnet"
-)
-
-var (
- runVMTests = flag.Bool("run-vm-tests", false, "run tests that require a VM")
- logTailscaled = flag.Bool("log-tailscaled", false, "log tailscaled output")
- pcapFile = flag.String("pcap", "", "write pcap to file")
-)
-
-type natTest struct {
- tb testing.TB
- base string // base image
- tempDir string // for qcow2 images
- vnet *vnet.Server
- kernel string // linux kernel path
-
- gotRoute pingRoute
-}
-
-func newNatTest(tb testing.TB) *natTest {
- root, err := os.Getwd()
- if err != nil {
- tb.Fatal(err)
- }
- modRoot := filepath.Join(root, "../../..")
-
- nt := &natTest{
- tb: tb,
- tempDir: tb.TempDir(),
- base: filepath.Join(modRoot, "gokrazy/natlabapp.qcow2"),
- }
-
- if !*runVMTests {
- tb.Skip("skipping heavy test; set --run-vm-tests to run")
- }
-
- if _, err := os.Stat(nt.base); err != nil {
- if !os.IsNotExist(err) {
- tb.Fatal(err)
- }
- tb.Logf("building VM image...")
- cmd := exec.Command("make", "natlab")
- cmd.Dir = filepath.Join(modRoot, "gokrazy")
- cmd.Stderr = os.Stderr
- cmd.Stdout = os.Stdout
- if err := cmd.Run(); err != nil {
- tb.Fatalf("Error running 'make natlab' in gokrazy directory: %v", err)
- }
- if _, err := os.Stat(nt.base); err != nil {
- tb.Skipf("still can't find VM image: %v", err)
- }
- }
-
- nt.kernel, err = findKernelPath(filepath.Join(modRoot, "go.mod"))
- if err != nil {
- tb.Skipf("skipping test; kernel not found: %v", err)
- }
- tb.Logf("found kernel: %v", nt.kernel)
-
- return nt
-}
-
-func findKernelPath(goMod string) (string, error) {
- b, err := os.ReadFile(goMod)
- if err != nil {
- return "", err
- }
- mf, err := modfile.Parse("go.mod", b, nil)
- if err != nil {
- return "", err
- }
- goModB, err := exec.Command("go", "env", "GOMODCACHE").CombinedOutput()
- if err != nil {
- return "", err
- }
- for _, r := range mf.Require {
- if r.Mod.Path == "github.com/gokrazy/kernel.amd64" {
- return strings.TrimSpace(string(goModB)) + "/" + r.Mod.String() + "/vmlinuz", nil
- }
- }
- return "", fmt.Errorf("failed to find kernel in %v", goMod)
-}
-
-type addNodeFunc func(c *vnet.Config) *vnet.Node // returns nil to omit test
-
-func easy(c *vnet.Config) *vnet.Node {
- n := c.NumNodes() + 1
- return c.AddNode(c.AddNetwork(
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT))
-}
-
-func easyAF(c *vnet.Config) *vnet.Node {
- n := c.NumNodes() + 1
- return c.AddNode(c.AddNetwork(
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyAFNAT))
-}
-
-func sameLAN(c *vnet.Config) *vnet.Node {
- nw := c.FirstNetwork()
- if nw == nil {
- return nil
- }
- if !nw.CanTakeMoreNodes() {
- return nil
- }
- return c.AddNode(nw)
-}
-
-func one2one(c *vnet.Config) *vnet.Node {
- n := c.NumNodes() + 1
- return c.AddNode(c.AddNetwork(
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- fmt.Sprintf("172.16.%d.1/24", n), vnet.One2OneNAT))
-}
-
-func easyPMP(c *vnet.Config) *vnet.Node {
- n := c.NumNodes() + 1
- return c.AddNode(c.AddNetwork(
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT, vnet.NATPMP))
-}
-
-func hard(c *vnet.Config) *vnet.Node {
- n := c.NumNodes() + 1
- return c.AddNode(c.AddNetwork(
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- fmt.Sprintf("10.0.%d.1/24", n), vnet.HardNAT))
-}
-
-func hardPMP(c *vnet.Config) *vnet.Node {
- n := c.NumNodes() + 1
- return c.AddNode(c.AddNetwork(
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- fmt.Sprintf("10.7.%d.1/24", n), vnet.HardNAT, vnet.NATPMP))
-}
-
-func (nt *natTest) setupTest(ctx context.Context, addNode ...addNodeFunc) (nodes []*vnet.Node, clients []*vnet.NodeAgentClient, cleanup func()) {
- if len(addNode) < 1 || len(addNode) > 2 {
- nt.tb.Fatalf("runTest: invalid number of nodes %v; want 1 or 2", len(addNode))
- }
- t := nt.tb
-
- var c vnet.Config
- c.SetPCAPFile(*pcapFile)
- for _, fn := range addNode {
- node := fn(&c)
- if node == nil {
- t.Skip("skipping test; not applicable combination")
- }
- nodes = append(nodes, node)
- if *logTailscaled {
- node.SetVerboseSyslog(true)
- }
- }
-
- var err error
- nt.vnet, err = vnet.New(&c)
- if err != nil {
- t.Fatalf("newServer: %v", err)
- }
- nt.tb.Cleanup(func() {
- nt.vnet.Close()
- })
-
- var wg sync.WaitGroup // waiting for srv.Accept goroutine
- defer wg.Wait()
-
- sockAddr := filepath.Join(nt.tempDir, "qemu.sock")
- srv, err := net.Listen("unix", sockAddr)
- if err != nil {
- t.Fatalf("Listen: %v", err)
- }
- defer srv.Close()
-
- wg.Go(func() {
- for {
- c, err := srv.Accept()
- if err != nil {
- return
- }
- go nt.vnet.ServeUnixConn(c.(*net.UnixConn), vnet.ProtocolQEMU)
- }
- })
-
- haveKVM := false
- if runtime.GOOS == "linux" {
- if f, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0); err == nil {
- f.Close()
- haveKVM = true
- }
- }
-
- qmpSocks := make([]string, len(nodes))
- for i, node := range nodes {
- disk := fmt.Sprintf("%s/node-%d.qcow2", nt.tempDir, i)
- out, err := exec.Command("qemu-img", "create",
- "-f", "qcow2",
- "-F", "qcow2",
- "-b", nt.base,
- disk).CombinedOutput()
- if err != nil {
- t.Fatalf("qemu-img create: %v, %s", err, out)
- }
-
- var envBuf bytes.Buffer
- for _, e := range node.Env() {
- fmt.Fprintf(&envBuf, " tailscaled.env=%s=%s", e.Key, e.Value)
- }
- sysLogAddr := net.JoinHostPort(vnet.FakeSyslogIPv4().String(), "995")
- if node.IsV6Only() {
- fmt.Fprintf(&envBuf, " tta.nameserver=%s", vnet.FakeDNSIPv6())
- sysLogAddr = net.JoinHostPort(vnet.FakeSyslogIPv6().String(), "995")
- }
- envStr := envBuf.String()
-
- qmpSocks[i] = fmt.Sprintf("%s/qmp-node-%d.sock", nt.tempDir, i)
- qemuArgs := []string{
- "-M", "microvm,isa-serial=off",
- "-m", "384M",
- "-nodefaults", "-no-user-config", "-nographic",
- "-kernel", nt.kernel,
- "-append", "console=hvc0 root=PARTUUID=60c24cc1-f3f9-427a-8199-76baa2d60001/PARTNROFF=1 ro init=/gokrazy/init panic=10 oops=panic pci=off nousb gokrazy.remote_syslog.target=" + sysLogAddr + " tailscale-tta=1" + envStr,
- "-drive", "id=blk0,file=" + disk + ",format=qcow2",
- "-device", "virtio-blk-device,drive=blk0",
- "-netdev", "stream,id=net0,addr.type=unix,addr.path=" + sockAddr,
- "-device", "virtio-serial-device",
- "-device", "virtio-rng-device",
- "-device", "virtio-net-device,netdev=net0,mac=" + node.MAC().String(),
- "-chardev", "stdio,id=virtiocon0,mux=on",
- "-device", "virtconsole,chardev=virtiocon0",
- "-mon", "chardev=virtiocon0,mode=readline",
- "-qmp", "unix:" + qmpSocks[i] + ",server=on,wait=off",
- }
- if haveKVM {
- qemuArgs = append(qemuArgs, "-enable-kvm", "-cpu", "host")
- }
- cmd := exec.Command("qemu-system-x86_64", qemuArgs...)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- if err := cmd.Start(); err != nil {
- t.Fatalf("qemu: %v", err)
- }
- nt.tb.Cleanup(func() {
- cmd.Process.Kill()
- cmd.Wait()
- })
- }
-
- for i, node := range nodes {
- if err := nt.vnet.AwaitFirstPacket(ctx, node.MAC()); err != nil {
- t.Logf("node %v: no boot progress (no packets received): %v", node, err)
- t.Logf("node %v: QMP status: %s", node, qmpQueryStatus(qmpSocks[i]))
- t.FailNow()
- }
- t.Logf("node %v: boot detected (first packet received)", node)
- }
-
- for _, n := range nodes {
- client := nt.vnet.NodeAgentClient(n)
- n.SetClient(client)
- clients = append(clients, client)
- }
-
- var eg errgroup.Group
- for i, c := range clients {
- eg.Go(func() error {
- node := nodes[i]
- t.Logf("%v calling Status...", node)
- st, err := c.Status(ctx)
- if err != nil {
- return fmt.Errorf("%v status: %w", node, err)
- }
- t.Logf("%v status: %v", node, st.BackendState)
-
- if node.HostFirewall() {
- if err := c.EnableHostFirewall(ctx); err != nil {
- return fmt.Errorf("%v firewall: %w", node, err)
- }
- t.Logf("%v firewalled", node)
- }
-
- if node.ShouldJoinTailnet() {
- if err := up(ctx, c); err != nil {
- return fmt.Errorf("%v up: %w", node, err)
- }
- t.Logf("%v up!", node)
-
- st, err = c.Status(ctx)
- if err != nil {
- return fmt.Errorf("%v status: %w", node, err)
- }
-
- if capMap := node.WantCapMap(); capMap != nil {
- nt.tb.Logf("using capmap for %s: %+v", node.String(), capMap)
- nt.vnet.ControlServer().SetNodeCapMap(st.Self.PublicKey, capMap)
- }
-
- if st.BackendState != "Running" {
- return fmt.Errorf("%v state = %q", node, st.BackendState)
- }
-
- t.Logf("%v AllowedIPs: %v", node, st.Self.Addrs)
- t.Logf("%v up with %v", node, st.Self.TailscaleIPs)
- } else {
- t.Logf("%v skipping joining tailnet", node)
- }
- return nil
- })
- }
- if err := eg.Wait(); err != nil {
- t.Fatalf("initial setup: %v", err)
- }
-
- return nodes, clients, nt.vnet.Close
-}
-
-type hasDeadline interface {
- Deadline() (deadline time.Time, ok bool)
-}
-
-// testContext returns a context derived from the test's deadline (from -timeout),
-// leaving a small margin for cleanup. Falls back to 60s if no deadline is set.
-func testContext(tb testing.TB) (context.Context, context.CancelFunc) {
- if t, ok := tb.(hasDeadline); ok {
- if dl, ok := t.Deadline(); ok {
- const margin = 5 * time.Second
- return context.WithDeadline(context.Background(), dl.Add(-margin))
- }
- }
- return context.WithTimeout(context.Background(), 60*time.Second)
-}
-
-func (nt *natTest) runTailscaleConnectivityTest(addNode ...addNodeFunc) pingRoute {
- ctx, cancel := testContext(nt.tb)
- defer cancel()
-
- nodes, clients, cleanup := nt.setupTest(ctx, addNode...)
- defer cleanup()
- t := nt.tb
-
- if len(nodes) < 2 {
- return ""
- }
- for _, n := range nodes {
- if !n.ShouldJoinTailnet() {
- t.Logf("%v did not join tailnet", n)
- return ""
- }
- }
-
- sts := make([]*ipnstate.Status, len(nodes))
- var eg errgroup.Group
- for i, c := range clients {
- eg.Go(func() error {
- node := nodes[i]
- st, err := c.Status(ctx)
- if err != nil {
- return fmt.Errorf("%v: %w", node, err)
- }
- sts[i] = st
- return nil
- })
- }
- if err := eg.Wait(); err != nil {
- t.Fatalf("get node statuses: %v", err)
- }
-
- preICMPPing := false
- for _, node := range nodes {
- node.Network().PostConnectedToControl()
- if err := node.PostConnectedToControl(ctx); err != nil {
- t.Fatalf("post control error: %s", err)
- }
- if node.PreICMPPing() {
- preICMPPing = true
- }
- }
-
- // Should we send traffic across the nodes before starting disco?
- // For nodes that rotated disco keys after control going away.
- if preICMPPing {
- _, err := ping(ctx, t, clients[0], sts[1].Self.TailscaleIPs[0], tailcfg.PingICMP)
- if err != nil {
- t.Fatalf("ICMP ping failure: %v", err)
- }
- }
-
- pingRes, err := ping(ctx, t, clients[0], sts[1].Self.TailscaleIPs[0], tailcfg.PingDisco)
- if err != nil {
- t.Logf("ping failure: %v", err)
- }
- nt.gotRoute = classifyPing(pingRes)
- t.Logf("ping route: %v", nt.gotRoute)
-
- return nt.gotRoute
-}
-
-func classifyPing(pr *ipnstate.PingResult) pingRoute {
- if pr == nil {
- return routeNil
- }
- if pr.Endpoint != "" {
- ap, err := netip.ParseAddrPort(pr.Endpoint)
- if err == nil {
- if ap.Addr().IsPrivate() {
- return routeLocal
- }
- return routeDirect
- }
- }
- return routeDERP // presumably
-}
-
-type pingRoute string
-
-const (
- routeDERP pingRoute = "derp"
- routeLocal pingRoute = "local"
- routeDirect pingRoute = "direct"
- routeNil pingRoute = "nil" // *ipnstate.PingResult is nil
-)
-
-func ping(ctx context.Context, t testing.TB, c *vnet.NodeAgentClient, target netip.Addr, pType tailcfg.PingType) (*ipnstate.PingResult, error) {
- var lastRes *ipnstate.PingResult
- for n := range 10 {
- t.Logf("ping attempt %d to %v ...", n+1, target)
- pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
- pr, err := c.PingWithOpts(pingCtx, target, pType, tailscale.PingOpts{})
- cancel()
- if err != nil {
- t.Logf("ping attempt %d error: %v", n+1, err)
- if ctx.Err() != nil {
- break
- }
- continue
- }
- if pr.Err != "" {
- return nil, errors.New(pr.Err)
- }
- t.Logf("ping attempt %d: derp=%d endpoint=%v latency=%v", n+1, pr.DERPRegionID, pr.Endpoint, pr.LatencySeconds)
- if pr.DERPRegionID == 0 {
- return pr, nil
- }
- lastRes = pr
- select {
- case <-ctx.Done():
- return lastRes, nil
- case <-time.After(time.Second):
- }
- }
- if lastRes != nil {
- return lastRes, nil
- }
- return nil, fmt.Errorf("no ping response (ctx: %v)", ctx.Err())
-}
-
-// qmpQueryStatus connects to a QEMU QMP socket and returns the VM status
-// (e.g. "running", "paused", "prelaunch") or an error string.
-func qmpQueryStatus(sockPath string) string {
- conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
- if err != nil {
- return fmt.Sprintf("dial error: %v", err)
- }
- defer conn.Close()
- conn.SetDeadline(time.Now().Add(5 * time.Second))
- dec := json.NewDecoder(conn)
-
- // Read QMP greeting.
- var greeting json.RawMessage
- if err := dec.Decode(&greeting); err != nil {
- return fmt.Sprintf("greeting error: %v", err)
- }
-
- // Enter command mode.
- if _, err := conn.Write([]byte(`{"execute":"qmp_capabilities"}` + "\n")); err != nil {
- return fmt.Sprintf("write caps: %v", err)
- }
- var capsResp json.RawMessage
- if err := dec.Decode(&capsResp); err != nil {
- return fmt.Sprintf("caps response: %v", err)
- }
-
- // Query status.
- if _, err := conn.Write([]byte(`{"execute":"query-status"}` + "\n")); err != nil {
- return fmt.Sprintf("write query-status: %v", err)
- }
- var statusResp struct {
- Return struct {
- Running bool `json:"running"`
- Status string `json:"status"`
- } `json:"return"`
- Error *struct {
- Class string `json:"class"`
- Desc string `json:"desc"`
- } `json:"error"`
- }
- if err := dec.Decode(&statusResp); err != nil {
- return fmt.Sprintf("status response: %v", err)
- }
- if statusResp.Error != nil {
- return fmt.Sprintf("qmp error: %s: %s", statusResp.Error.Class, statusResp.Error.Desc)
- }
- return fmt.Sprintf("status=%s running=%v", statusResp.Return.Status, statusResp.Return.Running)
-}
-
-func up(ctx context.Context, c *vnet.NodeAgentClient) error {
- req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/up", nil)
- if err != nil {
- return err
- }
- res, err := c.HTTPClient.Do(req)
- if err != nil {
- return err
- }
- defer res.Body.Close()
- all, _ := io.ReadAll(res.Body)
- if res.StatusCode != 200 {
- return fmt.Errorf("unexpected status code %v: %s", res.Status, all)
- }
- return nil
-}
-
-type nodeType struct {
- name string
- fn addNodeFunc
-}
-
-var types = []nodeType{
- {"easy", easy},
- {"easyAF", easyAF},
- {"hard", hard},
- {"easyPMP", easyPMP},
- {"hardPMP", hardPMP},
- {"one2one", one2one},
- {"sameLAN", sameLAN},
- {"cgnat", cgnatNoTailnet},
-}
-
-func cgnatNoTailnet(c *vnet.Config) *vnet.Node {
- n := c.NumNodes() + 1
- return c.AddNode(c.AddNetwork(
- fmt.Sprintf("100.65.%d.1/16", n),
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- vnet.EasyNAT),
- vnet.DontJoinTailnet)
-}
-
-var pair = flag.String("pair", "", "comma-separated pair of types to test (easy, easyAF, hard, easyPMP, hardPMP, one2one, sameLAN)")
-
-func TestPair(t *testing.T) {
- t1, t2, ok := strings.Cut(*pair, ",")
- if !ok {
- t.Skipf("skipping test without --pair=type1,type2 set")
- }
- find := func(name string) addNodeFunc {
- for _, nt := range types {
- if nt.name == name {
- return nt.fn
- }
- }
- t.Fatalf("unknown type %q", name)
- return nil
- }
-
- nt := newNatTest(t)
- nt.runTailscaleConnectivityTest(find(t1), find(t2))
-}
-
-var runGrid = flag.Bool("run-grid", false, "run grid test")
-
-func TestGrid(t *testing.T) {
- if !*runGrid {
- t.Skip("skipping grid test; set --run-grid to run")
- }
- t.Parallel()
-
- sem := syncs.NewSemaphore(2)
- var (
- mu sync.Mutex
- res = make(map[string]pingRoute)
- )
- for _, a := range types {
- for _, b := range types {
- key := a.name + "-" + b.name
- keyBack := b.name + "-" + a.name
- t.Run(key, func(t *testing.T) {
- t.Parallel()
-
- sem.Acquire()
- defer sem.Release()
-
- filename := key + ".cache"
- contents, _ := os.ReadFile(filename)
- if len(contents) == 0 {
- filename2 := keyBack + ".cache"
- contents, _ = os.ReadFile(filename2)
- }
- route := pingRoute(strings.TrimSpace(string(contents)))
-
- if route == "" {
- nt := newNatTest(t)
- route = nt.runTailscaleConnectivityTest(a.fn, b.fn)
- if err := os.WriteFile(filename, []byte(string(route)), 0666); err != nil {
- t.Fatalf("writeFile: %v", err)
- }
- }
-
- mu.Lock()
- defer mu.Unlock()
- res[key] = route
- t.Logf("results: %v", res)
- })
- }
- }
-
- t.Cleanup(func() {
- mu.Lock()
- defer mu.Unlock()
- var hb bytes.Buffer
- pf := func(format string, args ...any) {
- fmt.Fprintf(&hb, format, args...)
- }
- rewrite := func(s string) string {
- return strings.ReplaceAll(s, "PMP", "+pm")
- }
- pf("
")
- pf(" | ")
- for _, a := range types {
- pf("%s | ", rewrite(a.name))
- }
- pf("
\n")
-
- for _, a := range types {
- if a.name == "sameLAN" {
- continue
- }
- pf("| %s | ", rewrite(a.name))
- for _, b := range types {
- key := a.name + "-" + b.name
- key2 := b.name + "-" + a.name
- v := cmp.Or(res[key], res[key2], "-")
- if v == "derp" {
- pf("%s | ", v)
- } else if v == "local" {
- pf("%s | ", v)
- } else {
- pf("%s | ", v)
- }
- }
- pf("
\n")
- }
- pf("
")
- pf("easy: Endpoint-Independent Mapping, Address and Port-Dependent Filtering (e.g. Linux, Google Wifi, Unifi, eero)
")
- pf("easyAF: Endpoint-Independent Mapping, Address-Dependent Filtering (James says telephony things or Zyxel type things)
")
- pf("hard: Address and Port-Dependent Mapping, Address and Port-Dependent Filtering (FreeBSD, OPNSense, pfSense)
")
- pf("one2one: One-to-One NAT (e.g. an EC2 instance with a public IPv4)
")
- pf("x+pm: x, with port mapping (NAT-PMP, PCP, UPnP, etc)
")
- pf("sameLAN: a second node in the same LAN as the first
")
- pf("")
-
- if err := os.WriteFile("grid.html", hb.Bytes(), 0666); err != nil {
- t.Fatalf("writeFile: %v", err)
- }
- })
-}
diff --git a/tstest/natlab/vmtest/connectivity.go b/tstest/natlab/vmtest/connectivity.go
index 60d6e0e36..04ac3e511 100644
--- a/tstest/natlab/vmtest/connectivity.go
+++ b/tstest/natlab/vmtest/connectivity.go
@@ -8,23 +8,50 @@ import (
"time"
)
-// AddNodeFunc is used to describe a func passed to [RunConnectivityTest].
+// AddNodeFunc is used to describe a func passed to [RunConnectivityTestExpect].
type AddNodeFunc func(*Env) *Node
-// RunConnectivityTest adds the specified nodes to the network and then
+// RunConnectivityTestExpect adds the specified nodes to the network and then
// verifies that a Disco ping from n1 to n2 completes within 30 seconds.
-func (env *Env) RunConnectivityTest(name string, pingRoute PingRoute, n1, n2 AddNodeFunc) {
- n1(env)
- n2(env)
+func (e *Env) RunConnectivityTestExpect(name string, pingRoute PingRoute, n1, n2 AddNodeFunc) {
+ node1 := n1(e)
+ node2 := n2(e)
- discoPingStep := env.AddStep(
+ discoPingStep := e.AddStep(
fmt.Sprintf("[%s] Ping a → b Disco (want %s)", name, pingRoute))
- env.Start()
+ e.Start()
discoPingStep.Begin()
- if err := env.PingExpect(env.nodes[0], env.nodes[1], pingRoute, 30*time.Second); err != nil {
+ if err := e.PingExpect(node1, node2, pingRoute, 30*time.Second); err != nil {
discoPingStep.End(err)
- env.t.Error(err)
+ e.t.Error(err)
+ return
}
discoPingStep.End(nil)
}
+
+// RunConnectivityTest adds the specified nodes to the network and then
+// verifies that a Disco ping from n1 to n2 completes within 30 seconds.
+func (e *Env) RunConnectivityTest(name string, n1, n2 AddNodeFunc) PingRoute {
+ e.t.Helper()
+ node1 := n1(e)
+ node2 := n2(e)
+ if node1 == nil || node2 == nil {
+ e.t.Skip("skipping test; not applicable combination")
+ }
+
+ discoPingStep := e.AddStep(
+ fmt.Sprintf("[%s] Ping a → b Disco", name))
+ e.Start()
+
+ discoPingStep.Begin()
+ pRes, err := e.PingSettle(node1, node2, 10*time.Second)
+ if err != nil {
+ discoPingStep.End(err)
+ e.t.Error(err)
+ return PingRouteNil
+ }
+ pRoute := classifyPing(pRes)
+ discoPingStep.End(nil)
+ return pRoute
+}
diff --git a/tstest/natlab/vmtest/connectivity_test.go b/tstest/natlab/vmtest/connectivity_test.go
index 8085655cf..e777e9808 100644
--- a/tstest/natlab/vmtest/connectivity_test.go
+++ b/tstest/natlab/vmtest/connectivity_test.go
@@ -4,9 +4,16 @@
package vmtest_test
import (
+ "bytes"
+ "cmp"
+ "flag"
"fmt"
+ "os"
+ "strings"
+ "sync"
"testing"
+ "tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tstest/natlab/vmtest"
@@ -17,19 +24,27 @@ func v6cidr(n int) string {
return fmt.Sprintf("2000:%d::1/64", n)
}
-func easy(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
- env.AddNetwork(
+func easy(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT),
vmtest.OS(vmtest.Gokrazy))
}
-func easyAnd6(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
- env.AddNetwork(
+func easyAF(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
+ fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
+ fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyAFNAT))
+}
+
+func easyAnd6(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n),
v6cidr(n),
@@ -37,74 +52,73 @@ func easyAnd6(env *vmtest.Env) *vmtest.Node {
vmtest.OS(vmtest.Gokrazy))
}
-// easyNoControlDiscoRotate sets up a node with easy NAT, cuts traffic to
-// control after connecting, and then rotates the disco key to simulate a newly
-// started node (from a disco perspective).
-func easyNoControlDiscoRotate(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- nw := env.AddNetwork(
- fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
- fmt.Sprintf("192.168.%d.1/24", n),
- vnet.EasyNAT)
- nw.SetPostConnectControlBlackhole(true)
- return env.AddNode(fmt.Sprintf("node-%d", n),
- vnet.TailscaledEnv{Key: "TS_USE_CACHED_NETMAP", Value: "true"},
- vnet.RotateDisco, vnet.PreICMPPing,
- nw,
- vmtest.OS(vmtest.Gokrazy))
-}
-
// easyFW is easy + host firewall.
-func easyFW(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
+func easyFW(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
vnet.HostFirewall,
- env.AddNetwork(
+ e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT),
vmtest.OS(vmtest.Gokrazy))
}
// easyPMPFWPlusBPF is easy + port mapping + host firewall + BPF.
-func easyPMPFWPlusBPF(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
+func easyPMPFWPlusBPF(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
vnet.HostFirewall,
vnet.TailscaledEnv{Key: "TS_ENABLE_RAW_DISCO", Value: "true"},
vnet.TailscaledEnv{Key: "TS_DEBUG_RAW_DISCO", Value: "1"},
vnet.TailscaledEnv{Key: "TS_DEBUG_DISCO", Value: "1"},
vnet.TailscaledEnv{Key: "TS_LOG_VERBOSITY", Value: "2"},
- env.AddNetwork(
+ e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT, vnet.NATPMP),
vmtest.OS(vmtest.Gokrazy))
}
// easyPMPFWNoBPF is easy + port mapping + host firewall - BPF.
-func easyPMPFWNoBPF(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
+func easyPMPFWNoBPF(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
vnet.HostFirewall,
vnet.TailscaledEnv{Key: "TS_ENABLE_RAW_DISCO", Value: "false"},
- env.AddNetwork(
+ e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT, vnet.NATPMP),
vmtest.OS(vmtest.Gokrazy))
}
-func hard(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
- env.AddNetwork(
+func hard(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("10.0.%d.1/24", n), vnet.HardNAT),
vmtest.OS(vmtest.Gokrazy))
}
-func hardNoDERPOrEndpoints(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
- env.AddNetwork(
+func easyPMP(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
+ fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
+ fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT, vnet.NATPMP))
+}
+
+func hardPMP(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
+ fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
+ fmt.Sprintf("10.7.%d.1/24", n), vnet.HardNAT, vnet.NATPMP))
+}
+
+func hardNoDERPOrEndpoints(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("10.0.%d.1/24", n), vnet.HardNAT),
vnet.TailscaledEnv{Key: "TS_DEBUG_STRIP_ENDPOINTS", Value: "1"},
@@ -112,64 +126,83 @@ func hardNoDERPOrEndpoints(env *vmtest.Env) *vmtest.Node {
vmtest.OS(vmtest.Gokrazy))
}
-func just6(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n),
- env.AddNetwork(v6cidr(n)), // public IPv6 prefix
+func one2one(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
+ fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
+ fmt.Sprintf("172.16.%d.1/24", n), vnet.One2OneNAT))
+}
+
+func sameLAN(e *vmtest.Env) *vmtest.Node {
+ nw := e.FirstNetwork()
+ if nw == nil {
+ return nil
+ }
+ if !nw.CanTakeMoreNodes() {
+ return nil
+ }
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n), nw)
+}
+
+func cgnat(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(
+ fmt.Sprintf("100.65.%d.1/16", n),
+ fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
+ vnet.EasyNAT))
+}
+
+func just6(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n),
+ e.AddNetwork(v6cidr(n)), // public IPv6 prefix
vmtest.OS(vmtest.Gokrazy))
}
-func v6AndBlackholedIPv4(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- nw := env.AddNetwork(
+func v6AndBlackholedIPv4(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ nw := e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n),
fmt.Sprintf("192.168.%d.1/24", n),
v6cidr(n),
vnet.EasyNAT)
nw.SetBlackholedIPv4(true)
- return env.AddNode(fmt.Sprintf("node-%d", n), nw, vmtest.OS(vmtest.Gokrazy))
+ return e.AddNode(fmt.Sprintf("node-%d", n), nw, vmtest.OS(vmtest.Gokrazy))
}
func TestEasyEasy(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDirect, easy, easy)
-}
-
-// TestTwoEasyNoControlDiscoRotate tests a situation where two nodes have been
-// online and connected through control, but then lose control access and also
-// rotate keys. It is not a perfect proxy for a cached node, as the node will
-// still have a mapState and not use the backup method of inserting keys into
-// the engine directly.
-func TestTwoEasyNoControlDiscoRotate(t *testing.T) {
- env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDirect, easyNoControlDiscoRotate, easyNoControlDiscoRotate)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDirect, easy, easy)
}
func TestJustIPv6(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDirect, just6, just6)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDirect, just6, just6)
}
func TestEasy4AndJust6(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDirect, easyAnd6, just6)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDirect, easyAnd6, just6)
}
func TestSameLAN(t *testing.T) {
env := vmtest.New(t)
var sharedNW *vnet.Network
- makeEasy := func(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- sharedNW = env.AddNetwork(
+ makeEasy := func(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ sharedNW = e.AddNetwork(
fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP
fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT)
- return env.AddNode(fmt.Sprintf("node-%d", n), sharedNW, vmtest.OS(vmtest.Gokrazy))
+ return e.AddNode(fmt.Sprintf("node-%d", n), sharedNW, vmtest.OS(vmtest.Gokrazy))
}
- sameLAN := func(env *vmtest.Env) *vmtest.Node {
- n := env.NumNodes()
- return env.AddNode(fmt.Sprintf("node-%d", n), sharedNW, vmtest.OS(vmtest.Gokrazy))
+ sameLAN := func(e *vmtest.Env) *vmtest.Node {
+ n := e.NumNodes()
+ return e.AddNode(fmt.Sprintf("node-%d", n), sharedNW, vmtest.OS(vmtest.Gokrazy))
}
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteLocal, makeEasy, sameLAN)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteLocal, makeEasy, sameLAN)
}
// TestBPFDisco tests https://github.com/tailscale/tailscale/issues/3824 ...
@@ -178,22 +211,22 @@ func TestSameLAN(t *testing.T) {
// * client machine has a stateful host firewall (e.g. ufw)
func TestBPFDisco(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDirect, easyPMPFWPlusBPF, hard)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDirect, easyPMPFWPlusBPF, hard)
}
func TestHostFWNoBPF(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDERP, easyPMPFWNoBPF, hard)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDERP, easyPMPFWNoBPF, hard)
}
func TestHostFWPair(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDirect, easyFW, easyFW)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDirect, easyFW, easyFW)
}
func TestOneHostFW(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDirect, easy, easyFW)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDirect, easy, easyFW)
}
// Issue tailscale/corp#26438: use learned DERP route as send path of last
@@ -212,7 +245,7 @@ func TestOneHostFW(t *testing.T) {
// packet over a particular DERP from that peer.
func TestFallbackDERPRegionForPeer(t *testing.T) {
env := vmtest.New(t)
- env.RunConnectivityTest(t.Name(), vmtest.PingRouteDERP, hard, hardNoDERPOrEndpoints)
+ env.RunConnectivityTestExpect(t.Name(), vmtest.PingRouteDERP, hard, hardNoDERPOrEndpoints)
}
// TestSingleJustIPv6 tests that a node can connect to control with just IPv6.
@@ -248,3 +281,140 @@ func TestNonTailscaleCGNATEndpoint(t *testing.T) {
env.Start()
env.LANPing(n1, n0.LanIP(cgnatNW))
}
+
+type nodeType struct {
+ name string
+ fn vmtest.AddNodeFunc
+}
+
+var types = []nodeType{
+ {"easy", easy},
+ {"easyAF", easyAF},
+ {"hard", hard},
+ {"easyPMP", easyPMP},
+ {"hardPMP", hardPMP},
+ {"one2one", one2one},
+ {"sameLAN", sameLAN},
+ {"cgnat", cgnat},
+}
+
+var pair = flag.String("pair", "", "comma-separated pair of types to test (easy, easyAF, hard, easyPMP, hardPMP, one2one, sameLAN)")
+
+func TestPair(t *testing.T) {
+ t1, t2, ok := strings.Cut(*pair, ",")
+ if !ok {
+ t.Skipf("skipping test without --pair=type1,type2 set")
+ }
+ find := func(name string) vmtest.AddNodeFunc {
+ for _, nt := range types {
+ if nt.name == name {
+ return nt.fn
+ }
+ }
+ t.Fatalf("unknown type %q", name)
+ return nil
+ }
+
+ env := vmtest.New(t)
+ route := env.RunConnectivityTest(t.Name(), find(t1), find(t2))
+ t.Logf("pair: got ping route: %s", route)
+}
+
+var runGrid = flag.Bool("run-grid", false, "run grid test")
+
+func TestGrid(t *testing.T) {
+ if !*runGrid {
+ t.Skip("skipping grid test; set --run-grid to run")
+ }
+ t.Parallel()
+
+ sem := syncs.NewSemaphore(2)
+ var (
+ mu sync.Mutex
+ res = make(map[string]vmtest.PingRoute)
+ )
+ for _, a := range types {
+ for _, b := range types {
+ key := a.name + "-" + b.name
+ keyBack := b.name + "-" + a.name
+ t.Run(key, func(t *testing.T) {
+ t.Parallel()
+
+ sem.Acquire()
+ defer sem.Release()
+
+ filename := key + ".cache"
+ contents, _ := os.ReadFile(filename)
+ if len(contents) == 0 {
+ filename2 := keyBack + ".cache"
+ contents, _ = os.ReadFile(filename2)
+ }
+ route := vmtest.PingRoute(strings.TrimSpace(string(contents)))
+
+ if route == "" {
+ env := vmtest.New(t)
+ route = env.RunConnectivityTest(
+ fmt.Sprintf("%s<->%s", a.name, b.name), a.fn, b.fn)
+ if err := os.WriteFile(filename, []byte(string(route)), 0o666); err != nil {
+ t.Fatalf("writeFile: %v", err)
+ }
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ res[key] = route
+ t.Logf("results: %v", res)
+ })
+ }
+ }
+
+ t.Cleanup(func() {
+ mu.Lock()
+ defer mu.Unlock()
+ var hb bytes.Buffer
+ pf := func(format string, args ...any) {
+ fmt.Fprintf(&hb, format, args...)
+ }
+ rewrite := func(s string) string {
+ return strings.ReplaceAll(s, "PMP", "+pm")
+ }
+ pf("")
+ pf(" | ")
+ for _, a := range types {
+ pf("%s | ", rewrite(a.name))
+ }
+ pf("
\n")
+
+ for _, a := range types {
+ if a.name == "sameLAN" {
+ continue
+ }
+ pf("| %s | ", rewrite(a.name))
+ for _, b := range types {
+ key := a.name + "-" + b.name
+ key2 := b.name + "-" + a.name
+ v := cmp.Or(res[key], res[key2], "-")
+ if v == "derp" {
+ pf("%s | ", v)
+ } else if v == "local" {
+ pf("%s | ", v)
+ } else {
+ pf("%s | ", v)
+ }
+ }
+ pf("
\n")
+ }
+ pf("
")
+ pf("easy: Endpoint-Independent Mapping, Address and Port-Dependent Filtering (e.g. Linux, Google Wifi, Unifi, eero)
")
+ pf("easyAF: Endpoint-Independent Mapping, Address-Dependent Filtering (James says telephony things or Zyxel type things)
")
+ pf("hard: Address and Port-Dependent Mapping, Address and Port-Dependent Filtering (FreeBSD, OPNSense, pfSense)
")
+ pf("one2one: One-to-One NAT (e.g. an EC2 instance with a public IPv4)
")
+ pf("x+pm: x, with port mapping (NAT-PMP, PCP, UPnP, etc)
")
+ pf("sameLAN: a second node in the same LAN as the first
")
+ pf("")
+
+ if err := os.WriteFile("grid.html", hb.Bytes(), 0o666); err != nil {
+ t.Fatalf("writeFile: %v", err)
+ }
+ })
+}
diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go
index 69a46ca71..f1bf5b6af 100644
--- a/tstest/natlab/vmtest/vmtest.go
+++ b/tstest/natlab/vmtest/vmtest.go
@@ -437,6 +437,12 @@ func (e *Env) AddNetwork(opts ...any) *vnet.Network {
return e.cfg.AddNetwork(opts...)
}
+// FirstNetwork returns the first existing network. If no network exists, it
+// returns nil.
+func (e *Env) FirstNetwork() *vnet.Network {
+ return e.cfg.FirstNetwork()
+}
+
// RegisterFile registers a file with the vnet fileserver.
// It is served at http://files.tailscale/.
func (e *Env) RegisterFile(path string, data []byte) {
@@ -564,16 +570,7 @@ func (n *Node) LanIP(net *vnet.Network) netip.Addr {
return n.vnetNode.LanIP(net)
}
-// DropControlTraffic sets up a blackhole for control traffic for just this
-// node on all the networks belonging to the node.
-func (n *Node) DropControlTraffic() {
- for _, network := range n.nets {
- network.BlackholeControlForAddr(n.LanIP(network))
- }
-}
-
// NodeOption types for configuring nodes.
-
type nodeOptOS OSImage
type nodeOptNoTailscale struct{}
type nodeOptTailscaleSSH struct{}
@@ -2327,11 +2324,11 @@ func (e *Env) PingExpect(from, to *Node, wantRoute PingRoute, timeout time.Durat
pr, err := from.agent.PingWithOpts(pingCtx, targetIP, tailcfg.PingDisco, local.PingOpts{})
pingCancel()
if err == nil && pr.Err == "" {
- if got := classifyPing(pr); got == wantRoute {
- e.t.Logf("Saw ping type %q", got)
+ got := classifyPing(pr)
+ e.t.Logf("Saw ping type %q", got)
+ if got == wantRoute {
return nil
} else {
- e.t.Logf("Saw ping type %q", got)
lastRoute = got
}
}
@@ -2343,7 +2340,68 @@ func (e *Env) PingExpect(from, to *Node, wantRoute PingRoute, timeout time.Durat
return fmt.Errorf("ping route = %q, want %q (after %v)", lastRoute, wantRoute, timeout)
}
-// NumNodes returns the current number of nodes configured in the env.
-func (env *Env) NumNodes() int {
- return len(env.nodes)
+// PingSettle retries disco pings every 1 second between nodes from -> to. The
+// intention is to have the route settle into the desired state at ctx timeout,
+// making the last returned type the settled state of the connection. If the
+// connection is direct before the timeout, the method returns early.
+// If no ping has been completed, nil will be returned.
+func (e *Env) PingSettle(from, to *Node, timeout time.Duration) (*ipnstate.PingResult, error) {
+ e.t.Helper()
+ ctx, cancel := context.WithTimeout(e.t.Context(), timeout)
+ defer cancel()
+ toSt, err := to.agent.Status(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("ping: can't get %s status: %w", to.name, err)
+ }
+ if len(toSt.Self.TailscaleIPs) == 0 {
+ return nil, fmt.Errorf("ping: %s has no Tailscale IPs", to.name)
+ }
+ targetIP := toSt.Self.TailscaleIPs[0]
+ var lastRes *ipnstate.PingResult
+ n := 0
+ for ctx.Err() == nil {
+ n++
+ e.t.Logf("ping: attempt %d to %v ...", n, targetIP)
+ pingCtx, pingCancel := context.WithTimeout(ctx, 3*time.Second)
+ pr, err := from.agent.PingWithOpts(pingCtx, targetIP, tailcfg.PingDisco, local.PingOpts{})
+ pingCancel()
+ if err != nil {
+ e.t.Logf("ping: attempt %d error: %v", n, err)
+ if ctx.Err() != nil {
+ break
+ }
+ continue
+ }
+ if pr.Err != "" {
+ return nil, errors.New(pr.Err)
+ }
+ e.t.Logf("ping: attempt %d: derp=%d endpoint=%v latency=%v", n, pr.DERPRegionID, pr.Endpoint, pr.LatencySeconds)
+ // When DERP on the result is 0, we have settled onto a direct path.
+ if pr.DERPRegionID == 0 {
+ return pr, nil
+ }
+ lastRes = pr
+ select {
+ case <-ctx.Done():
+ return lastRes, nil
+ case <-time.After(time.Second):
+ }
+ }
+ if lastRes != nil {
+ return lastRes, nil
+ }
+ return nil, fmt.Errorf("ping: ping no response (ctx: %v)", ctx.Err())
+}
+
+// NumNodes returns the current number of nodes configured in the env.
+func (e *Env) NumNodes() int {
+ return len(e.nodes)
+}
+
+// DropControlTraffic sets up a blackhole for control traffic for just this
+// node on all the networks belonging to the node.
+func (e *Env) DropControlTraffic(n *Node) {
+ for _, network := range n.nets {
+ network.BlackholeControlForAddr(n.LanIP(network))
+ }
}
diff --git a/tstest/natlab/vmtest/vmtest_test.go b/tstest/natlab/vmtest/vmtest_test.go
index 16a80169c..d4dd0236c 100644
--- a/tstest/natlab/vmtest/vmtest_test.go
+++ b/tstest/natlab/vmtest/vmtest_test.go
@@ -1165,8 +1165,8 @@ func TestCachedNetmapAfterRestart(t *testing.T) {
cutControlStep.Begin()
// Both nodes lose connection to control
- a.DropControlTraffic()
- b.DropControlTraffic()
+ env.DropControlTraffic(a)
+ env.DropControlTraffic(b)
env.ControlServer().SetOnMapRequest(func(nk key.NodePublic) {
panic(fmt.Sprintf("got connection from %v", nk))
})
@@ -1261,7 +1261,7 @@ func TestDirectConnectionWithCachedNetmapOnOneNode(t *testing.T) {
checkInitialMetrics.End(nil)
cutControlStep.Begin()
- a.DropControlTraffic()
+ env.DropControlTraffic(a)
env.ControlServer().SetOnMapRequest(func(nk key.NodePublic) {
if env.ControlServer().Node(nk).Name == a.Name() {
panic(fmt.Sprintf("got connection from %v", a.Name()))
@@ -1347,8 +1347,8 @@ func TestDirectConnectionWithCachedNetmapOnTwoNodes(t *testing.T) {
checkInitialMetrics.End(nil)
cutControlStep.Begin()
- a.DropControlTraffic()
- b.DropControlTraffic()
+ env.DropControlTraffic(a)
+ env.DropControlTraffic(b)
env.ControlServer().SetOnMapRequest(func(nk key.NodePublic) {
nodeName := env.ControlServer().Node(nk).Name
if nodeName == a.Name() || nodeName == b.Name() {
diff --git a/tstest/natlab/vnet/conf.go b/tstest/natlab/vnet/conf.go
index 191de9e18..c56ad8ed7 100644
--- a/tstest/natlab/vnet/conf.go
+++ b/tstest/natlab/vnet/conf.go
@@ -5,7 +5,6 @@ package vnet
import (
"cmp"
- "context"
"fmt"
"iter"
"net/netip"
@@ -98,11 +97,11 @@ func nodeLANIP6(n int) netip.Addr {
// AddNode creates a new node in the world.
//
// The opts may be of the following types:
-// - *Network: zero, one, or more networks to add this node to
+// - [*Network]: zero, one, or more networks to add this node to
// - TODO: more
//
// On an error or unknown opt type, AddNode returns a
-// node with a carried error that gets returned later.
+// [Node] with a carried error that gets returned later.
func (c *Config) AddNode(opts ...any) *Node {
num := len(c.nodes) + 1
n := &Node{
@@ -123,10 +122,6 @@ func (c *Config) AddNode(opts ...any) *Node {
switch o {
case HostFirewall:
n.hostFW = true
- case RotateDisco:
- n.rotateDisco = true
- case PreICMPPing:
- n.preICMPPing = true
case DontJoinTailnet:
n.dontJoinTailnet = true
case VerboseSyslog:
@@ -154,8 +149,6 @@ type NodeOption string
const (
HostFirewall NodeOption = "HostFirewall"
- RotateDisco NodeOption = "RotateDisco"
- PreICMPPing NodeOption = "PreICMPPing"
DontJoinTailnet NodeOption = "DontJoinTailnet"
VerboseSyslog NodeOption = "VerboseSyslog"
)
@@ -172,8 +165,8 @@ type TailscaledEnv struct {
// - string IP address, for the network's WAN IP (if any)
// - string netip.Prefix, for the network's LAN IP (defaults to 192.168.0.0/24)
// if IPv4, or its WAN IPv6 + CIDR (e.g. "2000:52::1/64")
-// - NAT, the type of NAT to use
-// - NetworkService, a service to add to the network
+// - [NAT], the type of NAT to use
+// - [NetworkService], a service to add to the network
//
// On an error or unknown opt type, AddNetwork returns a
// network with a carried error that gets returned later.
@@ -224,8 +217,6 @@ type Node struct {
env []TailscaledEnv
hostFW bool
- rotateDisco bool
- preICMPPing bool
verboseSyslog bool
dontJoinTailnet bool
capMap tailcfg.NodeCapMap
@@ -292,29 +283,6 @@ func (n *Node) SetClient(c *NodeAgentClient) {
n.client = c
}
-// PostConnectedToControl should be called after the clients have connected to
-// control to modify the client behaviour after getting the network maps.
-// Currently, the only implemented behavior is rotating disco keys.
-func (n *Node) PostConnectedToControl(ctx context.Context) error {
- if n.rotateDisco {
- if err := n.client.DebugAction(ctx, "rotate-disco-key"); err != nil {
- return err
- }
- }
- return nil
-}
-
-// PreICMPPing reports whether node should send an ICMP Ping sent before
-// the disco ping. This is important for the nodes having rotated their
-// disco keys while control is down. Disco pings deliberately does not
-// trigger a TSMPDiscoKeyAdvertisement, making the need for other traffic (here
-// simlulated as an ICMP ping) needed first. Any traffic could trigger this key
-// exchange, the ICMP Ping is used as a handy existing way of sending some
-// non-disco traffic.
-func (n *Node) PreICMPPing() bool {
- return n.preICMPPing
-}
-
// ShouldJoinTailnet reports whether node should join the test tailnet. Machines in
// the virtual universe that aren't on the tailnet are useful for testing that
// Tailscale does not break connectivity to resources outside the tailnet.
@@ -384,7 +352,6 @@ type Network struct {
lanIP4 netip.Prefix
nodes []*Node
breakWAN4 bool // whether to break WAN IPv4 connectivity
- postConnectBlackholeControl bool // whether to break control connectivity after nodes have connected
network *network
svcs set.Set[NetworkService]
@@ -417,12 +384,6 @@ func (n *Network) SetBlackholedIPv4(v bool) {
n.breakWAN4 = v
}
-// SetPostConnectControlBlackhole sets whether the network should blackhole all
-// traffic to the control server after the clients have connected.
-func (n *Network) SetPostConnectControlBlackhole(v bool) {
- n.postConnectBlackholeControl = v
-}
-
func (n *Network) CanV4() bool {
return n.lanIP4.IsValid() || n.wanIP4.IsValid()
}
@@ -438,13 +399,6 @@ func (n *Network) CanTakeMoreNodes() bool {
return len(n.nodes) < 150
}
-// PostConnectedToControl should be called after the clients have connected to
-// the control server to modify network behaviors. Currently the only
-// implemented behavior is to conditionally blackhole traffic to control.
-func (n *Network) PostConnectedToControl() {
- n.network.SetControlBlackholed(n.postConnectBlackholeControl)
-}
-
// BlackholeControlForAddr sets weither the network should drop all control
// traffic for the specified addr starting immediately.
func (n *Network) BlackholeControlForAddr(addr netip.Addr) {
@@ -460,7 +414,7 @@ const (
UPnP NetworkService = "UPnP"
)
-// AddService adds a network service (such as port mapping protocols) to a
+// AddService adds a [NetworkService] (such as port mapping protocols) to a
// network.
func (n *Network) AddService(s NetworkService) {
if n.svcs == nil {
diff --git a/tstest/natlab/vnet/vnet.go b/tstest/natlab/vnet/vnet.go
index 580dd9a31..4a974d817 100644
--- a/tstest/natlab/vnet/vnet.go
+++ b/tstest/natlab/vnet/vnet.go
@@ -638,6 +638,7 @@ func (m MAC) HWAddr() net.HardwareAddr {
return net.HardwareAddr(m[:])
}
+// String returns the mac address as "xx:xx:xx:xx:xx:xx".
func (m MAC) String() string {
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", m[0], m[1], m[2], m[3], m[4], m[5])
}