mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-09 20:01:54 -04:00
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:
1 parent
eb79f3e33e
commit
b31be2cd1e
8 files changed
+372
-861
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user