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 }