tstest/natlab/{vnet,vmtest}: clean up natlab inconsitencies (#21483)

There were a handful of inconsitencies in natlab with the biggest being.
 - Pair and grid tests living under tstest/integration and using the old
   test style.
 - Inconsistent use of e vs env.
 - Methods for actions on node living on the vnet.Node rather than being
   a method on vmtest.Env

Clean up these inconsitencies.

Updates #cleanup

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
This commit is contained in:
Claus Lensbøl authored and GitHub committed 2026-09-29 14:57:10 -04:00
1 parent eb79f3e33e
commit b31be2cd1e
8 files changed
+372 -861

No files matched your search

+3 -3
View File
@@ -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
-699
View File
@@ -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("<html><table border=1 cellpadding=5>")
pf("<tr><td></td>")
for _, a := range types {
pf("<td><b>%s</b></td>", rewrite(a.name))
}
pf("</tr>\n")
for _, a := range types {
if a.name == "sameLAN" {
continue
}
pf("<tr><td><b>%s</b></td>", 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("<td><div style='color: red; font-weight: bold'>%s</div></td>", v)
} else if v == "local" {
pf("<td><div style='color: green; font-weight: bold'>%s</div></td>", v)
} else {
pf("<td>%s</td>", v)
}
}
pf("</tr>\n")
}
pf("</table>")
pf("<b>easy</b>: Endpoint-Independent Mapping, Address and Port-Dependent Filtering (e.g. Linux, Google Wifi, Unifi, eero)<br>")
pf("<b>easyAF</b>: Endpoint-Independent Mapping, Address-Dependent Filtering (James says telephony things or Zyxel type things)<br>")
pf("<b>hard</b>: Address and Port-Dependent Mapping, Address and Port-Dependent Filtering (FreeBSD, OPNSense, pfSense)<br>")
pf("<b>one2one</b>: One-to-One NAT (e.g. an EC2 instance with a public IPv4)<br>")
pf("<b>x+pm</b>: x, with port mapping (NAT-PMP, PCP, UPnP, etc)<br>")
pf("<b>sameLAN</b>: a second node in the same LAN as the first<br>")
pf("</html>")
if err := os.WriteFile("grid.html", hb.Bytes(), 0666); err != nil {
t.Fatalf("writeFile: %v", err)
}
})
}
+36 -9
View File
@@ -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
}
+249 -79
View File
@@ -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("<html><table border=1 cellpadding=5>")
pf("<tr><td></td>")
for _, a := range types {
pf("<td><b>%s</b></td>", rewrite(a.name))
}
pf("</tr>\n")
for _, a := range types {
if a.name == "sameLAN" {
continue
}
pf("<tr><td><b>%s</b></td>", 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("<td><div style='color: red; font-weight: bold'>%s</div></td>", v)
} else if v == "local" {
pf("<td><div style='color: green; font-weight: bold'>%s</div></td>", v)
} else {
pf("<td>%s</td>", v)
}
}
pf("</tr>\n")
}
pf("</table>")
pf("<b>easy</b>: Endpoint-Independent Mapping, Address and Port-Dependent Filtering (e.g. Linux, Google Wifi, Unifi, eero)<br>")
pf("<b>easyAF</b>: Endpoint-Independent Mapping, Address-Dependent Filtering (James says telephony things or Zyxel type things)<br>")
pf("<b>hard</b>: Address and Port-Dependent Mapping, Address and Port-Dependent Filtering (FreeBSD, OPNSense, pfSense)<br>")
pf("<b>one2one</b>: One-to-One NAT (e.g. an EC2 instance with a public IPv4)<br>")
pf("<b>x+pm</b>: x, with port mapping (NAT-PMP, PCP, UPnP, etc)<br>")
pf("<b>sameLAN</b>: a second node in the same LAN as the first<br>")
pf("</html>")
if err := os.WriteFile("grid.html", hb.Bytes(), 0o666); err != nil {
t.Fatalf("writeFile: %v", err)
}
})
}
+73 -15
View File
@@ -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/<path>.
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))
}
}
+5 -5
View File
@@ -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() {
+5 -51
View File
@@ -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 {
+1
View File
@@ -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])
}