From 04ae61fe4bfaf67408e50664006bc3146f72b36a Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Tue, 19 May 2026 20:42:17 +0000 Subject: [PATCH] tstest/integration/jswasmtest: add headless-Chromium tests for @tailscale/connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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 Change-Id: Iff9cdee303e3b239924249b5bffb2fd04e02f391 --- .github/workflows/test.yml | 7 + cmd/derper/depaware.txt | 4 +- cmd/derper/derper.go | 2 +- cmd/tsconnect/build-pkg.go | 20 + cmd/tsconnect/common.go | 36 +- cmd/tsconnect/wasmbuild/wasmbuild.go | 153 ++++++ derp/derphttp/derphttp_client.go | 8 +- {cmd/derper => derp/derpserver}/websocket.go | 14 +- flake.nix | 2 +- flakehashes.json | 4 +- go.mod | 8 +- go.sum | 20 +- shell.nix | 2 +- tstest/integration/integration.go | 5 +- tstest/integration/jswasmtest/index.html | 167 +++++++ tstest/integration/jswasmtest/wasm_test.go | 466 ++++++++++++++++++ tstest/integration/testcontrol/testcontrol.go | 8 +- 17 files changed, 905 insertions(+), 21 deletions(-) create mode 100644 cmd/tsconnect/wasmbuild/wasmbuild.go rename {cmd/derper => derp/derpserver}/websocket.go (75%) create mode 100644 tstest/integration/jswasmtest/index.html create mode 100644 tstest/integration/jswasmtest/wasm_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ded7873aa..c2e3d64f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -642,6 +642,13 @@ jobs: run: | ./tool/go run ./cmd/tsconnect --fast-compression build ./tool/go run ./cmd/tsconnect --fast-compression build-pkg + - name: verify Google Chrome is available + run: | + which google-chrome + google-chrome --version + - name: tsconnect js/wasm headless-browser tests + working-directory: src + run: ./tool/go test ./tstest/integration/jswasmtest/ -v -timeout 180s --run-headless-browser-tests - name: Tidy cache working-directory: src shell: bash diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index c927335e4..c1a50d219 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -6,7 +6,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa github.com/axiomhq/hyperloglog from tailscale.com/derp/derpserver github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus 💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus - github.com/coder/websocket from tailscale.com/cmd/derper+ + github.com/coder/websocket from tailscale.com/derp/derpserver+ github.com/coder/websocket/internal/errd from github.com/coder/websocket github.com/coder/websocket/internal/util from github.com/coder/websocket github.com/coder/websocket/internal/xsync from github.com/coder/websocket @@ -115,7 +115,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial tailscale.com/net/tsaddr from tailscale.com/ipn+ tailscale.com/net/udprelay/status from tailscale.com/client/local - tailscale.com/net/wsconn from tailscale.com/cmd/derper + tailscale.com/net/wsconn from tailscale.com/derp/derpserver tailscale.com/paths from tailscale.com/client/local 💣 tailscale.com/safesocket from tailscale.com/client/local tailscale.com/syncs from tailscale.com/cmd/derper+ diff --git a/cmd/derper/derper.go b/cmd/derper/derper.go index 745d887f8..0e3ab1bc2 100644 --- a/cmd/derper/derper.go +++ b/cmd/derper/derper.go @@ -262,7 +262,7 @@ func main() { mux := http.NewServeMux() if *runDERP { derpHandler := derpserver.Handler(s) - derpHandler = addWebSocketSupport(s, derpHandler) + derpHandler = derpserver.AddWebSocketSupport(s, derpHandler) mux.Handle("/derp", derpHandler) } else { mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/tsconnect/build-pkg.go b/cmd/tsconnect/build-pkg.go index 53aacc02e..0817c287d 100644 --- a/cmd/tsconnect/build-pkg.go +++ b/cmd/tsconnect/build-pkg.go @@ -13,6 +13,7 @@ import ( "path" "github.com/tailscale/hujson" + "tailscale.com/cmd/tsconnect/wasmbuild" "tailscale.com/util/precompress" "tailscale.com/version" ) @@ -43,6 +44,10 @@ func runBuildPkg() { log.Fatalf("Could not pre-recompress wasm: %v", err) } + if err := writeBuildInfo(); err != nil { + log.Fatalf("Could not write %s: %v", wasmbuild.BuildInfoFile, err) + } + log.Printf("Generating types...\n") if err := runYarn("pkg-types"); err != nil { log.Fatalf("Type generation failed: %v", err) @@ -90,6 +95,21 @@ func updateVersion() error { return os.WriteFile(path.Join(*pkgDir, "package.json"), packageJSONBytes, 0644) } +// writeBuildInfo writes pkg/build-info.json so tests can detect a stale +// pkg/main.wasm. lastRawWasmSHA256 is set by buildWasm (in common.go) +// just before wasm-opt overwrites the file. +func writeBuildInfo() error { + if lastRawWasmSHA256 == "" { + return fmt.Errorf("lastRawWasmSHA256 unset; buildWasm did not run in non-dev mode") + } + bi := wasmbuild.BuildInfo{RawWasmSHA256: lastRawWasmSHA256} + data, err := json.MarshalIndent(bi, "", " ") + if err != nil { + return err + } + return os.WriteFile(path.Join(*pkgDir, wasmbuild.BuildInfoFile), append(data, '\n'), 0644) +} + func copyReadme() error { readmeBytes, err := os.ReadFile("README.pkg.md") if err != nil { diff --git a/cmd/tsconnect/common.go b/cmd/tsconnect/common.go index bc9e1ed4f..a6192f641 100644 --- a/cmd/tsconnect/common.go +++ b/cmd/tsconnect/common.go @@ -6,7 +6,10 @@ package main import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io" "log" "net" "os" @@ -19,8 +22,14 @@ import ( "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 @@ -228,14 +237,14 @@ func buildWasm(dev bool) ([]byte, error) { // to fail for unclosed files. defer outputFile.Close() - args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"} + 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", "-s -w") + args = append(args, "-trimpath", "-ldflags", wasmbuild.ProdLDFlags) } else if *devControl != "" { args = append(args, "-ldflags", fmt.Sprintf("-X 'main.ControlURL=%v'", *devControl)) } @@ -253,8 +262,16 @@ func buildWasm(dev bool) ([]byte, error) { log.Printf("Built wasm in %v\n", time.Since(start).Round(time.Millisecond)) if !dev { - err := runWasmOpt(outputPath) + // 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) } } @@ -262,6 +279,19 @@ func buildWasm(dev bool) ([]byte, error) { 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) diff --git a/cmd/tsconnect/wasmbuild/wasmbuild.go b/cmd/tsconnect/wasmbuild/wasmbuild.go new file mode 100644 index 000000000..bb2814027 --- /dev/null +++ b/cmd/tsconnect/wasmbuild/wasmbuild.go @@ -0,0 +1,153 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package wasmbuild contains the shared build flags and manifest layout +// used to produce the @tailscale/connect NPM package's main.wasm. It is +// imported both by cmd/tsconnect (which does the build) and by tests +// that verify the produced pkg/main.wasm matches what the current +// source tree would build (see tstest/integration/jswasmtest). +package wasmbuild + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + + "tailscale.com/feature/featuretags" +) + +// baseTags are the non-featuretag build tags always set for the wasm +// build. Featuretag omits (ts_omit_*) are computed dynamically by +// [Tags] from [Keep] using the [featuretags] registry. +// +// Note: nethttpomithttp2 is intentionally NOT included: control/ts2021 +// (since commit 1d93bdce2, Oct 2025) requires HTTP/2 from net/http's +// bundled implementation. Excluding it leaves the wasm client unable +// to negotiate with any control plane. +var baseTags = []string{ + "tailscale_go", + "osusergo", + "netgo", + "omitidna", + "omitpemdecrypt", +} + +// Keep is the set of feature/featuretags tags the cmd/tsconnect/wasm +// build needs LINKED. Every other feature in [featuretags.Features] is +// excluded via its ts_omit_ build tag (computed by [Tags]). +// Transitive dependencies of entries in Keep are pulled in +// automatically via [featuretags.Requires]. +// +// Adding an entry here grows the wasm bundle. Removing one strips it. +// The init() below panics if any entry is unknown to feature/featuretags, +// so a rename / removal in that registry fails loudly here. +// +// Notably absent (server-only or otherwise meaningless in a browser): +// - "ssh": controls the SSH *server* (feature/ssh registers +// ssh/tailssh). The wasm acts as an SSH *client* using +// golang.org/x/crypto/ssh directly; no featuretag gates that. +// - "portmapper", "debugportmapper": js/wasm has no UDP sockets, +// can't speak NAT-PMP / PCP / UPnP. +// - "captiveportal": the browser handles captive portal detection +// in front of us. +// - "syspolicy": no MDM in a browser. +// - "drive", "taildrop", "peerapi*": no local filesystem. +// - "clientupdate": no binary self-update. +// - "dbus", "resolved", "networkmanager", "iptables", "linkspeed", +// "linuxdnsfight", "listenrawdisco", "osrouter", "synology", +// "systray", "tundevstats", "wakeonlan": OS integrations not +// applicable to a browser-hosted client. +// - "aws", "cloud", "kube", "bird", "appconnectors", "conn25", +// "relayserver", "serve", "acme", "tap", "tpm", "doctor", +// "advertiseroutes", "advertiseexitnode", "useroutes", +// "useexitnode": server-side or otherwise out of scope for the +// SSH-in-browser / fetch-in-browser use case. +var Keep = []featuretags.FeatureTag{ + "c2n", // control-to-node mechanism the control client invokes + "dns", // MagicDNS resolution in-process + "health", // ipnstate/ipnlocal reference health warnables pervasively + "ipnbus", // notification bus for state/netmap callbacks + "logtail", // log upload (browser console + remote) + "netstack", // userspace networking; wasm has no kernel TUN +} + +func init() { + for _, ft := range Keep { + if _, ok := featuretags.Features[ft]; !ok { + panic(fmt.Sprintf("wasmbuild.Keep references unknown feature tag %q; "+ + "did feature/featuretags rename or remove it?", ft)) + } + } +} + +// ProdLDFlags is the -ldflags value used in production wasm builds. +// -s strips the symbol table, -w strips DWARF, both to shrink the +// shipped artifact. +const ProdLDFlags = "-s -w" + +// BuildInfoFile is the basename of the JSON manifest that build-pkg +// writes alongside main.wasm, recording the sha256 of the raw +// (pre-wasm-opt) go-build output. Tests use this to detect a stale +// pkg/main.wasm without having to re-run wasm-opt themselves. +const BuildInfoFile = "build-info.json" + +// BuildInfo is the JSON contents of [BuildInfoFile]. +type BuildInfo struct { + // RawWasmSHA256 is the lowercase hex sha256 of the wasm bytes as + // they came out of `go build` (before wasm-opt was run in place). + RawWasmSHA256 string `json:"raw_wasm_sha256"` +} + +// Tags returns the joined -tags value for the wasm build: [baseTags] +// plus a ts_omit_ for every entry in [featuretags.Features] +// that is not transitively required by [Keep]. +// +// The result is sorted so that the same source tree always produces +// the same string (and therefore the same wasm bytes, given identical +// inputs to `go build`). +func Tags() string { + keep := map[featuretags.FeatureTag]bool{} + for _, ft := range Keep { + for dep := range featuretags.Requires(ft) { + keep[dep] = true + } + } + tags := slices.Clone(baseTags) + for ft := range featuretags.Features { + if ft == "" || !ft.IsOmittable() { + continue + } + if !keep[ft] { + tags = append(tags, ft.OmitTag()) + } + } + slices.Sort(tags) + return strings.Join(tags, ",") +} + +// ProdCommand returns an *exec.Cmd that runs `go build` for +// cmd/tsconnect/wasm with the production flags, writing the raw +// (pre-wasm-opt) wasm to outputPath. GOOS=js GOARCH=wasm is set in the +// command's environment. The caller is responsible for wiring +// Stdin/Stdout/Stderr and invoking Run. +// +// If goBin is empty, runtime.GOROOT()+"/bin/go" is used so that the +// build runs under the same toolchain that built the caller. +func ProdCommand(goBin, outputPath string) *exec.Cmd { + if goBin == "" { + goBin = filepath.Join(runtime.GOROOT(), "bin", "go") + } + cmd := exec.Command(goBin, "build", + "-tags", Tags(), + "-trimpath", + "-ldflags", ProdLDFlags, + "-o", outputPath, + "tailscale.com/cmd/tsconnect/wasm", + ) + cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + return cmd +} diff --git a/derp/derphttp/derphttp_client.go b/derp/derphttp/derphttp_client.go index 3c8408e95..e213ca319 100644 --- a/derp/derphttp/derphttp_client.go +++ b/derp/derphttp/derphttp_client.go @@ -280,10 +280,16 @@ func (c *Client) urlString(node *tailcfg.DERPNode) string { return c.url.String() } proto := "https" + defaultPort := 443 if debugUseDERPHTTP() { proto = "http" + defaultPort = 80 } - return fmt.Sprintf("%s://%s/derp", proto, node.HostName) + host := node.HostName + if node.DERPPort != 0 && node.DERPPort != defaultPort { + host = net.JoinHostPort(host, fmt.Sprint(node.DERPPort)) + } + return fmt.Sprintf("%s://%s/derp", proto, host) } // AddressFamilySelector decides whether IPv6 is preferred for diff --git a/cmd/derper/websocket.go b/derp/derpserver/websocket.go similarity index 75% rename from cmd/derper/websocket.go rename to derp/derpserver/websocket.go index 1929f1690..8bad080c0 100644 --- a/cmd/derper/websocket.go +++ b/derp/derpserver/websocket.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -package main +package derpserver import ( "bufio" @@ -11,14 +11,20 @@ import ( "strings" "github.com/coder/websocket" - "tailscale.com/derp/derpserver" "tailscale.com/net/wsconn" ) var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts") -// addWebSocketSupport returns a Handle wrapping base that adds WebSocket server support. -func addWebSocketSupport(s *derpserver.Server, base http.Handler) http.Handler { +// AddWebSocketSupport returns an http.Handler wrapping base that adds +// WebSocket-DERP support. WebSocket-DERP requests (those with an Upgrade: +// websocket header and a "derp" Sec-WebSocket-Protocol value) are +// handled here; all other requests pass through to base. +// +// The browser-side Tailscale client (cmd/tsconnect/wasm) can only reach DERP +// via WebSocket, so any DERP server intended to be reachable from browsers +// must wrap derpserver.Handler with this function. +func AddWebSocketSupport(s *Server, base http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { up := strings.ToLower(r.Header.Get("Upgrade")) diff --git a/flake.nix b/flake.nix index e2c237f42..9a9e9315d 100644 --- a/flake.nix +++ b/flake.nix @@ -164,4 +164,4 @@ }); }; } -# nix-direnv cache busting line: sha256-Xwm+ZLNqd2k7c2GFQJ2Pf/xuFLMcXhYl5I/YVgS9V4U= +# nix-direnv cache busting line: sha256-HCYBBM2rp4wuwS6x4fvbpJ2R9WHoT5tC1t7d6jtj/n8= diff --git a/flakehashes.json b/flakehashes.json index b5f6234e2..d9d3d267c 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -4,7 +4,7 @@ "sri": "sha256-HeD70CytKL0Ks/VDqMU73bN8fxpWkNc6mNgNr9PEO7k=" }, "vendor": { - "goModSum": "sha256-qAO4LAc1PwV43rr/kDsfYwkxeXAelP5DoNSZiCkwcpU=", - "sri": "sha256-Xwm+ZLNqd2k7c2GFQJ2Pf/xuFLMcXhYl5I/YVgS9V4U=" + "goModSum": "sha256-EU/dC6ei0SKQJUBAkRseCLkaU2YLPS7EBJxCqXxEfm8=", + "sri": "sha256-HCYBBM2rp4wuwS6x4fvbpJ2R9WHoT5tC1t7d6jtj/n8=" } } diff --git a/go.mod b/go.mod index 89038db40..bf52154b7 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5 github.com/bradfitz/monogok v0.0.0-20260429173803-229ef7981a6b github.com/bramvdbogaerde/go-scp v1.4.0 + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc + github.com/chromedp/chromedp v0.15.1 github.com/cilium/ebpf v0.16.0 github.com/coder/websocket v1.8.12 github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 @@ -38,7 +40,7 @@ require ( github.com/frankban/quicktest v1.14.6 github.com/fxamacker/cbor/v2 v2.9.0 github.com/gaissmai/bart v0.26.1 - github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 github.com/go-logr/zapr v1.3.0 github.com/go-ole/go-ole v1.3.0 github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 @@ -167,6 +169,7 @@ require ( github.com/catenacyber/perfsprint v0.7.1 // indirect github.com/ccojocar/zxcvbn-go v1.0.2 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/ckaznocha/intrange v0.1.0 // indirect github.com/containerd/containerd v1.7.29 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -189,6 +192,9 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-yaml v1.12.0 // indirect github.com/gokrazy/gokapi v0.0.0-20250222071133-506fdb322775 // indirect github.com/gokrazy/internal v0.0.0-20251208203110-3c1aa9087c82 // indirect diff --git a/go.sum b/go.sum index b5e8950c3..f1b77c85c 100644 --- a/go.sum +++ b/go.sum @@ -243,6 +243,12 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -403,8 +409,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= -github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= -github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -471,6 +477,12 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM= github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= @@ -801,6 +813,8 @@ github.com/ldez/gomoddirectives v0.2.3 h1:y7MBaisZVDYmKvt9/l1mjNCiSA1BVn34U0ObUc github.com/ldez/gomoddirectives v0.2.3/go.mod h1:cpgBogWITnCfRq2qGoDkKMEVSaarhdBr6g8G04uz6d0= github.com/ldez/tagliatelle v0.5.0 h1:epgfuYt9v0CG3fms0pEgIMNPuFf/LpPIfjk4kyqSioo= github.com/ldez/tagliatelle v0.5.0/go.mod h1:rj1HmWiL1MiKQuOONhd09iySTEkUuE/8+5jtPYz9xa4= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leonklingele/grouper v1.1.1 h1:suWXRU57D4/Enn6pXR0QVqqWWrnJ9Osrz+5rjt8ivzU= @@ -923,6 +937,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= diff --git a/shell.nix b/shell.nix index bd81d79f0..dd01fecb7 100644 --- a/shell.nix +++ b/shell.nix @@ -16,4 +16,4 @@ ) { src = ./.; }).shellNix -# nix-direnv cache busting line: sha256-Xwm+ZLNqd2k7c2GFQJ2Pf/xuFLMcXhYl5I/YVgS9V4U= +# nix-direnv cache busting line: sha256-HCYBBM2rp4wuwS6x4fvbpJ2R9WHoT5tC1t7d6jtj/n8= diff --git a/tstest/integration/integration.go b/tstest/integration/integration.go index 861ec808d..190360950 100644 --- a/tstest/integration/integration.go +++ b/tstest/integration/integration.go @@ -315,7 +315,10 @@ func RunDERPAndSTUN(t testing.TB, logf logger.Logf, ipAddress string) (derpMap * t.Fatal(err) } - httpsrv := httptest.NewUnstartedServer(derpserver.Handler(d)) + // 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) diff --git a/tstest/integration/jswasmtest/index.html b/tstest/integration/jswasmtest/index.html new file mode 100644 index 000000000..205b9231f --- /dev/null +++ b/tstest/integration/jswasmtest/index.html @@ -0,0 +1,167 @@ + + + + + + @tailscale/connect jswasmtest + + +
loading...
+ + + diff --git a/tstest/integration/jswasmtest/wasm_test.go b/tstest/integration/jswasmtest/wasm_test.go new file mode 100644 index 000000000..bc0906836 --- /dev/null +++ b/tstest/integration/jswasmtest/wasm_test.go @@ -0,0 +1,466 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package jswasmtest contains headless-browser tests for the +// @tailscale/connect NPM package, built by cmd/tsconnect from +// tailscale.com/cmd/tsconnect/wasm (the js/wasm build of the client). +// +// To run locally: +// +// ./tool/go run ./cmd/tsconnect build-pkg +// ./tool/go test ./tstest/integration/jswasmtest/ -v --run-headless-browser-tests +// +// Tests are skipped unless --run-headless-browser-tests is set. When the +// flag is set, tests are also skipped if cmd/tsconnect/pkg/ has not been +// built, and fail with t.Error if no chromium binary is found in $PATH +// (honoring $CHROME_BIN as an override). On macOS, /Applications/Google +// Chrome.app and /Applications/Chromium.app are also tried. +// +// macOS note: launching Chrome from a terminal as a child process may +// trigger the system "App Management" privacy prompt on Sonoma and later +// (a one-shot prompt that says " was prevented from modifying +// apps on your Mac"). Grant the terminal app this permission under +// System Settings → Privacy & Security → App Management and re-run. +package jswasmtest + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + cdpruntime "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" + "tailscale.com/cmd/tsconnect/wasmbuild" + "tailscale.com/ipn/store/mem" + "tailscale.com/tsnet" + "tailscale.com/tstest/integration" + "tailscale.com/tstest/integration/testcontrol" +) + +// pkgDir is the path to cmd/tsconnect/pkg/ (the directory written by +// `go run ./cmd/tsconnect build-pkg`), relative to this test file's +// directory (the cwd `go test` sets). +const pkgDir = "../../../cmd/tsconnect/pkg" + +var runHeadlessBrowserTests = flag.Bool("run-headless-browser-tests", false, + "run tests that require a headless browser (Chromium / Google Chrome)") + +// preflight returns the chromium binary path, or fails / skips the test as +// appropriate. Tests skip if --run-headless-browser-tests is not set or if +// cmd/tsconnect/pkg/ has not been built. They t.Error if the flag is set +// but no chromium binary is on $PATH. +func preflight(t *testing.T) (chromiumBin string) { + t.Helper() + if !*runHeadlessBrowserTests { + t.Skip("skipping headless-browser test; set --run-headless-browser-tests to run") + } + if _, err := os.Stat(filepath.Join(pkgDir, "main.wasm")); err != nil { + t.Skipf("cmd/tsconnect/pkg/ not built; run "+ + "`./tool/go run ./cmd/tsconnect build-pkg` first: %v", err) + } + checkPkgFreshness(t) + if t.Failed() { + return "" + } + bin := findChromium() + if bin == "" { + t.Errorf("no chromium / chromium-browser / google-chrome binary in $PATH " + + "(set $CHROME_BIN to override)") + return "" + } + return bin +} + +// launchChrome boots a headless chromium under chromedp and returns a context +// whose cancellation tears down the browser. +func launchChrome(t *testing.T, bin string, extraFlags map[string]any) context.Context { + t.Helper() + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(bin), + chromedp.Flag("headless", "new"), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + ) + for k, v := range extraFlags { + opts = append(opts, chromedp.Flag(k, v)) + } + allocCtx, cancelAlloc := chromedp.NewExecAllocator(t.Context(), opts...) + t.Cleanup(cancelAlloc) + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) + t.Cleanup(cancelBrowser) + + // Pipe browser console output and uncaught exceptions into go test logs. + chromedp.ListenTarget(browserCtx, func(ev any) { + switch ev := ev.(type) { + case *cdpruntime.EventConsoleAPICalled: + var sb strings.Builder + for i, arg := range ev.Args { + if i > 0 { + sb.WriteByte(' ') + } + if len(arg.Value) > 0 { + sb.Write(arg.Value) + } else { + sb.WriteString(arg.Description) + } + } + t.Logf("[chrome console.%s] %s", ev.Type, sb.String()) + case *cdpruntime.EventExceptionThrown: + t.Logf("[chrome exception] %s", ev.ExceptionDetails.Text) + } + }) + return browserCtx +} + +// TestCreateIPN loads pkg.js into a real browser, calls createIPN with a +// junk auth key, and verifies that the documented public API surface is +// present on both the module exports and the returned IPN object. It does +// no control-plane traffic. +func TestCreateIPN(t *testing.T) { + chromiumBin := preflight(t) + if t.Failed() { + return + } + + mux := http.NewServeMux() + mux.Handle("/_pkg/", http.StripPrefix("/_pkg/", http.FileServer(http.Dir(pkgDir)))) + mux.Handle("/", http.FileServer(http.Dir("."))) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + browserCtx := launchChrome(t, chromiumBin, nil) + runCtx, cancelRun := context.WithTimeout(browserCtx, 60*time.Second) + t.Cleanup(cancelRun) + + var ( + done bool + imported bool + importErrors []string + runtimeErrs []string + panics []string + exports map[string]string + ipnMethods map[string]string + instantiateError string + ) + + if err := chromedp.Run(runCtx, + chromedp.Navigate(srv.URL+"/index.html"), + chromedp.Poll("window.tsTest && window.tsTest.done === true", &done, + chromedp.WithPollingTimeout(45*time.Second)), + chromedp.Evaluate("window.tsTest.imported", &imported), + chromedp.Evaluate("window.tsTest.importErrors", &importErrors), + chromedp.Evaluate("window.tsTest.exports", &exports), + chromedp.Evaluate("window.tsTest.instantiateError || ''", &instantiateError), + chromedp.Evaluate("window.tsTest.ipnMethods", &ipnMethods), + chromedp.Evaluate("window.tsTest.runtimeErrors", &runtimeErrs), + chromedp.Evaluate("window.tsTest.panics", &panics), + ); err != nil { + t.Fatalf("chromedp run: %v", err) + } + + if !done { + t.Fatalf("page never set window.tsTest.done = true") + } + if !imported { + t.Fatalf("pkg.js import failed: %v", importErrors) + } + if instantiateError != "" { + t.Fatalf("createIPN() rejected: %s", instantiateError) + } + + wantExports := map[string]string{ + "createIPN": "function", + "runSSHSession": "function", + } + for name, want := range wantExports { + if got := exports[name]; got != want { + t.Errorf("typeof pkg.%s = %q; want %q", name, got, want) + } + } + + wantIPN := []string{"run", "login", "logout", "ssh", "fetch"} + for _, name := range wantIPN { + if got := ipnMethods[name]; got != "function" { + t.Errorf("createIPN() result: typeof ipn.%s = %q; want %q", + name, got, "function") + } + } + + for _, e := range panics { + t.Errorf("WASM panic handler invoked: %s", e) + } + for _, e := range runtimeErrs { + // The WASM may emit non-fatal console errors (e.g. failed log uploads, + // no DERP, etc.); only fail on errors that look like a real crash. + if strings.Contains(e, "RuntimeError") || strings.Contains(e, "panic:") { + t.Errorf("page runtime error: %s", e) + } else { + t.Logf("benign page runtime message: %s", e) + } + } +} + +// TestFetchTailnetPeer wires a full local control-plane world +// (testcontrol + DERP + a tsnet.Server peer) and verifies that the +// browser-side WASM client can join the same tailnet over WebSocket +// transport and then ipn.fetch() an HTTP service hosted on the tsnet peer. +// +// The transport stack exercised by this test (browser-only): +// - control plane noise upgrade over ws:// (via control/controlhttp/client_js.go) +// - DERP relay over wss:// (via derp/derphttp + derpserver.AddWebSocketSupport) +// - ipn.fetch dials via netstack through DERP to the tsnet peer +// +// The DERP server uses a self-signed httptest TLS cert; Chromium is +// launched with --ignore-certificate-errors so the WSS connect succeeds. +func TestFetchTailnetPeer(t *testing.T) { + chromiumBin := preflight(t) + if t.Failed() { + return + } + + const authKey = "tskey-pkgtest-not-a-real-key" + const wantBody = "hello-from-tsnet-pkgtest" + + derpMap := integration.RunDERPAndSTUN(t, t.Logf, "127.0.0.1") + + control := &testcontrol.Server{ + DERPMap: derpMap, + Logf: t.Logf, + RequireAuthKey: authKey, + AllOnline: true, + } + + // Single-origin HTTP server: static fixtures + pkg + testcontrol all on + // one origin so the browser's WebSocket-upgrade dial to /ts2021 stays + // same-origin and so the WASM can configure controlURL = page origin. + // + // Only route testcontrol's known paths to it; everything else returns + // 404. testcontrol's default-handler panics on any unrecognized request, + // and Chromium spontaneously fetches /favicon.ico and similar browser + // chrome that would otherwise crash the test. + mux := http.NewServeMux() + mux.Handle("/_pkg/", http.StripPrefix("/_pkg/", http.FileServer(http.Dir(pkgDir)))) + mux.Handle("/_fixture/", http.StripPrefix("/_fixture/", http.FileServer(http.Dir(".")))) + mux.Handle("/key", control) + mux.Handle("/ts2021", control) + mux.Handle("/machine/", control) + mux.Handle("/c2n/", control) + mux.HandleFunc("/", http.NotFound) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + peer := &tsnet.Server{ + Dir: t.TempDir(), + ControlURL: srv.URL, + AuthKey: authKey, + Hostname: "tsnetpeer", + Ephemeral: true, + Logf: t.Logf, + Store: new(mem.Store), + } + t.Cleanup(func() { peer.Close() }) + + upCtx, cancelUp := context.WithTimeout(t.Context(), 30*time.Second) + defer cancelUp() + status, err := peer.Up(upCtx) + if err != nil { + t.Fatalf("tsnet peer Up: %v", err) + } + if len(status.TailscaleIPs) == 0 { + t.Fatalf("tsnet peer has no TailscaleIPs") + } + peerIP := status.TailscaleIPs[0] + t.Logf("tsnet peer up at %v", peerIP) + + ln, err := peer.Listen("tcp", ":80") + if err != nil { + t.Fatalf("tsnet peer Listen :80: %v", err) + } + t.Cleanup(func() { ln.Close() }) + go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, wantBody) + })) + + browserCtx := launchChrome(t, chromiumBin, map[string]any{ + "ignore-certificate-errors": true, + }) + runCtx, cancelRun := context.WithTimeout(browserCtx, 120*time.Second) + t.Cleanup(cancelRun) + + peerURL := fmt.Sprintf("http://%s/", peerIP) + pageURL := srv.URL + "/_fixture/index.html?" + url.Values{ + "mode": {"fetch"}, + "controlURL": {srv.URL}, + "authKey": {authKey}, + "peerURL": {peerURL}, + "hostname": {"browser-pkgtest"}, + }.Encode() + t.Logf("navigating to %s", pageURL) + + var ( + done bool + imported bool + importErrors []string + states []string + browseURLs []string + runError string + fetchError string + fetchResult map[string]any + panics []string + runtimeErrs []string + ) + + if err := chromedp.Run(runCtx, + chromedp.Navigate(pageURL), + chromedp.Poll("window.tsTest && window.tsTest.done === true", &done, + chromedp.WithPollingTimeout(100*time.Second)), + chromedp.Evaluate("window.tsTest.imported", &imported), + chromedp.Evaluate("window.tsTest.importErrors", &importErrors), + chromedp.Evaluate("window.tsTest.states", &states), + chromedp.Evaluate("window.tsTest.browseURLs", &browseURLs), + chromedp.Evaluate("window.tsTest.runError || ''", &runError), + chromedp.Evaluate("window.tsTest.fetchError || ''", &fetchError), + chromedp.Evaluate("window.tsTest.fetchResult", &fetchResult), + chromedp.Evaluate("window.tsTest.panics", &panics), + chromedp.Evaluate("window.tsTest.runtimeErrors", &runtimeErrs), + ); err != nil { + t.Fatalf("chromedp run: %v", err) + } + + t.Logf("browser state transitions: %v", states) + if len(browseURLs) > 0 { + t.Logf("browser notifyBrowseToURL: %v", browseURLs) + } + + if !done { + t.Fatalf("page never set window.tsTest.done = true") + } + if !imported { + t.Fatalf("pkg.js import failed: %v", importErrors) + } + if runError != "" { + t.Fatalf("ipn.run did not reach Running: %s (states=%v)", runError, states) + } + if fetchError != "" { + t.Fatalf("ipn.fetch failed: %s (states=%v)", fetchError, states) + } + if fetchResult == nil { + t.Fatalf("fetchResult is nil; states=%v", states) + } + + if statusCode, _ := fetchResult["status"].(float64); int(statusCode) != http.StatusOK { + t.Errorf("fetch status = %v; want %d", fetchResult["status"], http.StatusOK) + } + body, _ := fetchResult["body"].(string) + if !strings.Contains(body, wantBody) { + t.Errorf("fetch body = %q; want substring %q", body, wantBody) + } + + for _, e := range panics { + t.Errorf("WASM panic handler invoked: %s", e) + } + for _, e := range runtimeErrs { + if strings.Contains(e, "RuntimeError") || strings.Contains(e, "panic:") { + t.Errorf("page runtime error: %s", e) + } else { + t.Logf("benign page runtime message: %s", e) + } + } +} + +func findChromium() string { + if p := os.Getenv("CHROME_BIN"); p != "" { + return p + } + for _, name := range []string{ + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "chrome", + } { + if p, err := exec.LookPath(name); err == nil { + return p + } + } + if runtime.GOOS == "darwin" { + // On macOS, Chrome installs as an .app bundle whose executable is + // not on $PATH. + for _, p := range []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + } { + if _, err := os.Stat(p); err == nil { + return p + } + } + } + return "" +} + +// checkPkgFreshness fails the test if pkg/main.wasm was built from source +// that differs from what `go build` would produce against the current +// working tree. The mechanism is to do a fresh `go build` of +// cmd/tsconnect/wasm with the same flags build-pkg used, sha256 the +// output, and compare against pkg/build-info.json's recorded raw sha256. +// The Go build cache makes the rebuild nearly instant when CI just ran +// build-pkg, and equally fast locally between iterations. +func checkPkgFreshness(t *testing.T) { + t.Helper() + biPath := filepath.Join(pkgDir, wasmbuild.BuildInfoFile) + biBytes, err := os.ReadFile(biPath) + if err != nil { + t.Fatalf("reading %s: %v\nRe-run `./tool/go run ./cmd/tsconnect build-pkg`.", biPath, err) + } + var bi wasmbuild.BuildInfo + if err := json.Unmarshal(biBytes, &bi); err != nil { + t.Fatalf("parsing %s: %v", biPath, err) + } + + tmpWasm := filepath.Join(t.TempDir(), "main.wasm") + cmd := wasmbuild.ProdCommand("", tmpWasm) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + t.Logf("checking pkg freshness: %s", strings.Join(cmd.Args, " ")) + if err := cmd.Run(); err != nil { + t.Fatalf("go build cmd/tsconnect/wasm: %v", err) + } + freshSum, err := sha256File(tmpWasm) + if err != nil { + t.Fatalf("sha256 %s: %v", tmpWasm, err) + } + if freshSum != bi.RawWasmSHA256 { + t.Fatalf("pkg/main.wasm is stale\n"+ + " build-info.json raw_wasm_sha256: %s\n"+ + " freshly built (same -tags/-ldflags): %s\n"+ + "Re-run `./tool/go run ./cmd/tsconnect build-pkg`.", + bi.RawWasmSHA256, freshSum) + } +} + +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 +} diff --git a/tstest/integration/testcontrol/testcontrol.go b/tstest/integration/testcontrol/testcontrol.go index c96b1ed33..de00a43e4 100644 --- a/tstest/integration/testcontrol/testcontrol.go +++ b/tstest/integration/testcontrol/testcontrol.go @@ -430,8 +430,12 @@ type peerMachinePublicContextKey struct{} func (s *Server) serveNoiseUpgrade(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - if r.Method != "POST" { - http.Error(w, "POST required", 400) + // Allow GET for WebSocket-based clients (e.g. cmd/tsconnect/wasm) in + // addition to POST for the raw HTTP-upgrade path. AcceptHTTP routes by + // the Upgrade header and the underlying websocket library enforces + // GET for WebSocket upgrades. + if r.Method != "POST" && r.Method != "GET" { + http.Error(w, "POST or GET required", 400) return }