Files
tailscale/tstest/integration/integration.go
T
Brad FitzpatrickandBrad Fitzpatrick 04ae61fe4b tstest/integration/jswasmtest: add headless-Chromium tests for @tailscale/connect
Add Go tests that drive a real headless Chromium (via chromedp) against
the built cmd/tsconnect/pkg/ artifact and verify the @tailscale/connect
public API surface end-to-end. The package has not been republished in
three years, in part because no test exercises the produced artifact at
runtime — only tsc --noEmit and a Go build run in CI.

TestCreateIPN loads pkg.js into the browser, calls createIPN with a junk
auth key, and asserts that pkg.createIPN / pkg.runSSHSession are
functions and that createIPN() returns an IPN with the documented
run/login/logout/ssh/fetch methods. No control-plane traffic.

TestFetchTailnetPeer stands up a full local tailnet (testcontrol +
DERP + a tsnet.Server peer) and verifies that the browser-side WASM
client can join over WebSocket-noise to the same control, connect to
DERP over WSS, and then ipn.fetch() an HTTP service hosted on the tsnet
peer through the tailnet. The test asserts the response body matches a
known string. Browser state transitions are logged: NoState -> NeedsLogin
-> Starting -> Running.

Tests are opt-in via --run-headless-browser-tests (matching the existing
--run-vm-tests pattern in tstest/natlab/vmtest) so they never fire in
casual `go test ./...` runs. When the flag is set, a test is skipped if
cmd/tsconnect/pkg/ has not been built, and fails with t.Error if no
chromium binary is found on $PATH (honoring $CHROME_BIN as an override).
findChromium also falls back to /Applications/Google Chrome.app and
/Applications/Chromium.app on darwin, since macOS Chrome's executable
lives inside an .app bundle and is not on $PATH by default. The
.github/workflows/test.yml wasm job is extended to install
google-chrome-stable and run the tests with the flag after build-pkg.

To prevent silently testing a stale pkg/main.wasm (built from an older
checkout than the rest of the test invocation), build-pkg now writes
pkg/build-info.json recording the sha256 of the raw (pre-wasm-opt)
go-build output. The test does its own `go build` of
cmd/tsconnect/wasm with the same -tags/-trimpath/-ldflags (factored
into a new cmd/tsconnect/wasmbuild package shared by both call sites)
and t.Fatalfs with a "rebuild" instruction on mismatch. Cost is
near-zero because the Go build cache from the prior build-pkg makes
the rebuild a cache hit.

The new wasmbuild package also replaces cmd/tsconnect's hardcoded -tags
string with a minimal-feature-set computation. wasmbuild.Keep names the
small set of feature/featuretags entries the browser client actually
needs (netstack, logtail, dns, health, c2n, ipnbus); wasmbuild.Tags()
emits a ts_omit_<f> for every other
omittable feature in feature/featuretags.Features, with transitive deps
expanded via featuretags.Requires. An init() panics if Keep references
a feature unknown to feature/featuretags so a rename there fails
loudly. Net effect on size: 32M raw / 9.4M brotli before this change,
25M raw / 4.4M brotli after — vs the last-published 1.39.98 at 21M /
3.8M. The transitive package-import graph is unchanged (176
tailscale.com/* packages either way): featuretags omits eliminate
dead code via `const HasX = false`, not imports. Trimming the import
graph would require a separate, larger refactor splitting interface
packages by build tag.

Writing TestFetchTailnetPeer surfaced several real issues, all fixed
here:

  * cmd/tsconnect built the wasm with the nethttpomithttp2 tag, but
    control/ts2021 (since commit 1d93bdce2, "control/controlclient:
    remove x/net/http2, use net/http", Oct 2025) requires HTTP/2 from
    net/http's bundled implementation. With nethttpomithttp2 set, the
    bundle is excluded and the wasm client cannot speak HTTP/2 to any
    control plane, including production. Drop the tag. Wasm size grows
    ~1 MB raw / ~300 KB brotli (more than offset by the feature
    pruning above). The last published @tailscale/connect (1.39.98,
    early 2023) pre-dates the regression, which is why no consumer has
    reported the breakage.

  * tstest/integration/testcontrol.Server's /ts2021 noise upgrade
    endpoint rejected anything but POST. WebSocket clients (the only
    transport available to browser-WASM) come in as GET. Allow both;
    the controlhttp AcceptHTTP path dispatches on the Upgrade header,
    so the websocket library still enforces GET for WS upgrades.
    This matches production, where the same controlhttpserver.AcceptHTTP
    routes purely on the Upgrade header without checking method.

  * derp/derphttp's urlString built the DERP URL from node.HostName
    only, dropping node.DERPPort. Non-WS clients use a separate code
    path (connectToHost) that honors DERPPort, but WebSocket-only
    clients (browser-WASM) went through urlString and so could not
    reach a DERP running on any port other than 443. Include the port
    when it differs from the scheme default.

Also move addWebSocketSupport from cmd/derper (where it was main-only)
to derp/derpserver.AddWebSocketSupport so tstest/integration.RunDERPAndSTUN
can wrap its DERP handler with WebSocket support — without that, the
test DERP would not accept the browser's wss connection.

Fixes #9394

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Iff9cdee303e3b239924249b5bffb2fd04e02f391
2026-05-20 10:48:29 -07:00

1204 lines
32 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package integration contains Tailscale integration tests.
//
// This package is considered internal and the public API is subject
// to change without notice.
package integration
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"go4.org/mem"
"tailscale.com/client/local"
"tailscale.com/derp/derpserver"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store"
"tailscale.com/net/stun/stuntest"
"tailscale.com/safesocket"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/nettype"
"tailscale.com/util/rands"
"tailscale.com/util/zstdframe"
"tailscale.com/version"
)
var (
verboseTailscaled = flag.Bool("verbose-tailscaled", false, "verbose tailscaled logging")
verboseTailscale = flag.Bool("verbose-tailscale", false, "verbose tailscale CLI logging")
)
// MainError is an error that's set if an error conditions happens outside of a
// context where a testing.TB is available. The caller can check it in its TestMain
// as a last ditch place to report errors.
var MainError syncs.AtomicValue[error]
// Binaries contains the paths to the tailscale and tailscaled binaries.
type Binaries struct {
Dir string
Tailscale BinaryInfo
Tailscaled BinaryInfo
}
// BinaryInfo describes a tailscale or tailscaled binary.
type BinaryInfo struct {
// Path is the absolute path to the tailscale or tailscaled binary.
// This path may become invalid after the owning test's TempDir is
// cleaned up; use FD (or Contents on Windows) to access the binary
// contents.
Path string
Size int64
// FD and FDmu are set on Unix to efficiently copy the binary to a new
// test's automatically-cleaned-up temp directory.
FD *os.File // for Unix (macOS, Linux, ...)
FDMu sync.Locker
// Contents is used on Windows instead of FD to copy the binary between
// test directories. (On Windows you can't keep an FD open while an earlier
// test's temp directories are deleted.)
// This burns some memory and costs more in I/O, but oh well.
Contents []byte
}
// CopyTo copies or hardlinks the binary into dir, returning a new BinaryInfo
// with an updated Path. The source bytes come from FD (or Contents on Windows),
// not from b.Path, which may have been deleted when its owning test's TempDir
// was cleaned up.
func (b BinaryInfo) CopyTo(dir string) (BinaryInfo, error) {
ret := b
ret.Path = filepath.Join(dir, path.Base(b.Path))
switch runtime.GOOS {
case "linux":
// Try to hardlink from the open FD via /proc/self/fd, avoiding a
// full copy of the binary. We can't use os.Link(b.Path, ret.Path)
// because b.Path is in the first test's TempDir, which may be
// cleaned up before later tests call CopyTo. The open FD keeps the
// inode alive after the path is deleted.
if err := tryLinkat(b.FD, ret.Path); err == nil {
return ret, nil
}
fallthrough
case "darwin", "freebsd", "openbsd", "netbsd":
f, err := os.OpenFile(ret.Path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o755)
if err != nil {
return BinaryInfo{}, err
}
b.FDMu.Lock()
b.FD.Seek(0, 0)
size, err := io.Copy(f, b.FD)
b.FDMu.Unlock()
if err != nil {
f.Close()
return BinaryInfo{}, fmt.Errorf("copying %q: %w", b.Path, err)
}
if size != b.Size {
f.Close()
return BinaryInfo{}, fmt.Errorf("copy %q: size mismatch: %d != %d", b.Path, size, b.Size)
}
if err := f.Close(); err != nil {
return BinaryInfo{}, err
}
return ret, nil
case "windows":
return ret, os.WriteFile(ret.Path, b.Contents, 0o755)
default:
return BinaryInfo{}, fmt.Errorf("unsupported OS %q", runtime.GOOS)
}
}
// GetBinaries create a temp directory using tb and builds (or copies previously
// built) cmd/tailscale and cmd/tailscaled binaries into that directory.
//
// It fails tb if the build or binary copies fail.
func GetBinaries(tb testing.TB) *Binaries {
dir := tb.TempDir()
buildOnce.Do(func() {
buildErr = buildTestBinaries(dir)
})
if buildErr != nil {
tb.Fatal(buildErr)
}
if binariesCache.Dir == dir {
return binariesCache
}
ts, err := binariesCache.Tailscale.CopyTo(dir)
if err != nil {
tb.Fatalf("copying tailscale binary: %v", err)
}
tsd, err := binariesCache.Tailscaled.CopyTo(dir)
if err != nil {
tb.Fatalf("copying tailscaled binary: %v", err)
}
return &Binaries{
Dir: dir,
Tailscale: ts,
Tailscaled: tsd,
}
}
var (
buildOnce sync.Once
buildErr error
binariesCache *Binaries
)
// buildTestBinaries builds tailscale and tailscaled.
// On success, it initializes [binariesCache].
func buildTestBinaries(dir string) error {
getBinaryInfo := func(name string) (BinaryInfo, error) {
bi := BinaryInfo{Path: filepath.Join(dir, name+exe())}
fi, err := os.Stat(bi.Path)
if err != nil {
return BinaryInfo{}, fmt.Errorf("stat %q: %v", bi.Path, err)
}
bi.Size = fi.Size()
switch runtime.GOOS {
case "windows":
bi.Contents, err = os.ReadFile(bi.Path)
if err != nil {
return BinaryInfo{}, fmt.Errorf("read %q: %v", bi.Path, err)
}
default:
bi.FD, err = os.OpenFile(bi.Path, os.O_RDONLY, 0)
if err != nil {
return BinaryInfo{}, fmt.Errorf("open %q: %v", bi.Path, err)
}
bi.FDMu = new(sync.Mutex)
// Note: bi.FD is copied around between tests but never closed, by
// design. It will be closed when the process exits, and that will
// close the inode that we're copying the bytes from for each test.
}
return bi, nil
}
err := build(dir, "tailscale.com/cmd/tailscaled", "tailscale.com/cmd/tailscale")
if err != nil {
return err
}
b := &Binaries{
Dir: dir,
}
b.Tailscale, err = getBinaryInfo("tailscale")
if err != nil {
return err
}
b.Tailscaled, err = getBinaryInfo("tailscaled")
if err != nil {
return err
}
binariesCache = b
return nil
}
func build(outDir string, targets ...string) error {
goBin, err := findGo()
if err != nil {
return err
}
cmd := exec.Command(goBin, "install")
if version.IsRace() {
cmd.Args = append(cmd.Args, "-race")
}
cmd.Args = append(cmd.Args, targets...)
cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH, "GOBIN="+outDir)
errOut, err := cmd.CombinedOutput()
if err == nil {
return nil
}
if strings.Contains(string(errOut), "when GOBIN is set") {
// Fallback slow path for cross-compiled binaries.
for _, target := range targets {
outFile := filepath.Join(outDir, path.Base(target)+exe())
cmd := exec.Command(goBin, "build", "-o", outFile)
if version.IsRace() {
cmd.Args = append(cmd.Args, "-race")
}
cmd.Args = append(cmd.Args, target)
cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH)
if errOut, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to build %v with %v: %v, %s", target, goBin, err, errOut)
}
}
return nil
}
return fmt.Errorf("failed to build %v with %v: %v, %s", targets, goBin, err, errOut)
}
func findGo() (string, error) {
// Go 1.19 attempted to be helpful by prepending $PATH with GOROOT/bin based
// on the executed go binary when invoked using `go test` or `go generate`,
// however, this doesn't cover cases when run otherwise, such as via `go run`.
// runtime.GOROOT() may often be empty these days, so the safe thing to do
// here is, in order:
// 1. Look for a go binary in $PATH[0].
// 2. Look for a go binary in runtime.GOROOT()/bin if runtime.GOROOT() is non-empty.
// 3. Look for a go binary in $PATH.
// For tests we want to run as root on GitHub actions, we run with -exec=sudo,
// but that results in this test running with a different PATH and picking the
// wrong Go. So hard code the GitHub Actions case.
if os.Getuid() == 0 && os.Getenv("GITHUB_ACTIONS") == "true" {
const sudoGithubGo = "/home/runner/.cache/tailscale-go/bin/go"
if _, err := os.Stat(sudoGithubGo); err == nil {
return sudoGithubGo, nil
}
}
paths := strings.FieldsFunc(os.Getenv("PATH"), func(r rune) bool { return os.IsPathSeparator(uint8(r)) })
if len(paths) > 0 {
candidate := filepath.Join(paths[0], "go"+exe())
if path, err := exec.LookPath(candidate); err == nil {
return path, err
}
}
if runtime.GOROOT() != "" {
candidate := filepath.Join(runtime.GOROOT(), "bin", "go"+exe())
if path, err := exec.LookPath(candidate); err == nil {
return path, err
}
}
return exec.LookPath("go")
}
func exe() string {
if runtime.GOOS == "windows" {
return ".exe"
}
return ""
}
// RunDERPAndSTUN runs a local DERP and STUN server for tests, returning the derpMap
// that clients should use. This creates resources that must be cleaned up with the
// returned cleanup function.
func RunDERPAndSTUN(t testing.TB, logf logger.Logf, ipAddress string) (derpMap *tailcfg.DERPMap) {
t.Helper()
d := derpserver.New(key.NewNode(), logf)
ln, err := net.Listen("tcp", net.JoinHostPort(ipAddress, "0"))
if err != nil {
t.Fatal(err)
}
// Wrap with WebSocket support so browser-WASM (cmd/tsconnect) clients,
// which can only reach DERP via WebSocket, can use this same server.
handler := derpserver.AddWebSocketSupport(d, derpserver.Handler(d))
httpsrv := httptest.NewUnstartedServer(handler)
httpsrv.Listener.Close()
httpsrv.Listener = ln
httpsrv.Config.ErrorLog = logger.StdLogger(logf)
httpsrv.Config.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
httpsrv.StartTLS()
stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{})
m := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
RegionCode: "test",
Nodes: []*tailcfg.DERPNode{
{
Name: "t1",
RegionID: 1,
HostName: ipAddress,
IPv4: ipAddress,
IPv6: "none",
STUNPort: stunAddr.Port,
DERPPort: httpsrv.Listener.Addr().(*net.TCPAddr).Port,
InsecureForTests: true,
STUNTestIP: ipAddress,
},
},
},
},
}
t.Logf("DERP httpsrv listener: %v", httpsrv.Listener.Addr())
t.Cleanup(func() {
httpsrv.CloseClientConnections()
httpsrv.Close()
d.Close()
stunCleanup()
ln.Close()
})
return m
}
// LogCatcher is a minimal logcatcher for the logtail upload client.
type LogCatcher struct {
mu sync.Mutex
logf logger.Logf
buf bytes.Buffer
gotErr error
reqs int
raw bool // indicates whether to store the raw JSON logs uploaded, instead of just the text
}
// UseLogf makes the logcatcher implementation use a given logf function
// to dump all logs to.
func (lc *LogCatcher) UseLogf(fn logger.Logf) {
lc.mu.Lock()
defer lc.mu.Unlock()
lc.logf = fn
}
// StoreRawJSON instructs lc to save the raw JSON uploads, rather than just the text.
func (lc *LogCatcher) StoreRawJSON() {
lc.mu.Lock()
defer lc.mu.Unlock()
lc.raw = true
}
func (lc *LogCatcher) logsContains(sub mem.RO) bool {
lc.mu.Lock()
defer lc.mu.Unlock()
return mem.Contains(mem.B(lc.buf.Bytes()), sub)
}
func (lc *LogCatcher) numRequests() int {
lc.mu.Lock()
defer lc.mu.Unlock()
return lc.reqs
}
func (lc *LogCatcher) logsString() string {
lc.mu.Lock()
defer lc.mu.Unlock()
return lc.buf.String()
}
// Reset clears the buffered logs from memory.
func (lc *LogCatcher) Reset() {
lc.mu.Lock()
defer lc.mu.Unlock()
lc.buf.Reset()
}
func (lc *LogCatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// POST /c/<collection-name>/<private-ID>
if r.Method != "POST" {
log.Printf("bad logcatcher method: %v", r.Method)
http.Error(w, "only POST is supported", 400)
return
}
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/c/"), "/")
if len(pathParts) != 2 {
log.Printf("bad logcatcher path: %q", r.URL.Path)
http.Error(w, "bad URL", 400)
return
}
// collectionName := pathPaths[0]
privID, err := logid.ParsePrivateID(pathParts[1])
if err != nil {
log.Printf("bad log ID: %q: %v", r.URL.Path, err)
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("http.Request.Body.Read: %v", err)
return
}
if r.Header.Get("Content-Encoding") == "zstd" {
bodyBytes, err = zstdframe.AppendDecode(nil, bodyBytes)
if err != nil {
log.Printf("zstdframe.AppendDecode: %v", err)
http.Error(w, err.Error(), 400)
return
}
}
type Entry struct {
Logtail struct {
ClientTime time.Time `json:"client_time"`
ServerTime time.Time `json:"server_time"`
Error struct {
BadData string `json:"bad_data"`
} `json:"error"`
} `json:"logtail"`
Text string `json:"text"`
}
var jreq []Entry
if len(bodyBytes) > 0 && bodyBytes[0] == '[' {
err = json.Unmarshal(bodyBytes, &jreq)
} else {
var ent Entry
err = json.Unmarshal(bodyBytes, &ent)
jreq = append(jreq, ent)
}
lc.mu.Lock()
defer lc.mu.Unlock()
lc.reqs++
if lc.gotErr == nil && err != nil {
lc.gotErr = err
}
if err != nil {
fmt.Fprintf(&lc.buf, "error from %s of %#q: %v\n", r.Method, bodyBytes, err)
if lc.logf != nil {
lc.logf("error from %s of %#q: %v\n", r.Method, bodyBytes, err)
}
} else {
id := privID.Public().String()[:3] // good enough for integration tests
for _, ent := range jreq {
if lc.raw {
lc.buf.Write(bodyBytes)
continue
}
fmt.Fprintf(&lc.buf, "%s\n", strings.TrimSpace(ent.Text))
if lc.logf != nil {
lc.logf("logcatch:%s: %s", id, strings.TrimSpace(ent.Text))
}
}
}
w.WriteHeader(200) // must have no content, but not a 204
}
// TestEnv contains the test environment (set of servers) used by one
// or more nodes.
type TestEnv struct {
t testing.TB
tunMode bool
cli string
daemon string
loopbackPort *int
neverDirectUDP bool
relayServerUseLoopback bool
LogCatcher *LogCatcher
LogCatcherServer *httptest.Server
Control *testcontrol.Server
ControlServer *httptest.Server
TrafficTrap *trafficTrap
TrafficTrapServer *httptest.Server
}
// ControlURL returns e.ControlServer.URL, panicking if it's the empty string,
// which it should never be in tests.
func (e *TestEnv) ControlURL() string {
s := e.ControlServer.URL
if s == "" {
panic("control server not set")
}
return s
}
// TestEnvOpt represents an option that can be passed to NewTestEnv.
type TestEnvOpt interface {
ModifyTestEnv(*TestEnv)
}
// ConfigureControl is a test option that configures the test control server.
type ConfigureControl func(*testcontrol.Server)
func (f ConfigureControl) ModifyTestEnv(te *TestEnv) {
f(te.Control)
}
// NewTestEnv starts a bunch of services and returns a new test environment.
// NewTestEnv arranges for the environment's resources to be cleaned up on exit.
func NewTestEnv(t testing.TB, opts ...TestEnvOpt) *TestEnv {
if runtime.GOOS == "windows" {
t.Skip("not tested/working on Windows yet")
}
derpMap := RunDERPAndSTUN(t, logger.Discard, "127.0.0.1")
logc := new(LogCatcher)
control := &testcontrol.Server{
Logf: logger.WithPrefix(t.Logf, "testcontrol: "),
DERPMap: derpMap,
}
control.HTTPTestServer = httptest.NewUnstartedServer(control)
trafficTrap := new(trafficTrap)
binaries := GetBinaries(t)
e := &TestEnv{
t: t,
cli: binaries.Tailscale.Path,
daemon: binaries.Tailscaled.Path,
LogCatcher: logc,
LogCatcherServer: httptest.NewServer(logc),
Control: control,
ControlServer: control.HTTPTestServer,
TrafficTrap: trafficTrap,
TrafficTrapServer: httptest.NewServer(trafficTrap),
}
for _, o := range opts {
o.ModifyTestEnv(e)
}
control.HTTPTestServer.Start()
t.Cleanup(func() {
// Shut down e.
if err := e.TrafficTrap.Err(); err != nil {
e.t.Errorf("traffic trap: %v", err)
e.t.Logf("logs: %s", e.LogCatcher.logsString())
}
e.LogCatcherServer.Close()
e.TrafficTrapServer.Close()
e.ControlServer.Close()
})
t.Logf("control URL: %v", e.ControlURL())
return e
}
// TestNode is a machine with a tailscale & tailscaled.
// Currently, the test is simplistic and user==node==machine.
// That may grow complexity later to test more.
type TestNode struct {
env *TestEnv
tailscaledParser *nodeOutputParser
dir string // temp dir for sock & state
configFile string // or empty for none
sockFile string
stateFile string
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
encryptState bool
allowUpdates bool
mu sync.Mutex
onLogLine []func([]byte)
lc *local.Client
}
// NewTestNode allocates a temp directory for a new test node.
// The node is not started automatically.
func NewTestNode(t *testing.T, env *TestEnv) *TestNode {
dir := t.TempDir()
sockFile := filepath.Join(dir, "tailscale.sock")
if len(sockFile) >= 104 {
// Maximum length for a unix socket on darwin. Try something else.
sockFile = filepath.Join(os.TempDir(), rands.HexString(8)+".sock")
t.Cleanup(func() { os.Remove(sockFile) })
}
n := &TestNode{
env: env,
dir: dir,
sockFile: sockFile,
stateFile: filepath.Join(dir, "tailscaled.state"), // matches what cmd/tailscaled uses
}
// Look for a data race or panic.
// Once we see the start marker, start logging the rest.
var sawRace bool
var sawPanic bool
n.addLogLineHook(func(line []byte) {
lineB := mem.B(line)
if mem.Contains(lineB, mem.S("DEBUG-ADDR=")) {
t.Log(strings.TrimSpace(string(line)))
}
if mem.Contains(lineB, mem.S("WARNING: DATA RACE")) {
sawRace = true
}
if mem.HasPrefix(lineB, mem.S("panic: ")) {
sawPanic = true
}
if sawRace || sawPanic {
t.Logf("%s", line)
}
})
return n
}
func (n *TestNode) LocalClient() *local.Client {
n.mu.Lock()
defer n.mu.Unlock()
if n.lc == nil {
tr := &http.Transport{}
n.lc = &local.Client{
Socket: n.sockFile,
UseSocketOnly: true,
}
n.env.t.Cleanup(tr.CloseIdleConnections)
}
return n.lc
}
func (n *TestNode) diskPrefs() *ipn.Prefs {
t := n.env.t
t.Helper()
if _, err := os.ReadFile(n.stateFile); err != nil {
t.Fatalf("reading prefs: %v", err)
}
fs, err := store.New(nil, n.stateFile)
if err != nil {
t.Fatalf("reading prefs, NewFileStore: %v", err)
}
p, err := ipnlocal.ReadStartupPrefsForTest(t.Logf, fs)
if err != nil {
t.Fatalf("reading prefs, ReadDiskPrefsForTest: %v", err)
}
return p.AsStruct()
}
// AwaitResponding waits for n's tailscaled to be up enough to be
// responding, but doesn't wait for any particular state.
func (n *TestNode) AwaitResponding() {
t := n.env.t
t.Helper()
n.AwaitListening()
st := n.MustStatus()
t.Logf("Status: %s", st.BackendState)
if err := tstest.WaitFor(20*time.Second, func() error {
const sub = `Program starting: `
if !n.env.LogCatcher.logsContains(mem.S(sub)) {
return fmt.Errorf("log catcher didn't see %#q; got %s", sub, n.env.LogCatcher.logsString())
}
return nil
}); err != nil {
t.Fatal(err)
}
}
// addLogLineHook registers a hook f to be called on each tailscaled
// log line output.
func (n *TestNode) addLogLineHook(f func([]byte)) {
n.mu.Lock()
defer n.mu.Unlock()
n.onLogLine = append(n.onLogLine, f)
}
// socks5AddrChan returns a channel that receives the address (e.g. "localhost:23874")
// of the node's SOCKS5 listener, once started.
func (n *TestNode) socks5AddrChan() <-chan string {
ch := make(chan string, 1)
n.addLogLineHook(func(line []byte) {
const sub = "SOCKS5 listening on "
i := mem.Index(mem.B(line), mem.S(sub))
if i == -1 {
return
}
addr := strings.TrimSpace(string(line)[i+len(sub):])
select {
case ch <- addr:
default:
}
})
return ch
}
func (n *TestNode) AwaitSocksAddr(ch <-chan string) string {
t := n.env.t
t.Helper()
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
select {
case v := <-ch:
return v
case <-timer.C:
t.Fatal("timeout waiting for node to log its SOCK5 listening address")
panic("unreachable")
}
}
// nodeOutputParser parses stderr of tailscaled processes, calling the
// per-line callbacks previously registered via
// testNode.addLogLineHook.
type nodeOutputParser struct {
allBuf bytes.Buffer
pendLineBuf bytes.Buffer
n *TestNode
}
func (op *nodeOutputParser) Write(p []byte) (n int, err error) {
tn := op.n
tn.mu.Lock()
defer tn.mu.Unlock()
op.allBuf.Write(p)
n, err = op.pendLineBuf.Write(p)
op.parseLinesLocked()
return
}
func (op *nodeOutputParser) parseLinesLocked() {
n := op.n
buf := op.pendLineBuf.Bytes()
for len(buf) > 0 {
nl := bytes.IndexByte(buf, '\n')
if nl == -1 {
break
}
line := buf[:nl+1]
buf = buf[nl+1:]
for _, f := range n.onLogLine {
f(line)
}
}
if len(buf) == 0 {
op.pendLineBuf.Reset()
} else {
io.CopyN(io.Discard, &op.pendLineBuf, int64(op.pendLineBuf.Len()-len(buf)))
}
}
type Daemon struct {
Process *os.Process
}
func (d *Daemon) MustCleanShutdown(t testing.TB) {
d.Process.Signal(os.Interrupt)
ps, err := d.Process.Wait()
if err != nil {
t.Fatalf("tailscaled Wait: %v", err)
}
if ps.ExitCode() != 0 {
t.Errorf("tailscaled ExitCode = %d; want 0", ps.ExitCode())
}
}
// awaitTailscaledRunnable tries to run `tailscaled --version` until it
// works. This is an unsatisfying workaround for ETXTBSY we were seeing
// on GitHub Actions that aren't understood. It's not clear what's holding
// a writable fd to tailscaled after `go install` completes.
// See https://github.com/tailscale/tailscale/issues/15868.
func (n *TestNode) awaitTailscaledRunnable() error {
t := n.env.t
t.Helper()
if err := tstest.WaitFor(10*time.Second, func() error {
out, err := exec.Command(n.env.daemon, "--version").CombinedOutput()
if err == nil {
return nil
}
t.Logf("error running tailscaled --version: %v, %s", err, out)
return err
}); err != nil {
return fmt.Errorf("gave up trying to run tailscaled: %v", err)
}
return nil
}
// StartDaemon starts the node's tailscaled, failing if it fails to start.
// StartDaemon ensures that the process will exit when the test completes.
func (n *TestNode) StartDaemon() *Daemon {
return n.StartDaemonAsIPNGOOS(runtime.GOOS)
}
func (n *TestNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
t := n.env.t
if err := n.awaitTailscaledRunnable(); err != nil {
t.Fatalf("awaitTailscaledRunnable: %v", err)
}
cmd := exec.Command(n.env.daemon)
cmd.Args = append(cmd.Args,
"--statedir="+n.dir,
"--socket="+n.sockFile,
"--socks5-server=localhost:0",
"--debug=localhost:0",
)
if *verboseTailscaled {
cmd.Args = append(cmd.Args, "-verbose=2")
}
if !n.env.tunMode {
cmd.Args = append(cmd.Args,
"--tun=userspace-networking",
)
}
if n.configFile != "" {
cmd.Args = append(cmd.Args, "--config="+n.configFile)
}
if n.encryptState {
cmd.Args = append(cmd.Args, "--encrypt-state")
}
cmd.Env = append(os.Environ(),
"TS_DEBUG_PERMIT_HTTP_C2N=1",
"TS_LOG_TARGET="+n.env.LogCatcherServer.URL,
"HTTP_PROXY="+n.env.TrafficTrapServer.URL,
"HTTPS_PROXY="+n.env.TrafficTrapServer.URL,
"TS_DEBUG_FAKE_GOOS="+ipnGOOS,
"TS_LOGS_DIR="+t.TempDir(),
"TS_NETCHECK_GENERATE_204_URL="+n.env.ControlServer.URL+"/generate_204",
"TS_ASSUME_NETWORK_UP_FOR_TEST=1", // don't pause control client in airplane mode (no wifi, etc)
"TS_PANIC_IF_HIT_MAIN_CONTROL=1",
"TS_DISABLE_PORTMAPPER=1", // shouldn't be needed; test is all localhost
"TS_DEBUG_LOG_RATE=all",
)
if n.allowUpdates {
cmd.Env = append(cmd.Env, "TS_TEST_ALLOW_AUTO_UPDATE=1")
}
if n.env.loopbackPort != nil {
cmd.Env = append(cmd.Env, "TS_DEBUG_NETSTACK_LOOPBACK_PORT="+strconv.Itoa(*n.env.loopbackPort))
}
if n.env.neverDirectUDP {
cmd.Env = append(cmd.Env, "TS_DEBUG_NEVER_DIRECT_UDP=1")
}
if n.env.relayServerUseLoopback {
cmd.Env = append(cmd.Env, "TS_DEBUG_RELAY_SERVER_ADDRS=::1,127.0.0.1")
}
if version.IsRace() {
cmd.Env = append(cmd.Env, "GORACE=halt_on_error=1")
}
n.tailscaledParser = &nodeOutputParser{n: n}
cmd.Stderr = n.tailscaledParser
if *verboseTailscaled {
cmd.Stdout = os.Stdout
cmd.Stderr = io.MultiWriter(cmd.Stderr, os.Stderr)
}
if runtime.GOOS != "windows" {
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pw.Close() })
cmd.ExtraFiles = append(cmd.ExtraFiles, pr)
cmd.Env = append(cmd.Env, "TS_PARENT_DEATH_FD=3")
}
if err := cmd.Start(); err != nil {
t.Fatalf("starting tailscaled: %v", err)
}
t.Cleanup(func() { cmd.Process.Kill() })
return &Daemon{
Process: cmd.Process,
}
}
func (n *TestNode) MustUp(extraArgs ...string) {
t := n.env.t
t.Helper()
args := []string{
"up",
"--login-server=" + n.env.ControlURL(),
"--reset",
}
args = append(args, extraArgs...)
cmd := n.Tailscale(args...)
t.Logf("Running %v ...", cmd)
cmd.Stdout = nil // in case --verbose-tailscale was set
cmd.Stderr = nil // in case --verbose-tailscale was set
if b, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("up: %v, %v", string(b), err)
}
}
func (n *TestNode) MustDown() {
t := n.env.t
t.Logf("Running down ...")
if err := n.Tailscale("down", "--accept-risk=all").Run(); err != nil {
t.Fatalf("down: %v", err)
}
}
func (n *TestNode) MustLogOut() {
t := n.env.t
t.Logf("Running logout ...")
if err := n.Tailscale("logout").Run(); err != nil {
t.Fatalf("logout: %v", err)
}
}
func (n *TestNode) Ping(otherNode *TestNode) error {
t := n.env.t
ip := otherNode.AwaitIP4().String()
t.Logf("Running ping %v (from %v)...", ip, n.AwaitIP4())
return n.Tailscale("ping", "--timeout=1s", ip).Run()
}
// AwaitListening waits for the tailscaled to be serving local clients
// over its localhost IPC mechanism. (Unix socket, etc)
func (n *TestNode) AwaitListening() {
t := n.env.t
if err := tstest.WaitFor(20*time.Second, func() (err error) {
c, err := safesocket.ConnectContext(context.Background(), n.sockFile)
if err == nil {
c.Close()
}
return err
}); err != nil {
t.Fatal(err)
}
}
func (n *TestNode) AwaitIPs() []netip.Addr {
t := n.env.t
t.Helper()
var addrs []netip.Addr
if err := tstest.WaitFor(20*time.Second, func() error {
cmd := n.Tailscale("ip")
cmd.Stdout = nil // in case --verbose-tailscale was set
cmd.Stderr = nil // in case --verbose-tailscale was set
out, err := cmd.Output()
if err != nil {
return err
}
ips := string(out)
ipslice := strings.Fields(ips)
addrs = make([]netip.Addr, len(ipslice))
for i, ip := range ipslice {
netIP, err := netip.ParseAddr(ip)
if err != nil {
t.Fatal(err)
}
addrs[i] = netIP
}
return nil
}); err != nil {
t.Fatalf("awaiting an IP address: %v", err)
}
if len(addrs) == 0 {
t.Fatalf("returned IP address was blank")
}
return addrs
}
// AwaitIP4 returns the IPv4 address of n.
func (n *TestNode) AwaitIP4() netip.Addr {
t := n.env.t
t.Helper()
ips := n.AwaitIPs()
return ips[0]
}
// AwaitIP6 returns the IPv6 address of n.
func (n *TestNode) AwaitIP6() netip.Addr {
t := n.env.t
t.Helper()
ips := n.AwaitIPs()
return ips[1]
}
// AwaitRunning waits for n to reach the IPN state "Running".
func (n *TestNode) AwaitRunning() {
t := n.env.t
t.Helper()
n.AwaitBackendState("Running")
}
func (n *TestNode) AwaitBackendState(state string) {
t := n.env.t
t.Helper()
if err := tstest.WaitFor(20*time.Second, func() error {
st, err := n.Status()
if err != nil {
return err
}
if st.BackendState != state {
return fmt.Errorf("in state %q; want %q", st.BackendState, state)
}
return nil
}); err != nil {
t.Fatalf("failure/timeout waiting for transition to Running status: %v", err)
}
}
// AwaitNeedsLogin waits for n to reach the IPN state "NeedsLogin".
func (n *TestNode) AwaitNeedsLogin() {
t := n.env.t
t.Helper()
if err := tstest.WaitFor(20*time.Second, func() error {
st, err := n.Status()
if err != nil {
return err
}
if st.BackendState != "NeedsLogin" {
return fmt.Errorf("in state %q", st.BackendState)
}
return nil
}); err != nil {
t.Fatalf("failure/timeout waiting for transition to NeedsLogin status: %v", err)
}
}
func (n *TestNode) TailscaleForOutput(arg ...string) *exec.Cmd {
cmd := n.Tailscale(arg...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd
}
// Tailscale returns a command that runs the tailscale CLI with the provided arguments.
// It does not start the process.
func (n *TestNode) Tailscale(arg ...string) *exec.Cmd {
cmd := exec.Command(n.env.cli)
cmd.Args = append(cmd.Args, "--socket="+n.sockFile)
cmd.Args = append(cmd.Args, arg...)
cmd.Dir = n.dir
cmd.Env = append(os.Environ(),
"TS_DEBUG_UP_FLAG_GOOS="+n.upFlagGOOS,
"TS_LOGS_DIR="+n.env.t.TempDir(),
"SSH_CLIENT=", // Clear SSH_CLIENT to prevent isSSHOverTailscale() false positives in tests
"SSH_CONNECTION=", // just in case
"SSH_AUTH_SOCK=", // just in case
)
if *verboseTailscale {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
return cmd
}
func (n *TestNode) Status() (*ipnstate.Status, error) {
cmd := n.Tailscale("status", "--json")
cmd.Stdout = nil // in case --verbose-tailscale was set
cmd.Stderr = nil // in case --verbose-tailscale was set
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("running tailscale status: %v, %s", err, out)
}
st := new(ipnstate.Status)
if err := json.Unmarshal(out, st); err != nil {
return nil, fmt.Errorf("decoding tailscale status JSON: %w\njson:\n%s", err, out)
}
return st, nil
}
func (n *TestNode) MustStatus() *ipnstate.Status {
tb := n.env.t
tb.Helper()
st, err := n.Status()
if err != nil {
tb.Fatal(err)
}
return st
}
// PublicKey returns the hex-encoded public key of this node,
// e.g. `nodekey:123456abc`
func (n *TestNode) PublicKey() string {
tb := n.env.t
tb.Helper()
cmd := n.Tailscale("status", "--json")
out, err := cmd.CombinedOutput()
if err != nil {
tb.Fatalf("running `tailscale status`: %v, %s", err, out)
}
type Self struct{ PublicKey string }
type StatusOutput struct{ Self Self }
var st StatusOutput
if err := json.Unmarshal(out, &st); err != nil {
tb.Fatalf("decoding `tailscale status` JSON: %v\njson:\n%s", err, out)
}
return st.Self.PublicKey
}
// NLPublicKey returns the hex-encoded network lock public key of
// this node, e.g. `tlpub:123456abc`
func (n *TestNode) NLPublicKey() string {
tb := n.env.t
tb.Helper()
cmd := n.Tailscale("lock", "status", "--json")
out, err := cmd.CombinedOutput()
if err != nil {
tb.Fatalf("running `tailscale lock status`: %v, %s", err, out)
}
st := struct {
PublicKey string `json:"PublicKey"`
}{}
if err := json.Unmarshal(out, &st); err != nil {
tb.Fatalf("decoding `tailscale lock status` JSON: %v\njson:\n%s", err, out)
}
return st.PublicKey
}
// trafficTrap is an HTTP proxy handler to note whether any
// HTTP traffic tries to leave localhost from tailscaled. We don't
// expect any, so any request triggers a failure.
type trafficTrap struct {
atomicErr syncs.AtomicValue[error]
}
func (tt *trafficTrap) Err() error {
return tt.atomicErr.Load()
}
func (tt *trafficTrap) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var got bytes.Buffer
r.Write(&got)
err := fmt.Errorf("unexpected HTTP request via proxy: %s", got.Bytes())
MainError.Store(err)
if tt.Err() == nil {
// Best effort at remembering the first request.
tt.atomicErr.Store(err)
}
log.Printf("Error: %v", err)
w.WriteHeader(403)
}
type authURLParserWriter struct {
t *testing.T
buf bytes.Buffer
// Handle login URLs, and count how many times they were seen
authURLFn func(urlStr string) error
// Handle machine approval URLs, and count how many times they were seen.
deviceApprovalURLFn func(urlStr string) error
}
// Note: auth URLs from testcontrol look slightly different to real auth URLs,
// e.g. http://127.0.0.1:60456/auth/96af2ff7e04ae1499a9a
var authURLRx = regexp.MustCompile(`(https?://\S+/auth/\S+)`)
// Looks for any device approval URL, which is any URL ending with `/admin`
// e.g. http://127.0.0.1:60456/admin
var deviceApprovalURLRx = regexp.MustCompile(`(https?://\S+/admin)[^\S]`)
func (w *authURLParserWriter) Write(p []byte) (n int, err error) {
w.t.Helper()
w.t.Logf("received bytes: %s", string(p))
n, err = w.buf.Write(p)
defer w.buf.Reset() // so it's not matched again
m := authURLRx.FindSubmatch(w.buf.Bytes())
if m != nil {
urlStr := string(m[1])
if err := w.authURLFn(urlStr); err != nil {
return 0, err
}
}
m = deviceApprovalURLRx.FindSubmatch(w.buf.Bytes())
if m != nil && w.deviceApprovalURLFn != nil {
urlStr := string(m[1])
if err := w.deviceApprovalURLFn(urlStr); err != nil {
return 0, err
}
}
return n, err
}