mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-30 03:25:06 -04:00
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
369 lines
10 KiB
Go
369 lines
10 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
esbuild "github.com/evanw/esbuild/pkg/api"
|
|
"tailscale.com/cmd/tsconnect/wasmbuild"
|
|
)
|
|
|
|
// lastRawWasmSHA256 is set by buildWasm in non-dev mode after the
|
|
// `go build` step but before wasm-opt runs. build-pkg reads it to
|
|
// emit pkg/build-info.json (see [wasmbuild.BuildInfo]).
|
|
var lastRawWasmSHA256 string
|
|
|
|
const (
|
|
devMode = true
|
|
prodMode = false
|
|
)
|
|
|
|
// commonSetup performs setup that is common to both dev and build modes.
|
|
func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
|
|
// Change cwd to to where this file lives -- that's where all inputs for
|
|
// esbuild and other build steps live.
|
|
root, err := findRepoRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if *yarnPath == "" {
|
|
*yarnPath = path.Join(root, "tool", "yarn")
|
|
}
|
|
tsConnectDir := filepath.Join(root, "cmd", "tsconnect")
|
|
if err := os.Chdir(tsConnectDir); err != nil {
|
|
return nil, fmt.Errorf("Cannot change cwd: %w", err)
|
|
}
|
|
if err := installJSDeps(); err != nil {
|
|
return nil, fmt.Errorf("Cannot install JS deps: %w", err)
|
|
}
|
|
|
|
return &esbuild.BuildOptions{
|
|
EntryPoints: []string{"src/app/index.ts", "src/app/index.css"},
|
|
Outdir: *distDir,
|
|
Bundle: true,
|
|
Sourcemap: esbuild.SourceMapLinked,
|
|
LogLevel: esbuild.LogLevelInfo,
|
|
Define: map[string]string{"DEBUG": strconv.FormatBool(dev)},
|
|
Target: esbuild.ES2017,
|
|
Plugins: []esbuild.Plugin{
|
|
{
|
|
Name: "tailscale-tailwind",
|
|
Setup: func(build esbuild.PluginBuild) {
|
|
setupEsbuildTailwind(build, dev)
|
|
},
|
|
},
|
|
{
|
|
Name: "tailscale-go-wasm-exec-js",
|
|
Setup: setupEsbuildWasmExecJS,
|
|
},
|
|
{
|
|
Name: "tailscale-wasm",
|
|
Setup: func(build esbuild.PluginBuild) {
|
|
setupEsbuildWasm(build, dev)
|
|
},
|
|
},
|
|
},
|
|
JSX: esbuild.JSXAutomatic,
|
|
}, nil
|
|
}
|
|
|
|
func findRepoRoot() (string, error) {
|
|
if *rootDir != "" {
|
|
return *rootDir, nil
|
|
}
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for {
|
|
if _, err := os.Stat(path.Join(cwd, "go.mod")); err == nil {
|
|
return cwd, nil
|
|
}
|
|
if cwd == "/" {
|
|
return "", fmt.Errorf("Cannot find repo root")
|
|
}
|
|
cwd = path.Dir(cwd)
|
|
}
|
|
}
|
|
|
|
func commonPkgSetup(dev bool) (*esbuild.BuildOptions, error) {
|
|
buildOptions, err := commonSetup(dev)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buildOptions.EntryPoints = []string{"src/pkg/pkg.ts", "src/pkg/pkg.css"}
|
|
buildOptions.Outdir = *pkgDir
|
|
buildOptions.Format = esbuild.FormatESModule
|
|
buildOptions.AssetNames = "[name]"
|
|
return buildOptions, nil
|
|
}
|
|
|
|
// cleanDir removes files from dirPath, except the ones specified by
|
|
// preserveFiles.
|
|
func cleanDir(dirPath string, preserveFiles ...string) error {
|
|
log.Printf("Cleaning %s...\n", dirPath)
|
|
files, err := os.ReadDir(dirPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return os.MkdirAll(dirPath, 0755)
|
|
}
|
|
return err
|
|
}
|
|
|
|
for _, file := range files {
|
|
if !slices.Contains(preserveFiles, file.Name()) {
|
|
if err := os.Remove(filepath.Join(dirPath, file.Name())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runEsbuildServe(buildOptions esbuild.BuildOptions) {
|
|
host, portStr, err := net.SplitHostPort(*addr)
|
|
if err != nil {
|
|
log.Fatalf("Cannot parse addr: %v", err)
|
|
}
|
|
port, err := strconv.ParseUint(portStr, 10, 16)
|
|
if err != nil {
|
|
log.Fatalf("Cannot parse port: %v", err)
|
|
}
|
|
buildContext, ctxErr := esbuild.Context(buildOptions)
|
|
if ctxErr != nil {
|
|
log.Fatalf("Cannot create esbuild context: %v", err)
|
|
}
|
|
result, err := buildContext.Serve(esbuild.ServeOptions{
|
|
Port: uint16(port),
|
|
Host: host,
|
|
Servedir: "./",
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("Cannot start esbuild server: %v", err)
|
|
}
|
|
log.Printf("Listening on http://%s:%d\n", result.Host, result.Port)
|
|
select {}
|
|
}
|
|
|
|
func runEsbuild(buildOptions esbuild.BuildOptions) esbuild.BuildResult {
|
|
log.Printf("Running esbuild...\n")
|
|
result := esbuild.Build(buildOptions)
|
|
if len(result.Errors) > 0 {
|
|
log.Printf("ESBuild Error:\n")
|
|
for _, e := range result.Errors {
|
|
log.Printf("%v", e)
|
|
}
|
|
log.Fatal("Build failed")
|
|
}
|
|
if len(result.Warnings) > 0 {
|
|
log.Printf("ESBuild Warnings:\n")
|
|
for _, w := range result.Warnings {
|
|
log.Printf("%v", w)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// setupEsbuildWasmExecJS generates an esbuild plugin that serves the current
|
|
// wasm_exec.js runtime helper library from the Go toolchain.
|
|
func setupEsbuildWasmExecJS(build esbuild.PluginBuild) {
|
|
wasmExecSrcPath := filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js")
|
|
if _, err := os.Stat(wasmExecSrcPath); os.IsNotExist(err) {
|
|
// Go 1.24+ location:
|
|
wasmExecSrcPath = filepath.Join(runtime.GOROOT(), "lib", "wasm", "wasm_exec.js")
|
|
}
|
|
build.OnResolve(esbuild.OnResolveOptions{
|
|
Filter: "./wasm_exec$",
|
|
}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
|
return esbuild.OnResolveResult{Path: wasmExecSrcPath}, nil
|
|
})
|
|
}
|
|
|
|
// setupEsbuildWasm generates an esbuild plugin that builds the Tailscale wasm
|
|
// binary and serves it as a file that the JS can load.
|
|
func setupEsbuildWasm(build esbuild.PluginBuild, dev bool) {
|
|
// Add a resolve hook to convince esbuild that the path exists.
|
|
build.OnResolve(esbuild.OnResolveOptions{
|
|
Filter: "./main.wasm$",
|
|
}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
|
return esbuild.OnResolveResult{
|
|
Path: "./src/main.wasm",
|
|
Namespace: "generated",
|
|
}, nil
|
|
})
|
|
build.OnLoad(esbuild.OnLoadOptions{
|
|
Filter: "./src/main.wasm$",
|
|
}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
|
contents, err := buildWasm(dev)
|
|
if err != nil {
|
|
return esbuild.OnLoadResult{}, fmt.Errorf("Cannot build main.wasm: %w", err)
|
|
}
|
|
contentsStr := string(contents)
|
|
return esbuild.OnLoadResult{
|
|
Contents: &contentsStr,
|
|
Loader: esbuild.LoaderFile,
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
func buildWasm(dev bool) ([]byte, error) {
|
|
start := time.Now()
|
|
outputFile, err := os.CreateTemp("", "main.*.wasm")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Cannot create main.wasm output file: %w", err)
|
|
}
|
|
outputPath := outputFile.Name()
|
|
|
|
defer os.Remove(outputPath)
|
|
// Running defer (*os.File).Close() in defer order before os.Remove
|
|
// because on some systems like Windows, it is possible for os.Remove
|
|
// to fail for unclosed files.
|
|
defer outputFile.Close()
|
|
|
|
args := []string{"build", "-tags", wasmbuild.Tags()}
|
|
if !dev {
|
|
if *devControl != "" {
|
|
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
|
|
}
|
|
// Omit long paths and debug symbols in release builds, to reduce the
|
|
// generated WASM binary size.
|
|
args = append(args, "-trimpath", "-ldflags", wasmbuild.ProdLDFlags)
|
|
} else if *devControl != "" {
|
|
args = append(args, "-ldflags", fmt.Sprintf("-X 'main.ControlURL=%v'", *devControl))
|
|
}
|
|
|
|
args = append(args, "-o", outputPath, "./wasm")
|
|
cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
|
|
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err = cmd.Run()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Cannot build main.wasm: %w", err)
|
|
}
|
|
log.Printf("Built wasm in %v\n", time.Since(start).Round(time.Millisecond))
|
|
|
|
if !dev {
|
|
// Capture the raw (pre-wasm-opt) sha256 so build-pkg can write it to
|
|
// pkg/build-info.json. Tests reproduce this sha256 to detect a stale
|
|
// pkg/main.wasm without having to re-run wasm-opt.
|
|
sum, err := sha256File(outputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hashing raw wasm: %w", err)
|
|
}
|
|
lastRawWasmSHA256 = sum
|
|
|
|
if err := runWasmOpt(outputPath); err != nil {
|
|
return nil, fmt.Errorf("Cannot run wasm-opt: %w", err)
|
|
}
|
|
}
|
|
|
|
return os.ReadFile(outputPath)
|
|
}
|
|
|
|
func sha256File(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func runWasmOpt(path string) error {
|
|
start := time.Now()
|
|
stat, err := os.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("Cannot stat %v: %w", path, err)
|
|
}
|
|
startSize := stat.Size()
|
|
cmd := exec.Command("../../tool/wasm-opt", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "-Oz", path, "-o", path)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err = cmd.Run()
|
|
if err != nil {
|
|
return fmt.Errorf("Cannot run wasm-opt: %w", err)
|
|
}
|
|
stat, err = os.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("Cannot stat %v: %w", path, err)
|
|
}
|
|
endSize := stat.Size()
|
|
log.Printf("Ran wasm-opt in %v, size dropped by %dK\n", time.Since(start).Round(time.Millisecond), (startSize-endSize)/1024)
|
|
return nil
|
|
}
|
|
|
|
// installJSDeps installs the JavaScript dependencies specified by package.json
|
|
func installJSDeps() error {
|
|
log.Printf("Installing JS deps...\n")
|
|
return runYarn()
|
|
}
|
|
|
|
func runYarn(args ...string) error {
|
|
cmd := exec.Command(*yarnPath, args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// EsbuildMetadata is the subset of metadata struct (described by
|
|
// https://esbuild.github.io/api/#metafile) that we care about for mapping
|
|
// from entry points to hashed file names.
|
|
type EsbuildMetadata struct {
|
|
Outputs map[string]struct {
|
|
Inputs map[string]struct {
|
|
BytesInOutput int64 `json:"bytesInOutput"`
|
|
} `json:"inputs,omitempty"`
|
|
EntryPoint string `json:"entryPoint,omitempty"`
|
|
} `json:"outputs,omitempty"`
|
|
}
|
|
|
|
func setupEsbuildTailwind(build esbuild.PluginBuild, dev bool) {
|
|
build.OnLoad(esbuild.OnLoadOptions{
|
|
Filter: "./src/.*\\.css$",
|
|
}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
|
start := time.Now()
|
|
yarnArgs := []string{"--silent", "tailwind", "-i", args.Path}
|
|
if !dev {
|
|
yarnArgs = append(yarnArgs, "--minify")
|
|
}
|
|
cmd := exec.Command(*yarnPath, yarnArgs...)
|
|
tailwindOutput, err := cmd.Output()
|
|
log.Printf("Ran tailwind in %v\n", time.Since(start).Round(time.Millisecond))
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
log.Printf("Tailwind stderr: %s", exitErr.Stderr)
|
|
}
|
|
return esbuild.OnLoadResult{}, fmt.Errorf("Cannot run tailwind: %w", err)
|
|
}
|
|
tailwindOutputStr := string(tailwindOutput)
|
|
return esbuild.OnLoadResult{
|
|
Contents: &tailwindOutputStr,
|
|
Loader: esbuild.LoaderCSS,
|
|
}, nil
|
|
|
|
})
|
|
}
|